diff --git a/package.json b/package.json index e6341c09..23584782 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "code-oss-dev", - "version": "1.99.3", + "version": "2.0.0 - Apple Foundation Model + Mistral Fetch support", "distro": "21c8d8ea1e46d97c5639a7cabda6c0e063cc8dd5", "author": { "name": "Microsoft Corporation" diff --git a/product.json b/product.json index b939424b..f3db6c6e 100644 --- a/product.json +++ b/product.json @@ -1,7 +1,7 @@ { "nameShort": "Void", "nameLong": "Void", - "voidVersion": "1.4.9", + "voidVersion": "2.0.0 - Apple Foundation Model + Mistral Fetch support", "voidRelease": "0044", "applicationName": "void", "dataFolderName": ".void-editor", diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index c3d2dfe5..755b259f 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -133,6 +133,7 @@ import { LLMMessageChannel } from '../../workbench/contrib/void/electron-main/se import { VoidSCMService } from '../../workbench/contrib/void/electron-main/voidSCMMainService.js'; import { IVoidSCMService } from '../../workbench/contrib/void/common/voidSCMTypes.js'; import { MCPChannel } from '../../workbench/contrib/void/electron-main/mcpChannel.js'; +import { startAfmIfNeeded } from '../../workbench/contrib/void/electron-main/afmBackendService.js'; /** * The main VS Code application. There will only ever be one instance, * even if the user starts many instances (e.g. from the command line). @@ -1389,6 +1390,9 @@ export class CodeApplication extends Disposable { private afterWindowOpen(): void { + // Void: start afm (Apple Foundation Model) backend if available + this.startAfmBackend(); + // Windows: mutex this.installMutex(); @@ -1415,6 +1419,13 @@ export class CodeApplication extends Disposable { } } + private startAfmBackend(): void { + startAfmIfNeeded( + (cb) => Event.once(this.lifecycleMainService.onWillShutdown)(() => cb()), + (msg) => this.logService.info(msg), + ).catch((err) => this.logService.error(`[Void] afm: unexpected error: ${err}`)); + } + private async installMutex(): Promise { const win32MutexName = this.productService.win32MutexName; if (isWindows && win32MutexName) { diff --git a/src/vs/workbench/contrib/void/common/modelCapabilities.ts b/src/vs/workbench/contrib/void/common/modelCapabilities.ts index f5d4dfb4..57111ee1 100644 --- a/src/vs/workbench/contrib/void/common/modelCapabilities.ts +++ b/src/vs/workbench/contrib/void/common/modelCapabilities.ts @@ -61,6 +61,9 @@ export const defaultProviderSettings = { region: 'us-east-1', // add region setting endpoint: '', // optionally allow overriding default }, + macLocalAFM: { + endpoint: 'http://localhost:9999', + }, } as const @@ -152,6 +155,7 @@ export const defaultModelsOfProvider = { microsoftAzure: [], awsBedrock: [], liteLLM: [], + macLocalAFM: [], // autodetected via afm list endpoint } as const satisfies Record @@ -1459,6 +1463,22 @@ const openRouterSettings: VoidStaticProviderInfo = { +// ---------------- MAC LOCAL AFM (Apple Foundation Model / MLX via afm) ---------------- +const macLocalAFMSettings: VoidStaticProviderInfo = { + modelOptionsFallback: (modelName) => extensiveModelOptionsFallback(modelName, { downloadable: false }), + modelOptions: { + 'foundation': { + contextWindow: 4_096, + reservedOutputTokenSpace: 2_048, + cost: { input: 0, output: 0 }, + downloadable: false, + supportsFIM: false, + supportsSystemMessage: 'system-role', + reasoningCapabilities: false, + }, + }, +} + // ---------------- model settings of everything above ---------------- const modelSettingsOfProvider: { [providerName in ProviderName]: VoidStaticProviderInfo } = { @@ -1484,6 +1504,7 @@ const modelSettingsOfProvider: { [providerName in ProviderName]: VoidStaticProvi googleVertex: googleVertexSettings, microsoftAzure: microsoftAzureSettings, awsBedrock: awsBedrockSettings, + macLocalAFM: macLocalAFMSettings, } as const diff --git a/src/vs/workbench/contrib/void/common/refreshModelService.ts b/src/vs/workbench/contrib/void/common/refreshModelService.ts index c4bfe115..f1e31aca 100644 --- a/src/vs/workbench/contrib/void/common/refreshModelService.ts +++ b/src/vs/workbench/contrib/void/common/refreshModelService.ts @@ -7,7 +7,7 @@ import { IVoidSettingsService } from './voidSettingsService.js'; import { ILLMMessageService } from './sendLLMMessageService.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; -import { RefreshableProviderName, refreshableProviderNames, SettingsOfProvider } from './voidSettingsTypes.js'; +import { localProviderNames, RefreshableProviderName, refreshableProviderNames, SettingsOfProvider } from './voidSettingsTypes.js'; import { OllamaModelResponse, OpenaiCompatibleModelResponse } from './sendLLMMessageTypes.js'; import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; @@ -47,6 +47,8 @@ const refreshBasedOn: { [k in RefreshableProviderName]: (keyof SettingsOfProvide ollama: ['_didFillInProviderSettings', 'endpoint'], vLLM: ['_didFillInProviderSettings', 'endpoint'], lmStudio: ['_didFillInProviderSettings', 'endpoint'], + macLocalAFM: ['_didFillInProviderSettings', 'endpoint'], + mistral: ['_didFillInProviderSettings', 'apiKey'], // openAICompatible: ['_didFillInProviderSettings', 'endpoint', 'apiKey'], } const REFRESH_INTERVAL = 5_000 @@ -144,6 +146,8 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ ollama: { state: 'init', timeoutId: null }, vLLM: { state: 'init', timeoutId: null }, lmStudio: { state: 'init', timeoutId: null }, + macLocalAFM: { state: 'init', timeoutId: null }, + mistral: { state: 'init', timeoutId: null }, } @@ -154,8 +158,10 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ this._setRefreshState(providerName, 'refreshing', options) + const isLocalProvider = (localProviderNames as string[]).includes(providerName) const autoPoll = () => { - if (this.voidSettingsService.state.globalSettings.autoRefreshModels) { + // only continuously poll local providers (cloud providers like Mistral don't need it) + if (isLocalProvider && this.voidSettingsService.state.globalSettings.autoRefreshModels) { // resume auto-polling const timeoutId = setTimeout(() => this.startRefreshingModels(providerName, autoOptions), REFRESH_INTERVAL) this._setTimeoutId(providerName, timeoutId) @@ -171,10 +177,12 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ this.voidSettingsService.setAutodetectedModels( providerName, models.map(model => { - if (providerName === 'ollama') return (model as OllamaModelResponse).name; - else if (providerName === 'vLLM') return (model as OpenaiCompatibleModelResponse).id; - else if (providerName === 'lmStudio') return (model as OpenaiCompatibleModelResponse).id; - else throw new Error('refreshMode fn: unknown provider', providerName); + if (providerName === 'ollama') return (model as OllamaModelResponse).name; + else if (providerName === 'vLLM') return (model as OpenaiCompatibleModelResponse).id; + else if (providerName === 'lmStudio') return (model as OpenaiCompatibleModelResponse).id; + else if (providerName === 'macLocalAFM') return (model as OpenaiCompatibleModelResponse).id; + else if (providerName === 'mistral') return (model as OpenaiCompatibleModelResponse).id; + else throw new Error('refreshMode fn: unknown provider', providerName); }), { enableProviderOnSuccess: options.enableProviderOnSuccess, hideRefresh: options.doNotFire } ) diff --git a/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts b/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts index 38497c60..35e66ec3 100644 --- a/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts +++ b/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts @@ -16,9 +16,12 @@ type UnionOfKeys = T extends T ? keyof T : never; export type ProviderName = keyof typeof defaultProviderSettings export const providerNames = Object.keys(defaultProviderSettings) as ProviderName[] -export const localProviderNames = ['ollama', 'vLLM', 'lmStudio'] satisfies ProviderName[] // all local names +export const localProviderNames = ['ollama', 'vLLM', 'lmStudio', 'macLocalAFM'] satisfies ProviderName[] // all local names (no API key, endpoint-based) export const nonlocalProviderNames = providerNames.filter((name) => !(localProviderNames as string[]).includes(name)) // all non-local names +// providers whose model list can be fetched and auto-refreshed (local + cloud providers with a /v1/models endpoint) +export const refreshableProviderNames = ['ollama', 'vLLM', 'lmStudio', 'macLocalAFM', 'mistral'] satisfies ProviderName[] + type CustomSettingName = UnionOfKeys type CustomProviderSettings = { [k in CustomSettingName]: k extends keyof typeof defaultProviderSettings[providerName] ? string : undefined @@ -106,6 +109,9 @@ export const displayInfoOfProviderName = (providerName: ProviderName): DisplayIn else if (providerName === 'awsBedrock') { return { title: 'AWS Bedrock', } } + else if (providerName === 'macLocalAFM') { + return { title: 'Apple Foundation Model', } + } throw new Error(`descOfProviderName: Unknown provider name: "${providerName}"`) } @@ -128,6 +134,7 @@ export const subTextMdOfProviderName = (providerName: ProviderName): string => { if (providerName === 'vLLM') return 'Read more about custom [Endpoints here](https://docs.vllm.ai/en/latest/getting_started/quickstart.html#openai-compatible-server).' if (providerName === 'lmStudio') return 'Read more about custom [Endpoints here](https://lmstudio.ai/docs/app/api/endpoints/openai).' if (providerName === 'liteLLM') return 'Read more about endpoints [here](https://docs.litellm.ai/docs/providers/openai_compatible).' + if (providerName === 'macLocalAFM') return 'Run Apple\'s on-device Foundation Model or any MLX model locally. Install [afm](https://github.com/scouzi1966/maclocal-api) and start with `afm` (Foundation Model) or `afm mlx -m ` (MLX). Requires macOS 26+, Apple Silicon, and Apple Intelligence enabled.' throw new Error(`subTextMdOfProviderName: Unknown provider name: "${providerName}"`) } @@ -163,9 +170,10 @@ export const displayInfoOfSettingName = (providerName: ProviderName, settingName } else if (settingName === 'endpoint') { return { - title: providerName === 'ollama' ? 'Endpoint' : - providerName === 'vLLM' ? 'Endpoint' : - providerName === 'lmStudio' ? 'Endpoint' : + title: providerName === 'ollama' ? 'Endpoint' : + providerName === 'vLLM' ? 'Endpoint' : + providerName === 'lmStudio' ? 'Endpoint' : + providerName === 'macLocalAFM' ? 'Endpoint' : providerName === 'openAICompatible' ? 'baseURL' : // (do not include /chat/completions) providerName === 'googleVertex' ? 'baseURL' : providerName === 'microsoftAzure' ? 'baseURL' : @@ -173,10 +181,11 @@ export const displayInfoOfSettingName = (providerName: ProviderName, settingName providerName === 'awsBedrock' ? 'Endpoint' : '(never)', - placeholder: providerName === 'ollama' ? defaultProviderSettings.ollama.endpoint - : providerName === 'vLLM' ? defaultProviderSettings.vLLM.endpoint - : providerName === 'openAICompatible' ? 'https://my-website.com/v1' - : providerName === 'lmStudio' ? defaultProviderSettings.lmStudio.endpoint + placeholder: providerName === 'ollama' ? defaultProviderSettings.ollama.endpoint + : providerName === 'vLLM' ? defaultProviderSettings.vLLM.endpoint + : providerName === 'openAICompatible' ? 'https://my-website.com/v1' + : providerName === 'lmStudio' ? defaultProviderSettings.lmStudio.endpoint + : providerName === 'macLocalAFM' ? defaultProviderSettings.macLocalAFM.endpoint : providerName === 'liteLLM' ? 'http://localhost:4000' : providerName === 'awsBedrock' ? 'http://localhost:4000/v1' : '(never)', @@ -352,6 +361,12 @@ export const defaultSettingsOfProvider: SettingsOfProvider = { ...modelInfoOfDefaultModelNames(defaultModelsOfProvider.awsBedrock), _didFillInProviderSettings: undefined, }, + macLocalAFM: { + ...defaultCustomSettings, + ...defaultProviderSettings.macLocalAFM, + ...modelInfoOfDefaultModelNames([...defaultModelsOfProvider.macLocalAFM]), + _didFillInProviderSettings: undefined, + }, } @@ -385,8 +400,6 @@ export const displayInfoOfFeatureName = (featureName: FeatureName) => { } -// the models of these can be refreshed (in theory all can, but not all should) -export const refreshableProviderNames = localProviderNames export type RefreshableProviderName = typeof refreshableProviderNames[number] // models that come with download buttons diff --git a/src/vs/workbench/contrib/void/electron-main/afmBackendService.ts b/src/vs/workbench/contrib/void/electron-main/afmBackendService.ts new file mode 100644 index 00000000..7a71f84a --- /dev/null +++ b/src/vs/workbench/contrib/void/electron-main/afmBackendService.ts @@ -0,0 +1,131 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { spawn, exec as execCb, ChildProcess } from 'child_process'; +import { createServer } from 'net'; +import { promisify } from 'util'; + +const exec = promisify(execCb); +const AFM_DEFAULT_PORT = 9999; + +/** + * Returns true if something is already listening on the given port. + */ +const isPortInUse = (port: number): Promise => { + return new Promise((resolve) => { + const server = createServer(); + server.once('error', () => resolve(true)); // port is taken + server.once('listening', () => { + server.close(() => resolve(false)); // port is free + }); + server.listen(port, '127.0.0.1'); + }); +}; + +/** + * Returns true if the given command is available in PATH. + */ +const isCommandAvailable = async (cmd: string): Promise => { + try { + await exec(`which ${cmd}`); + return true; + } catch { + return false; + } +}; + +/** + * Tries to install `afm` via Homebrew. + * Returns true if installation succeeded. + */ +const installAfmViaBrew = async (log: (msg: string) => void): Promise => { + const brewAvailable = await isCommandAvailable('brew'); + if (!brewAvailable) { + log('[Void] afm: not found and Homebrew is not installed. Install afm manually: brew install scouzi1966/afm/afm'); + return false; + } + + log('[Void] afm: not found — installing via Homebrew (this may take a moment)…'); + try { + await exec('brew install scouzi1966/afm/afm'); + log('[Void] afm: installed successfully via Homebrew'); + return true; + } catch (e) { + log(`[Void] afm: Homebrew installation failed: ${e}`); + return false; + } +}; + +/** + * Tries to start the `afm -g` backend (Apple Foundation Model + API gateway). + * - Only runs on macOS. + * - If `afm` is not installed, auto-installs it via Homebrew. + * - If port 9999 is already in use (user started afm manually), leaves it untouched. + * - If we spawn the process, we register a shutdown hook to kill it cleanly when + * Void exits. + */ +export const startAfmIfNeeded = async ( + onShutdown: (cb: () => void) => void, + log: (msg: string) => void, +): Promise => { + + if (process.platform !== 'darwin') { + return; // afm is macOS-only + } + + const portInUse = await isPortInUse(AFM_DEFAULT_PORT); + if (portInUse) { + log('[Void] afm: port 9999 already in use — using existing afm instance'); + return; + } + + // Auto-install if afm is not in PATH + const afmAvailable = await isCommandAvailable('afm'); + if (!afmAvailable) { + const installed = await installAfmViaBrew(log); + if (!installed) { + return; + } + } + + // Spawn afm -g (gateway mode: auto-discovers Ollama, LM Studio, Jan, etc.) + let afmProcess: ChildProcess | null = null; + + try { + afmProcess = spawn('afm', ['-g'], { + detached: false, + stdio: 'ignore', + env: { ...process.env }, + }); + } catch (e) { + log(`[Void] afm: could not start: ${e}`); + return; + } + + afmProcess.on('error', (err) => { + log(`[Void] afm: error: ${err.message}`); + afmProcess = null; + }); + + afmProcess.on('exit', (code, signal) => { + log(`[Void] afm: exited (code=${code}, signal=${signal})`); + afmProcess = null; + }); + + log('[Void] afm: started on port 9999 with gateway mode (-g)'); + + // Kill afm when Void shuts down — only if WE started it + onShutdown(() => { + if (afmProcess) { + log('[Void] afm: stopping on Void shutdown'); + try { + afmProcess.kill(); + } catch (_) { + // process may have already exited + } + afmProcess = null; + } + }); +}; diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts index b4c794e2..4d5b80cf 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts @@ -167,6 +167,11 @@ const newOpenAICompatibleSDK = async ({ settingsOfProvider, providerName, includ const thisConfig = settingsOfProvider[providerName] return new OpenAI({ baseURL: 'https://api.mistral.ai/v1', apiKey: thisConfig.apiKey, ...commonPayloadOpts }) } + else if (providerName === 'macLocalAFM') { + const thisConfig = settingsOfProvider[providerName] + const endpoint = thisConfig.endpoint || 'http://localhost:9999' + return new OpenAI({ baseURL: `${endpoint}/v1`, apiKey: 'noop', ...commonPayloadOpts }) + } else throw new Error(`Void providerName was invalid: ${providerName}.`) } @@ -878,7 +883,7 @@ export const sendLLMMessageToProviderImplementation = { mistral: { sendChat: (params) => _sendOpenAICompatibleChat(params), sendFIM: (params) => sendMistralFIM(params), - list: null, + list: (params) => _openaiCompatibleList(params), }, ollama: { sendChat: (params) => _sendOpenAICompatibleChat(params), @@ -937,6 +942,11 @@ export const sendLLMMessageToProviderImplementation = { sendFIM: null, list: null, }, + macLocalAFM: { + sendChat: (params) => _sendOpenAICompatibleChat(params), + sendFIM: null, + list: (params) => _openaiCompatibleList(params), + }, } satisfies CallFnOfProvider