Merge pull request #2 from jcommaret/dev

Dev - added models
This commit is contained in:
Jérôme Commaret 2026-04-24 14:21:31 +02:00 committed by GitHub
commit 912d668d95
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 313 additions and 32 deletions

View file

@ -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",

View file

@ -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<void> {
const win32MutexName = this.productService.win32MutexName;
if (isWindows && win32MutexName) {

View file

@ -61,6 +61,10 @@ export const defaultProviderSettings = {
region: 'us-east-1', // add region setting
endpoint: '', // optionally allow overriding default
},
apple: {
endpoint: 'http://localhost:9999', // Apple Foundation Model
mlxEndpoint: 'http://localhost:8080', // MLX models (afm mlx -m <model> -p 8080)
},
} as const
@ -134,24 +138,15 @@ 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: [],
microsoftAzure: [],
awsBedrock: [],
liteLLM: [],
apple: [], // autodetected via afm list endpoint∂
} as const satisfies Record<ProviderName, string[]>
@ -1459,6 +1454,23 @@ const openRouterSettings: VoidStaticProviderInfo = {
// ---------------- MAC LOCAL AFM (Apple Foundation Model / MLX via afm) ----------------
const appleSettings: VoidStaticProviderInfo = {
modelOptionsFallback: (modelName) => extensiveModelOptionsFallback(modelName, { downloadable: false }),
modelOptions: {
'foundation': {
contextWindow: 4_096,
reservedOutputTokenSpace: 2_048,
cost: { input: 0, output: 0 },
downloadable: false,
supportsFIM: true,
supportsSystemMessage: 'system-role',
specialToolFormat: 'openai-style',
reasoningCapabilities: { supportsReasoning: true, canTurnOffReasoning: false, canIOReasoning: true, openSourceThinkTags: ['<think>', '</think>'] },
},
},
}
// ---------------- model settings of everything above ----------------
const modelSettingsOfProvider: { [providerName in ProviderName]: VoidStaticProviderInfo } = {
@ -1484,6 +1496,8 @@ const modelSettingsOfProvider: { [providerName in ProviderName]: VoidStaticProvi
googleVertex: googleVertexSettings,
microsoftAzure: microsoftAzureSettings,
awsBedrock: awsBedrockSettings,
// Local Apple model
apple: appleSettings,
} as const

View file

@ -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'],
apple: ['_didFillInProviderSettings', 'endpoint', 'mlxEndpoint'],
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 },
apple: { 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)
@ -170,12 +176,17 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ
// set the models to the detected models
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);
}),
models.flatMap(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 if (providerName === 'apple') return [(model as OpenaiCompatibleModelResponse).id];
else if (providerName === 'mistral') {
const id = (model as OpenaiCompatibleModelResponse).id;
return id.endsWith('-latest') ? [id] : [];
}
else throw new Error('refreshMode fn: unknown provider', providerName);
}),
{ enableProviderOnSuccess: options.enableProviderOnSuccess, hideRefresh: options.doNotFire }
)

View file

