From 943548a7b42ad2272b4792aa687f59cc9144a1fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Commaret?= Date: Fri, 24 Apr 2026 03:08:35 +0200 Subject: [PATCH] Enhance Apple model integration by adding MLX endpoint support, updating settings, and refining model fetching logic for improved clarity and functionality. --- .../contrib/void/common/modelCapabilities.ts | 21 ++----- .../contrib/void/common/voidSettingsTypes.ts | 9 ++- .../void/electron-main/afmBackendService.ts | 34 +++++++++-- .../llmMessage/sendLLMMessage.impl.ts | 56 ++++++++++++++++++- 4 files changed, 96 insertions(+), 24 deletions(-) diff --git a/src/vs/workbench/contrib/void/common/modelCapabilities.ts b/src/vs/workbench/contrib/void/common/modelCapabilities.ts index 75f3d1bd..d1c1e6f9 100644 --- a/src/vs/workbench/contrib/void/common/modelCapabilities.ts +++ b/src/vs/workbench/contrib/void/common/modelCapabilities.ts @@ -62,7 +62,8 @@ export const defaultProviderSettings = { endpoint: '', // optionally allow overriding default }, apple: { - endpoint: 'http://localhost:9999', + endpoint: 'http://localhost:9999', // Apple Foundation Model + mlxEndpoint: 'http://localhost:8080', // MLX models (afm mlx -m -p 8080) }, } as const @@ -137,18 +138,8 @@ export const defaultModelsOfProvider = { 'llama-3.1-8b-instant', // 'qwen-2.5-coder-32b', // preview mode (experimental) ], - mistral: [ // https://docs.mistral.ai/getting-started/models/models_overview/ - 'codestral-latest', - 'devstral-medium-latest', - 'devstral-small-latest', - 'magistral-medium-latest', - 'magistral-small-latest', - 'mistral-large-latest', - 'mistral-medium-latest', - 'mistral-small-latest', - 'ministral-3b-latest', - 'ministral-8b-latest', - 'open-codestral-mamba' + mistral: [ // https://docs.mistral.ai/getting-started/models/models_overview + // Mistral models are autodetected and fetched using the model list endpoint via refreshModelService ], openAICompatible: [], // fallback googleVertex: [], @@ -1472,10 +1463,10 @@ const appleSettings: VoidStaticProviderInfo = { reservedOutputTokenSpace: 2_048, cost: { input: 0, output: 0 }, downloadable: false, - supportsFIM: false, + supportsFIM: true, supportsSystemMessage: 'system-role', specialToolFormat: 'openai-style', - reasoningCapabilities: false, + reasoningCapabilities: { supportsReasoning: true, canTurnOffReasoning: false, canIOReasoning: true, openSourceThinkTags: ['', ''] }, }, }, } diff --git a/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts b/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts index 563fb3bd..40b181ed 100644 --- a/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts +++ b/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts @@ -134,7 +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 === 'apple') 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.' + if (providerName === 'apple') return 'Run Apple\'s on-device Foundation Model or any MLX model locally via [afm](https://github.com/scouzi1966/maclocal-api). Requires macOS 26+, Apple Silicon, and Apple Intelligence enabled.\n\n**Foundation Model** (on-device, no download): `afm`\n\n**MLX models for code** (recommended for 8 GB RAM):\n- `afm mlx -m mlx-community/Qwen2.5-Coder-7B-Instruct-4bit` (~4 GB, best for code + autocomplete)\n- `afm mlx -m mlx-community/devstral-small-2505-4bit` (~4 GB, agentic coding)' throw new Error(`subTextMdOfProviderName: Unknown provider name: "${providerName}"`) } @@ -193,6 +193,12 @@ export const displayInfoOfSettingName = (providerName: ProviderName, settingName } } + else if (settingName === 'mlxEndpoint') { + return { + title: 'MLX Endpoint', + placeholder: defaultProviderSettings.apple.mlxEndpoint, + } + } else if (settingName === 'headersJSON') { return { title: 'Custom Headers', placeholder: '{ "X-Request-Id": "..." }' } } @@ -366,6 +372,7 @@ export const defaultSettingsOfProvider: SettingsOfProvider = { ...defaultProviderSettings.apple, ...modelInfoOfDefaultModelNames([...defaultModelsOfProvider.apple]), _didFillInProviderSettings: undefined, + mlxEndpoint: defaultProviderSettings.apple.mlxEndpoint, }, } diff --git a/src/vs/workbench/contrib/void/electron-main/afmBackendService.ts b/src/vs/workbench/contrib/void/electron-main/afmBackendService.ts index 7a71f84a..b7681f47 100644 --- a/src/vs/workbench/contrib/void/electron-main/afmBackendService.ts +++ b/src/vs/workbench/contrib/void/electron-main/afmBackendService.ts @@ -8,7 +8,23 @@ import { createServer } from 'net'; import { promisify } from 'util'; const exec = promisify(execCb); -const AFM_DEFAULT_PORT = 9999; +const AFM_FOUNDATION_PORT = 9999; // Apple Foundation Model +const AFM_MLX_PORT = 8080; // MLX models (afm mlx -m -p 8080) + +// Electron apps don't source the shell, so PATH is minimal (/usr/bin:/bin). +// We enrich it with common Homebrew paths so `afm` and `brew` can be found. +const HOMEBREW_PATHS = [ + '/opt/homebrew/bin', // Apple Silicon + '/usr/local/bin', // Intel Mac + '/opt/homebrew/sbin', + '/usr/local/sbin', +]; + +const enrichedEnv = (): NodeJS.ProcessEnv => { + const currentPath = process.env.PATH ?? ''; + const extra = HOMEBREW_PATHS.filter(p => !currentPath.includes(p)).join(':'); + return { ...process.env, PATH: extra ? `${extra}:${currentPath}` : currentPath }; +}; /** * Returns true if something is already listening on the given port. @@ -25,11 +41,11 @@ const isPortInUse = (port: number): Promise => { }; /** - * Returns true if the given command is available in PATH. + * Returns true if the given command is available in PATH (including Homebrew paths). */ const isCommandAvailable = async (cmd: string): Promise => { try { - await exec(`which ${cmd}`); + await exec(`which ${cmd}`, { env: enrichedEnv() }); return true; } catch { return false; @@ -49,7 +65,7 @@ const installAfmViaBrew = async (log: (msg: string) => void): Promise = log('[Void] afm: not found — installing via Homebrew (this may take a moment)…'); try { - await exec('brew install scouzi1966/afm/afm'); + await exec('brew install scouzi1966/afm/afm', { env: enrichedEnv() }); log('[Void] afm: installed successfully via Homebrew'); return true; } catch (e) { @@ -75,12 +91,18 @@ export const startAfmIfNeeded = async ( return; // afm is macOS-only } - const portInUse = await isPortInUse(AFM_DEFAULT_PORT); + const portInUse = await isPortInUse(AFM_FOUNDATION_PORT); if (portInUse) { log('[Void] afm: port 9999 already in use — using existing afm instance'); return; } + // Check if an MLX model is already running on port 8080 + const mlxPortInUse = await isPortInUse(AFM_MLX_PORT); + if (mlxPortInUse) { + log('[Void] afm: MLX model detected on port 8080'); + } + // Auto-install if afm is not in PATH const afmAvailable = await isCommandAvailable('afm'); if (!afmAvailable) { @@ -97,7 +119,7 @@ export const startAfmIfNeeded = async ( afmProcess = spawn('afm', ['-g'], { detached: false, stdio: 'ignore', - env: { ...process.env }, + env: enrichedEnv(), }); } catch (e) { log(`[Void] afm: could not start: ${e}`); 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 3a1f3b92..eea4ed3a 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 @@ -169,6 +169,7 @@ const newOpenAICompatibleSDK = async ({ settingsOfProvider, providerName, includ } else if (providerName === 'apple') { const thisConfig = settingsOfProvider[providerName] + // endpoint may have been pre-patched by _appleSettingsForModel to point to the right port const endpoint = thisConfig.endpoint || 'http://localhost:9999' return new OpenAI({ baseURL: `${endpoint}/v1`, apiKey: 'noop', ...commonPayloadOpts }) } @@ -400,6 +401,54 @@ type OpenAIModel = { object: 'model'; owned_by: string; } +// Returns settingsOfProvider with apple.endpoint patched to the right URL based on model name. +// - 'foundation' → endpoint (port 9999, Apple Foundation Model) +// - anything else → mlxEndpoint (port 8080, MLX models) with fallback to endpoint +const _appleSettingsForModel = (settingsOfProvider: SettingsOfProvider, modelName: string): SettingsOfProvider => { + const config = settingsOfProvider.apple + const isFoundation = modelName === 'foundation' + const targetEndpoint = isFoundation + ? (config.endpoint || 'http://localhost:9999') + : (config.mlxEndpoint || config.endpoint || 'http://localhost:8080') + return { ...settingsOfProvider, apple: { ...config, endpoint: targetEndpoint } } +} + +// Lists models from both AFM endpoints and merges them. +const _appleList = async ({ onSuccess, onError, settingsOfProvider, providerName }: ListParams_Internal) => { + const config = settingsOfProvider.apple + const mainEndpoint = config.endpoint || 'http://localhost:9999' + const mlxEndpoint = config.mlxEndpoint || 'http://localhost:8080' + + const fetchModels = async (baseURL: string): Promise => { + const openai = new OpenAI({ baseURL: `${baseURL}/v1`, apiKey: 'noop', dangerouslyAllowBrowser: true }) + const response = await openai.models.list() + const models: OpenAIModel[] = [...response.data] + while (response.hasNextPage()) { + models.push(...(await response.getNextPage()).data) + } + return models + } + + // Query both endpoints in parallel; ignore failures from either one + const [mainResult, mlxResult] = await Promise.allSettled([ + fetchModels(mainEndpoint), + mainEndpoint !== mlxEndpoint ? fetchModels(mlxEndpoint) : Promise.resolve([]), + ]) + + const allModels: OpenAIModel[] = [] + if (mainResult.status === 'fulfilled') allModels.push(...mainResult.value) + if (mlxResult.status === 'fulfilled') allModels.push(...mlxResult.value) + + if (allModels.length === 0 && mainResult.status === 'rejected') { + onError({ error: mainResult.reason + '' }) + } else { + // Deduplicate by model id + const seen = new Set() + const unique = allModels.filter(m => { if (seen.has(m.id)) return false; seen.add(m.id); return true }) + onSuccess({ models: unique }) + } +} + const _openaiCompatibleList = async ({ onSuccess: onSuccess_, onError: onError_, settingsOfProvider, providerName }: ListParams_Internal) => { const onSuccess = ({ models }: { models: OpenAIModel[] }) => { onSuccess_({ models }) @@ -943,9 +992,12 @@ export const sendLLMMessageToProviderImplementation = { list: null, }, apple: { - sendChat: (params) => _sendOpenAICompatibleChat(params), + sendChat: (params) => _sendOpenAICompatibleChat({ + ...params, + settingsOfProvider: _appleSettingsForModel(params.settingsOfProvider, params.modelName), + }), sendFIM: null, - list: (params) => _openaiCompatibleList(params), + list: (params) => _appleList(params), }, } satisfies CallFnOfProvider