Refactor Apple Foundation Models integration and update setup instructions

- Enhanced comments and documentation for macOS Apple Foundation Models (afm) auto-installation and server setup.
- Updated user-facing text in settings to clarify installation methods via Homebrew and pip.
- Added constants for Homebrew and pip installation details to improve maintainability.
- Improved logging messages for server status and installation processes.
This commit is contained in:
Jérôme Commaret 2026-05-20 16:37:36 +02:00
parent bf80a00b30
commit 9c98e1c688
6 changed files with 110 additions and 36 deletions

View file

@ -829,7 +829,7 @@ export const AutoSetupAppleFoundationModelsToggle = () => {
metricsService.capture('Click', { action: 'Apple FM Auto-Setup Toggle', settingName, enabled: newVal })
}}
/>}
text='On macOS: install `afm` (Homebrew) if needed, start the Apple server, and enable the `foundation` model.'
text='On macOS: install maclocal-api (afm) via Homebrew or pip, start the server on port 9999, and enable model foundation.'
/>
}
@ -900,11 +900,11 @@ export const MlxSetupInstructions = () => {
export const AppleFoundationModelsSetupInstructions = () => {
if (os !== 'mac') return null
return <div className='prose-p:my-0 prose-ol:list-decimal prose-p:py-0 prose-ol:my-0 prose-ol:py-0 text-void-fg-3 text-sm list-decimal select-text mb-4'>
<div><ChatMarkdownRender string={`Apple (one model: \`foundation\`)`} chatMessageLocation={undefined} /></div>
<div className='pl-6'><ChatMarkdownRender string={`1. Requires macOS 26+, Apple Silicon, and Apple Intelligence enabled. Void can install [\`afm\`](https://github.com/scouzi1966/maclocal-api) via Homebrew (toggle in Settings → Models).`} chatMessageLocation={undefined} /></div>
<div className='pl-6'><ChatMarkdownRender string={`2. Only one **autodetected** entry (\`foundation\`): the on-device model exposed by \`afm\`.`} chatMessageLocation={undefined} /></div>
<div className='pl-6'><ChatMarkdownRender string={`3. **Fine-tuned adapter**: \`afm -a ./my-adapter.fmadapter -p 9998\`, then set Voids endpoint to \`http://127.0.0.1:9998\` and refresh.`} chatMessageLocation={undefined} /></div>
<div className='pl-6'><ChatMarkdownRender string={`4. **Other models (Llama, Qwen, etc.)**: use **MLX** or **Ollama** — Apple FM only serves Apples built-in Foundation model.`} chatMessageLocation={undefined} /></div>
<div><ChatMarkdownRender string={`apple — [maclocal-api](https://github.com/scouzi1966/maclocal-api) (\`afm\`)`} chatMessageLocation={undefined} /></div>
<div className='pl-6'><ChatMarkdownRender string={`1. Requires macOS 26+, Apple Silicon, and Apple Intelligence. Void auto-installs \`afm\` (Homebrew: \`brew tap scouzi1966/afm && brew install scouzi1966/afm/afm\`, or pip: \`pip install macafm\`).`} chatMessageLocation={undefined} /></div>
<div className='pl-6'><ChatMarkdownRender string={`2. Default server: \`afm -p 9999\` → endpoint \`http://127.0.0.1:9999/v1\`, model id \`foundation\`.`} chatMessageLocation={undefined} /></div>
<div className='pl-6'><ChatMarkdownRender string={`3. **LoRA adapter**: \`afm -a ./my-adapter.fmadapter -p 9998\`, then set Voids endpoint to \`http://127.0.0.1:9998\` and refresh.`} chatMessageLocation={undefined} /></div>
<div className='pl-6'><ChatMarkdownRender string={`4. **Other local models**: use **MLX** (\`mlx_lm.server\`) or \`afm mlx -m <hf-repo>\` from maclocal-api — this provider is only Apples on-device Foundation model.`} chatMessageLocation={undefined} /></div>
</div>
}

View file

@ -75,7 +75,7 @@ import '../common/voidSettingsService.js'
// refreshModel
import '../common/refreshModelService.js'
// Apple Foundation Models (macOS): auto-install afm + start server
// apple (macOS): maclocal-api (afm) auto-install + server
import '../common/appleFoundationModelsService.js'
import './appleFoundationModelsWorkbenchContrib.js'

View file

@ -8,6 +8,13 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta
export const APPLE_FOUNDATION_MODELS_DEFAULT_ENDPOINT = 'http://127.0.0.1:9999';
export const APPLE_FOUNDATION_MODELS_DEFAULT_PORT = 9999;
/** https://github.com/scouzi1966/maclocal-api — OpenAI-compatible `afm` server for Apple Foundation Models */
export const MACLOCAL_API_REPO_URL = 'https://github.com/scouzi1966/maclocal-api';
export const AFM_DEFAULT_MODEL_ID = 'foundation';
export const AFM_HOMEBREW_TAP = 'scouzi1966/afm';
export const AFM_HOMEBREW_FORMULA = 'scouzi1966/afm/afm';
export const AFM_PIP_PACKAGE = 'macafm';
export type AppleFoundationModelsEnsureAction =
| 'already-running'
| 'started'

View file

@ -1330,7 +1330,7 @@ const appleFoundationModelCapabilities = {
}
const appleFoundationModelsModelOptions = {
'foundation': { // Apple on-device model via afm — https://github.com/scouzi1966/maclocal-api
'foundation': { // maclocal-api afm — https://github.com/scouzi1966/maclocal-api
...appleFoundationModelCapabilities,
},
} as const satisfies { [s: string]: VoidStaticModelInfo }

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 === 'mlx') return 'Only one loaded model is listed at a time (autodetected). See the MLX instructions below to switch models or add a second one.'
if (providerName === 'appleFoundationModels') return 'Only one on-device model (`foundation`, autodetected). Void can install and run [`afm`](https://github.com/scouzi1966/maclocal-api) automatically. See the instructions below for adapters or other models.'
if (providerName === 'appleFoundationModels') return 'On-device model `foundation` via [maclocal-api](https://github.com/scouzi1966/maclocal-api) (`afm` on port 9999). Void can install via Homebrew or `pip install macafm`. For MLX models on the same stack, run `afm mlx -m <model>` separately or use the **MLX** provider.'
if (providerName === 'liteLLM') return 'Read more about endpoints [here](https://docs.litellm.ai/docs/providers/openai_compatible).'
throw new Error(`subTextMdOfProviderName: Unknown provider name: "${providerName}"`)
@ -464,7 +464,7 @@ export type ChatMode = 'agent' | 'gather' | 'normal'
export type GlobalSettings = {
autoRefreshModels: boolean;
/** macOS only: install `afm` via Homebrew and start the local Apple Foundation Models server */
/** macOS only: install maclocal-api (`afm`) via Homebrew or pip and start the server */
autoSetupAppleFoundationModels: boolean;
/** macOS only: install `mlx-lm` via pip and start mlx_lm.server */
autoSetupMlx: boolean;

View file

@ -8,9 +8,13 @@ import { promisify } from 'util';
import { exec as _exec } from 'child_process';
import { isMacintosh } from '../../../../base/common/platform.js';
import {
AFM_HOMEBREW_FORMULA,
AFM_HOMEBREW_TAP,
AFM_PIP_PACKAGE,
APPLE_FOUNDATION_MODELS_DEFAULT_PORT,
AppleFoundationModelsEnsureResult,
IAppleFoundationModelsMainService,
MACLOCAL_API_REPO_URL,
} from '../common/appleFoundationModelsTypes.js';
const exec = promisify(_exec);
@ -33,7 +37,7 @@ export class AppleFoundationModelsMainService implements IAppleFoundationModelsM
}
if (await this._isServerUp(endpoint)) {
log.push(`Serveur déjà actif sur ${endpoint}`);
log.push(`maclocal-api (afm) already running at ${endpoint}`);
return { ok: true, endpoint, action: 'already-running', log };
}
@ -41,35 +45,39 @@ export class AppleFoundationModelsMainService implements IAppleFoundationModelsM
let afmPath = await this._whichAfm();
if (!afmPath) {
if (!options.installIfMissing) {
log.push('Commande `afm` introuvable.');
return { ok: false, reason: 'afm-missing', log, errorMessage: 'Installez afm avec Homebrew : brew tap scouzi1966/afm && brew install afm' };
log.push('`afm` not found (maclocal-api).');
return {
ok: false,
reason: 'afm-missing',
log,
errorMessage: `Install from ${MACLOCAL_API_REPO_URL} — Homebrew: brew tap ${AFM_HOMEBREW_TAP} && brew install ${AFM_HOMEBREW_FORMULA} — or pip: pip install ${AFM_PIP_PACKAGE}`,
};
}
const brewPath = await this._whichBrew();
if (!brewPath) {
log.push('Homebrew introuvable — impossible dinstaller afm automatiquement.');
return { ok: false, reason: 'brew-missing', log, errorMessage: 'Installez Homebrew puis : brew tap scouzi1966/afm && brew install afm' };
}
log.push('Installation de afm via Homebrew…');
try {
await exec(`${brewPath} tap scouzi1966/afm`, { timeout: 120_000 });
await exec(`${brewPath} install afm`, { timeout: 600_000 });
const installedViaBrew = await this._tryInstallViaHomebrew(log);
if (installedViaBrew) {
didInstall = true;
log.push('Installation Homebrew terminée.');
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
log.push(`Échec installation : ${msg}`);
return { ok: false, reason: 'install-failed', log, errorMessage: msg };
} else {
const installedViaPip = await this._tryInstallViaPip(log);
if (installedViaPip) {
didInstall = true;
} else {
return {
ok: false,
reason: 'install-failed',
log,
errorMessage: `Could not install afm. See ${MACLOCAL_API_REPO_URL}`,
};
}
}
afmPath = await this._whichAfm();
if (!afmPath) {
log.push('afm toujours introuvable après installation.');
log.push('`afm` still not on PATH after install.');
return { ok: false, reason: 'afm-missing', log };
}
} else {
log.push(`afm trouvé : ${afmPath}`);
log.push(`Found afm: ${afmPath}`);
}
if (options.startServer) {
@ -79,14 +87,19 @@ export class AppleFoundationModelsMainService implements IAppleFoundationModelsM
for (let i = 0; i < 45; i++) {
if (await this._isServerUp(endpoint)) {
const action = didInstall ? 'installed-and-started' as const : 'started' as const;
log.push(`Serveur prêt sur ${endpoint}`);
log.push(`maclocal-api ready at ${endpoint} (model: foundation)`);
return { ok: true, endpoint, action, log };
}
await sleep(1000);
}
log.push('Délai dépassé en attendant le serveur afm.');
return { ok: false, reason: 'server-timeout', log, errorMessage: `Le serveur na pas répondu sur ${endpoint}. Lancez \`afm -p ${port}\` manuellement.` };
log.push('Timed out waiting for afm.');
return {
ok: false,
reason: 'server-timeout',
log,
errorMessage: `Server did not respond at ${endpoint}. Run manually: afm -p ${port} -H 127.0.0.1`,
};
}
async stopServerIfSpawnedByVoid(): Promise<void> {
@ -102,6 +115,60 @@ export class AppleFoundationModelsMainService implements IAppleFoundationModelsM
this._spawnedByVoid = false;
}
private async _tryInstallViaHomebrew(log: string[]): Promise<boolean> {
const brewPath = await this._whichBrew();
if (!brewPath) {
log.push('Homebrew not found; will try pip install macafm.');
return false;
}
log.push(`Installing afm via Homebrew (${AFM_HOMEBREW_FORMULA})…`);
try {
await exec(`"${brewPath}" tap ${AFM_HOMEBREW_TAP}`, { timeout: 120_000 });
await exec(`"${brewPath}" install ${AFM_HOMEBREW_FORMULA}`, { timeout: 600_000 });
log.push('Homebrew install finished.');
return true;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
log.push(`Homebrew install failed: ${msg}`);
return false;
}
}
private async _tryInstallViaPip(log: string[]): Promise<boolean> {
const pythonPath = await this._whichPython3();
if (!pythonPath) {
log.push('python3 not found; cannot pip install macafm.');
return false;
}
log.push(`Installing ${AFM_PIP_PACKAGE} via pip (${MACLOCAL_API_REPO_URL})…`);
try {
await exec(`"${pythonPath}" -m pip install --upgrade ${AFM_PIP_PACKAGE}`, { timeout: 600_000 });
log.push('pip install finished.');
return true;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
log.push(`pip install failed: ${msg}`);
return false;
}
}
private async _whichPython3(): Promise<string | null> {
for (const cmd of ['python3', 'python']) {
try {
const { stdout } = await exec(`which ${cmd}`, { timeout: 5_000 });
const path = stdout.trim();
if (path) {
return path;
}
} catch {
// try next
}
}
return null;
}
private async _whichAfm(): Promise<string | null> {
try {
const { stdout } = await exec('which afm', { timeout: 5_000 });
@ -131,7 +198,7 @@ export class AppleFoundationModelsMainService implements IAppleFoundationModelsM
return true;
}
} catch {
// try models next
// try /v1/models next
} finally {
clearTimeout(timeout);
}
@ -150,11 +217,11 @@ export class AppleFoundationModelsMainService implements IAppleFoundationModelsM
private async _startServer(afmPath: string, port: number, log: string[]): Promise<void> {
if (this._child && !this._child.killed) {
log.push('Processus afm Void déjà en cours.');
log.push('Void afm process already running.');
return;
}
log.push(`Démarrage de afm sur le port ${port}`);
log.push(`Starting maclocal-api: afm -p ${port} -H 127.0.0.1`);
const child = spawn(afmPath, ['-p', String(port), '-H', '127.0.0.1'], {
detached: true,
stdio: 'ignore',