@ -16,9 +16,12 @@ type UnionOfKeys<T> = 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', 'apple'] 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', 'apple', 'mistral'] satisfies ProviderName[]
type CustomSettingName = UnionOfKeys<typeof defaultProviderSettings[ProviderName]>
type CustomProviderSettings<providerName extends ProviderName> = {
[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 === 'apple') {
return { title: 'Apple & MLX', }
}
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 === '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}"`)
}
@ -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 === 'apple' ? '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 === 'apple' ? defaultProviderSettings.apple.endpoint
: providerName === 'liteLLM' ? 'http://localhost:4000'
: providerName === 'awsBedrock' ? 'http://localhost:4000/v1'
: '(never)',
@ -184,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": "..." }' }
}
@ -237,6 +252,7 @@ export const displayInfoOfSettingName = (providerName: ProviderName, settingName
const defaultCustomSettings: Record<CustomSettingName, undefined> = {
apiKey: undefined,
endpoint: undefined,
mlxEndpoint: undefined,
region: undefined, // googleVertex
project: undefined,
azureApiVersion: undefined,
@ -352,6 +368,13 @@ export const defaultSettingsOfProvider: SettingsOfProvider = {
...modelInfoOfDefaultModelNames(defaultModelsOfProvider.awsBedrock),
_didFillInProviderSettings: undefined,
},
apple: {
...defaultCustomSettings,
...defaultProviderSettings.apple,
...modelInfoOfDefaultModelNames([...defaultModelsOfProvider.apple]),
_didFillInProviderSettings: undefined,
mlxEndpoint: defaultProviderSettings.apple.mlxEndpoint,
},
}
@ -385,8 +408,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

View file

@ -0,0 +1,153 @@
/*--------------------------------------------------------------------------------------
* 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_FOUNDATION_PORT = 9999; // Apple Foundation Model
const AFM_MLX_PORT = 8080; // MLX models (afm mlx -m <model> -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.
*/
const isPortInUse = (port: number): Promise<boolean> => {
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 (including Homebrew paths).
*/
const isCommandAvailable = async (cmd: string): Promise<boolean> => {
try {
await exec(`which ${cmd}`, { env: enrichedEnv() });
return true;
} catch {
return false;
}
};
/**
* Tries to install `afm` via Homebrew.
* Returns true if installation succeeded.
*/
const installAfmViaBrew = async (log: (msg: string) => void): Promise<boolean> => {
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', { env: enrichedEnv() });
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<void> => {
if (process.platform !== 'darwin') {
return; // afm is macOS-only
}
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) {
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: enrichedEnv(),
});
} 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;
}
});
};

View file

@ -167,6 +167,12 @@ 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 === '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 })
}
else throw new Error(`Void providerName was invalid: ${providerName}.`)
}
@ -395,6 +401,63 @@ type OpenAIModel = {
object: 'model';
owned_by: string;
}
// Maps each Apple model id to the endpoint it was last seen on (populated by _appleList).
// This lets _appleSettingsForModel route requests correctly without relying on name conventions.
const _appleModelEndpointMap = new Map<string, string>()
// Returns settingsOfProvider with apple.endpoint patched to the URL the model was listed from.
// Falls back to the 'foundation' name convention if the model hasn't been listed yet.
const _appleSettingsForModel = (settingsOfProvider: SettingsOfProvider, modelName: string): SettingsOfProvider => {
const config = settingsOfProvider.apple
const mainEndpoint = config.endpoint || 'http://localhost:9999'
const mlxEndpoint = config.mlxEndpoint || config.endpoint || 'http://localhost:8080'
const targetEndpoint = _appleModelEndpointMap.get(modelName)
?? (modelName === 'foundation' ? mainEndpoint : mlxEndpoint)
return { ...settingsOfProvider, apple: { ...config, endpoint: targetEndpoint } }
}
// Lists models from both AFM endpoints, records their source, and merges them.
const _appleList = async ({ onSuccess, onError, settingsOfProvider, providerName }: ListParams_Internal<OpenAIModel>) => {
const config = settingsOfProvider.apple
const mainEndpoint = config.endpoint || 'http://localhost:9999'
const mlxEndpoint = config.mlxEndpoint || 'http://localhost:8080'
const fetchModels = async (baseURL: string): Promise<OpenAIModel[]> => {
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') {
for (const m of mainResult.value) _appleModelEndpointMap.set(m.id, mainEndpoint)
allModels.push(...mainResult.value)
}
if (mlxResult.status === 'fulfilled') {
for (const m of mlxResult.value) _appleModelEndpointMap.set(m.id, mlxEndpoint)
allModels.push(...mlxResult.value)
}
if (allModels.length === 0 && mainResult.status === 'rejected') {
onError({ error: mainResult.reason + '' })
} else {
// Deduplicate by model id
const seen = new Set<string>()
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<OpenAIModel>) => {
const onSuccess = ({ models }: { models: OpenAIModel[] }) => {
onSuccess_({ models })
@ -878,7 +941,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 +1000,14 @@ export const sendLLMMessageToProviderImplementation = {
sendFIM: null,
list: null,
},
apple: {
sendChat: (params) => _sendOpenAICompatibleChat({
...params,
settingsOfProvider: _appleSettingsForModel(params.settingsOfProvider, params.modelName),
}),
sendFIM: null,
list: (params) => _appleList(params),
},
} satisfies CallFnOfProvider