mirror of
https://github.com/voideditor/void
synced 2026-05-23 01:18:25 +00:00
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:
parent
f7257cc7be
commit
943548a7b4
4 changed files with 96 additions and 24 deletions
|
|
@ -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>'] },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue