Enhance Apple model integration by adding MLX endpoint support, updating settings, and refining model fetching logic for improved clarity and functionality.

This commit is contained in:
Jérôme Commaret 2026-04-24 03:08:35 +02:00
parent f7257cc7be
commit 943548a7b4
4 changed files with 96 additions and 24 deletions

View file

@ -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 <model> -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: ['<think>', '</think>'] },
},
},
}

View file

@ -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 <model>` (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,
},
}

View file

@ -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 <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.
@ -25,11 +41,11 @@ const isPortInUse = (port: number): Promise<boolean> => {
};
/**
* 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<boolean> => {
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<boolean> =
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}`);

View file

@ -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<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') 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<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 })
@ -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