Add support for MLX and Apple Foundation Models, including auto-setup options and model handling in settings

This commit is contained in:
Jérôme Commaret 2026-05-20 15:02:27 +02:00
parent 98baa32c41
commit a5ed7b6af7
18 changed files with 912 additions and 15 deletions

View file

@ -131,6 +131,10 @@ import { MetricsMainService } from '../../workbench/contrib/void/electron-main/m
import { VoidMainUpdateService } from '../../workbench/contrib/void/electron-main/voidUpdateMainService.js';
import { LLMMessageChannel } from '../../workbench/contrib/void/electron-main/sendLLMMessageChannel.js';
import { VoidSCMService } from '../../workbench/contrib/void/electron-main/voidSCMMainService.js';
import { AppleFoundationModelsMainService } from '../../workbench/contrib/void/electron-main/appleFoundationModelsMainService.js';
import { IAppleFoundationModelsMainService } from '../../workbench/contrib/void/common/appleFoundationModelsTypes.js';
import { MlxMainService } from '../../workbench/contrib/void/electron-main/mlxMainService.js';
import { IMlxMainService } from '../../workbench/contrib/void/common/mlxTypes.js';
import { IVoidSCMService } from '../../workbench/contrib/void/common/voidSCMTypes.js';
import { MCPChannel } from '../../workbench/contrib/void/electron-main/mcpChannel.js';
/**
@ -1105,6 +1109,8 @@ export class CodeApplication extends Disposable {
services.set(IMetricsService, new SyncDescriptor(MetricsMainService, undefined, false));
services.set(IVoidUpdateService, new SyncDescriptor(VoidMainUpdateService, undefined, false));
services.set(IVoidSCMService, new SyncDescriptor(VoidSCMService, undefined, false));
services.set(IAppleFoundationModelsMainService, new SyncDescriptor(AppleFoundationModelsMainService, undefined, false));
services.set(IMlxMainService, new SyncDescriptor(MlxMainService, undefined, false));
// Default Extensions Profile Init
services.set(IExtensionsProfileScannerService, new SyncDescriptor(ExtensionsProfileScannerService, undefined, true));
@ -1250,6 +1256,12 @@ export class CodeApplication extends Disposable {
const voidSCMChannel = ProxyChannel.fromService(accessor.get(IVoidSCMService), disposables);
mainProcessElectronServer.registerChannel('void-channel-scm', voidSCMChannel);
const appleFoundationModelsChannel = ProxyChannel.fromService(accessor.get(IAppleFoundationModelsMainService), disposables);
mainProcessElectronServer.registerChannel('void-channel-appleFoundationModels', appleFoundationModelsChannel);
const mlxChannel = ProxyChannel.fromService(accessor.get(IMlxMainService), disposables);
mainProcessElectronServer.registerChannel('void-channel-mlx', mlxChannel);
// Void added this
const mcpChannel = new MCPChannel();
mainProcessElectronServer.registerChannel('void-channel-mcp', mcpChannel);

View file

@ -0,0 +1,73 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { Disposable } from '../../../../base/common/lifecycle.js';
import { isMacintosh } from '../../../../base/common/platform.js';
import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js';
import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';
import { APPLE_FOUNDATION_MODELS_DEFAULT_ENDPOINT } from '../common/appleFoundationModelsTypes.js';
import { IAppleFoundationModelsService } from '../common/appleFoundationModelsService.js';
import { IRefreshModelService } from '../common/refreshModelService.js';
import { IVoidSettingsService } from '../common/voidSettingsService.js';
export class AppleFoundationModelsWorkbenchContrib extends Disposable {
static readonly ID = 'workbench.contrib.appleFoundationModels';
private _didRun = false;
constructor(
@IVoidSettingsService private readonly voidSettingsService: IVoidSettingsService,
@IAppleFoundationModelsService private readonly appleFoundationModelsService: IAppleFoundationModelsService,
@IRefreshModelService private readonly refreshModelService: IRefreshModelService,
@INotificationService private readonly notificationService: INotificationService,
) {
super();
this.voidSettingsService.waitForInitState.then(() => this._maybeBootstrap());
this._register(this.voidSettingsService.onDidChangeState((e) => {
if (typeof e === 'object' && e[1] === 'autoSetupAppleFoundationModels') {
this._didRun = false;
this._maybeBootstrap();
}
}));
}
private async _maybeBootstrap(): Promise<void> {
if (this._didRun) {
return;
}
if (!isMacintosh) {
return;
}
if (!this.voidSettingsService.state.globalSettings.autoSetupAppleFoundationModels) {
return;
}
this._didRun = true;
const result = await this.appleFoundationModelsService.ensureReady({
installIfMissing: true,
startServer: true,
});
if (result.ok) {
const endpoint = result.endpoint || APPLE_FOUNDATION_MODELS_DEFAULT_ENDPOINT;
if (this.voidSettingsService.state.settingsOfProvider.appleFoundationModels.endpoint !== endpoint) {
await this.voidSettingsService.setSettingOfProvider('appleFoundationModels', 'endpoint', endpoint);
}
this.refreshModelService.startRefreshingModels('appleFoundationModels', { enableProviderOnSuccess: true, doNotFire: false });
return;
}
const detail = result.errorMessage ?? result.log.join('\n');
this.notificationService.notify({
severity: Severity.Warning,
message: 'Apple: automatic setup failed',
source: detail || 'Void',
});
}
}
registerWorkbenchContribution2(AppleFoundationModelsWorkbenchContrib.ID, AppleFoundationModelsWorkbenchContrib, WorkbenchPhase.AfterRestored);

View file

@ -0,0 +1,73 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { Disposable } from '../../../../base/common/lifecycle.js';
import { isMacintosh } from '../../../../base/common/platform.js';
import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js';
import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';
import { MLX_DEFAULT_ENDPOINT } from '../common/mlxTypes.js';
import { IMlxService } from '../common/mlxService.js';
import { IRefreshModelService } from '../common/refreshModelService.js';
import { IVoidSettingsService } from '../common/voidSettingsService.js';
export class MlxWorkbenchContrib extends Disposable {
static readonly ID = 'workbench.contrib.mlx';
private _didRun = false;
constructor(
@IVoidSettingsService private readonly voidSettingsService: IVoidSettingsService,
@IMlxService private readonly mlxService: IMlxService,
@IRefreshModelService private readonly refreshModelService: IRefreshModelService,
@INotificationService private readonly notificationService: INotificationService,
) {
super();
this.voidSettingsService.waitForInitState.then(() => this._maybeBootstrap());
this._register(this.voidSettingsService.onDidChangeState((e) => {
if (typeof e === 'object' && e[1] === 'autoSetupMlx') {
this._didRun = false;
this._maybeBootstrap();
}
}));
}
private async _maybeBootstrap(): Promise<void> {
if (this._didRun) {
return;
}
if (!isMacintosh) {
return;
}
if (!this.voidSettingsService.state.globalSettings.autoSetupMlx) {
return;
}
this._didRun = true;
const result = await this.mlxService.ensureReady({
installIfMissing: true,
startServer: true,
});
if (result.ok) {
const endpoint = result.endpoint || MLX_DEFAULT_ENDPOINT;
if (this.voidSettingsService.state.settingsOfProvider.mlx.endpoint !== endpoint) {
await this.voidSettingsService.setSettingOfProvider('mlx', 'endpoint', endpoint);
}
this.refreshModelService.startRefreshingModels('mlx', { enableProviderOnSuccess: true, doNotFire: false });
return;
}
const detail = result.errorMessage ?? result.log.join('\n');
this.notificationService.notify({
severity: Severity.Warning,
message: 'MLX: automatic setup failed',
source: detail || 'Void',
});
}
}
registerWorkbenchContribution2(MlxWorkbenchContrib.ID, MlxWorkbenchContrib, WorkbenchPhase.AfterRestored);

View file

@ -511,8 +511,8 @@ const VoidOnboardingContent = () => {
const providerNamesOfWantToUseOption: { [wantToUseOption in WantToUseOption]: ProviderName[] } = {
smart: ['anthropic', 'openAI', 'gemini', 'openRouter'],
private: ['ollama', 'vLLM', 'openAICompatible', 'lmStudio'],
cheap: ['gemini', 'deepseek', 'openRouter', 'ollama', 'vLLM'],
private: ['ollama', 'vLLM', 'openAICompatible', 'lmStudio', 'mlx', 'appleFoundationModels'],
cheap: ['gemini', 'deepseek', 'openRouter', 'ollama', 'vLLM', 'mlx'],
all: providerNames,
}
@ -564,7 +564,7 @@ const VoidOnboardingContent = () => {
const detailedDescOfWantToUseOption: { [wantToUseOption in WantToUseOption]: string } = {
smart: "Most intelligent and best for agent mode.",
private: "Private-hosted so your data never leaves your computer or network. [Email us](mailto:founders@voideditor.com) for help setting up at your company.",
cheap: "Use great deals like Gemini 2.5 Pro, or self-host a model with Ollama or vLLM for free.",
cheap: "Use great deals like Gemini 2.5 Pro, or self-host with Ollama, vLLM, or MLX on Apple Silicon.",
all: "",
}

View file

@ -5,6 +5,7 @@
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; // Added useRef import just in case it was missed, though likely already present
import { ProviderName, SettingName, displayInfoOfSettingName, providerNames, VoidStatefulModelInfo, customSettingNamesOfProvider, RefreshableProviderName, refreshableProviderNames, displayInfoOfProviderName, nonlocalProviderNames, localProviderNames, GlobalSettingName, featureNames, displayInfoOfFeatureName, isProviderNameDisabled, FeatureName, hasDownloadButtonsOnModelsProviderNames, subTextMdOfProviderName } from '../../../../common/voidSettingsTypes.js'
import { os } from '../../../../common/helpers/systemInfo.js'
import ErrorBoundary from '../sidebar-tsx/ErrorBoundary.js'
import { VoidButtonBgDarken, VoidCustomDropdownBox, VoidInputBox2, VoidSimpleInputBox, VoidSwitch } from '../util/inputs.js'
import { useAccessor, useIsDark, useIsOptedOut, useRefreshModelListener, useRefreshModelState, useSettingsState } from '../util/services.js'
@ -13,7 +14,6 @@ import { URI } from '../../../../../../../base/common/uri.js'
import { ModelDropdown } from './ModelDropdown.js'
import { ChatMarkdownRender } from '../markdown/ChatMarkdownRender.js'
import { WarningBox } from './WarningBox.js'
import { os } from '../../../../common/helpers/systemInfo.js'
import { IconLoading } from '../sidebar-tsx/SidebarChat.js'
import { ToolApprovalType, toolApprovalTypes } from '../../../../common/toolsServiceTypes.js'
import Severity from '../../../../../../../base/common/severity.js'
@ -737,7 +737,9 @@ export const SettingsForProvider = ({ providerName, showProviderTitle, showProvi
{showProviderSuggestions && needsModel ?
providerName === 'ollama' ?
<WarningBox className="pl-2 mb-4" text={`Please install an Ollama model. We'll auto-detect it.`} />
: <WarningBox className="pl-2 mb-4" text={`Please add a model for ${providerTitle} (Models section).`} />
: (providerName === 'mlx' || providerName === 'appleFoundationModels' || providerName === 'lmStudio' || providerName === 'vLLM') ?
<WarningBox className="pl-2 mb-4" text={`Start your local server — Void will auto-detect models at the endpoint.`} />
: <WarningBox className="pl-2 mb-4" text={`Please add a model for ${providerTitle} (Models section).`} />
: null}
</div>
</div >
@ -781,6 +783,56 @@ export const AutoDetectLocalModelsToggle = () => {
}
export const AutoSetupMlxToggle = () => {
if (os !== 'mac') {
return null
}
const settingName: GlobalSettingName = 'autoSetupMlx'
const accessor = useAccessor()
const voidSettingsService = accessor.get('IVoidSettingsService')
const metricsService = accessor.get('IMetricsService')
const voidSettingsState = useSettingsState()
const enabled = voidSettingsState.globalSettings[settingName]
return <ButtonLeftTextRightOption
leftButton={<VoidSwitch
size='xxs'
value={enabled}
onChange={(newVal) => {
voidSettingsService.setGlobalSetting(settingName, newVal)
metricsService.capture('Click', { action: 'MLX Auto-Setup Toggle', settingName, enabled: newVal })
}}
/>}
text='On macOS: install mlx-lm (pip) if needed, start mlx_lm.server, and autodetect the loaded model.'
/>
}
export const AutoSetupAppleFoundationModelsToggle = () => {
if (os !== 'mac') {
return null
}
const settingName: GlobalSettingName = 'autoSetupAppleFoundationModels'
const accessor = useAccessor()
const voidSettingsService = accessor.get('IVoidSettingsService')
const metricsService = accessor.get('IMetricsService')
const voidSettingsState = useSettingsState()
const enabled = voidSettingsState.globalSettings[settingName]
return <ButtonLeftTextRightOption
leftButton={<VoidSwitch
size='xxs'
value={enabled}
onChange={(newVal) => {
voidSettingsService.setGlobalSetting(settingName, newVal)
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.'
/>
}
export const AIInstructionsBox = () => {
const accessor = useAccessor()
const voidSettingsService = accessor.get('IVoidSettingsService')
@ -835,6 +887,27 @@ export const OllamaSetupInstructions = ({ sayWeAutoDetect }: { sayWeAutoDetect?:
</div>
}
export const MlxSetupInstructions = () => {
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={`MLX (one model at a time)`} chatMessageLocation={undefined} /></div>
<div className='pl-6'><ChatMarkdownRender string={`1. Void can run \`pip install mlx-lm\` and start \`mlx_lm.server\` for you (toggle in Settings → Models). Default model: \`mlx-community/Qwen2.5-Coder-1.5B-Instruct\` on port 8080.`} chatMessageLocation={undefined} /></div>
<div className='pl-6'><ChatMarkdownRender string={`2. Void shows only **one** autodetected entry: the model currently loaded by the server.`} chatMessageLocation={undefined} /></div>
<div className='pl-6'><ChatMarkdownRender string={`3. **Switch models**: stop the server, run \`mlx_lm.server --model <other-hf-repo>\`, then click **Refresh** next to MLX in Settings → Models.`} chatMessageLocation={undefined} /></div>
<div className='pl-6'><ChatMarkdownRender string={`4. **Second model in parallel**: run another server on a different port (e.g. \`mlx_lm.server --model … --port 8081\`), then at the bottom of the model list use **Add Model** → **OpenAI-Compatible** with \`http://127.0.0.1:8081/v1\`, or add the model id manually under MLX if the endpoint exposes multiple ids.`} chatMessageLocation={undefined} /></div>
</div>
}
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>
}
const RedoOnboardingButton = ({ className }: { className?: string }) => {
const accessor = useAccessor()
@ -1181,6 +1254,8 @@ export const Settings = () => {
<ModelDump />
<div className='w-full h-[1px] my-4' />
<AutoDetectLocalModelsToggle />
<AutoSetupMlxToggle />
<AutoSetupAppleFoundationModelsToggle />
<RefreshableModels />
</ErrorBoundary>
</div>
@ -1194,6 +1269,10 @@ export const Settings = () => {
<div className='opacity-80 mb-4'>
<OllamaSetupInstructions sayWeAutoDetect={true} />
</div>
<div className='opacity-80'>
<MlxSetupInstructions />
<AppleFoundationModelsSetupInstructions />
</div>
<VoidProviderSettings providerNames={localProviderNames} />
</ErrorBoundary>

View file

@ -75,6 +75,14 @@ import '../common/voidSettingsService.js'
// refreshModel
import '../common/refreshModelService.js'
// Apple Foundation Models (macOS): auto-install afm + start server
import '../common/appleFoundationModelsService.js'
import './appleFoundationModelsWorkbenchContrib.js'
// MLX (macOS): auto-install mlx-lm + start mlx_lm.server
import '../common/mlxService.js'
import './mlxWorkbenchContrib.js'
// metrics
import '../common/metricsService.js'

View file

@ -0,0 +1,34 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js';
import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js';
import { AppleFoundationModelsEnsureResult, IAppleFoundationModelsMainService } from './appleFoundationModelsTypes.js';
export interface IAppleFoundationModelsService {
readonly _serviceBrand: undefined;
ensureReady(options: { installIfMissing: boolean; startServer: boolean; port?: number }): Promise<AppleFoundationModelsEnsureResult>;
}
export const IAppleFoundationModelsService = createDecorator<IAppleFoundationModelsService>('appleFoundationModelsService');
export class AppleFoundationModelsService implements IAppleFoundationModelsService {
readonly _serviceBrand: undefined;
private readonly _main: IAppleFoundationModelsMainService;
constructor(
@IMainProcessService mainProcessService: IMainProcessService,
) {
this._main = ProxyChannel.toService<IAppleFoundationModelsMainService>(mainProcessService.getChannel('void-channel-appleFoundationModels'));
}
ensureReady = (options: { installIfMissing: boolean; startServer: boolean; port?: number }): Promise<AppleFoundationModelsEnsureResult> => {
return this._main.ensureReady(options);
};
}
registerSingleton(IAppleFoundationModelsService, AppleFoundationModelsService, InstantiationType.Eager);

View file

@ -0,0 +1,34 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
export const APPLE_FOUNDATION_MODELS_DEFAULT_ENDPOINT = 'http://127.0.0.1:9999';
export const APPLE_FOUNDATION_MODELS_DEFAULT_PORT = 9999;
export type AppleFoundationModelsEnsureAction =
| 'already-running'
| 'started'
| 'installed-and-started';
export type AppleFoundationModelsEnsureFailureReason =
| 'not-mac'
| 'disabled'
| 'brew-missing'
| 'install-failed'
| 'afm-missing'
| 'server-timeout';
export type AppleFoundationModelsEnsureResult =
| { ok: true; endpoint: string; action: AppleFoundationModelsEnsureAction; log: string[] }
| { ok: false; reason: AppleFoundationModelsEnsureFailureReason; log: string[]; errorMessage?: string };
export interface IAppleFoundationModelsMainService {
readonly _serviceBrand: undefined;
ensureReady(options: { installIfMissing: boolean; startServer: boolean; port?: number }): Promise<AppleFoundationModelsEnsureResult>;
stopServerIfSpawnedByVoid(): Promise<void>;
}
export const IAppleFoundationModelsMainService = createDecorator<IAppleFoundationModelsMainService>('appleFoundationModelsMainService');

View file

@ -0,0 +1,52 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { ProviderName, VoidStatefulModelInfo } from './voidSettingsTypes.js';
/** One loaded model per endpoint (mlx_lm.server / afm). */
export const singleAutodetectedLocalProviders = ['mlx', 'appleFoundationModels'] as const satisfies ProviderName[]
export type SingleAutodetectedLocalProvider = typeof singleAutodetectedLocalProviders[number]
const canonicalAppleFoundationModelName = (modelName: string) =>
modelName === 'foundation-models' || modelName === 'foundation-model' ? 'foundation' : modelName
export const normalizeAutodetectedModelNamesForProvider = (providerName: ProviderName, modelNames: string[]): string[] => {
if (providerName === 'appleFoundationModels') {
const normalized = modelNames.map(canonicalAppleFoundationModelName)
return [new Set(normalized).values().next().value ?? 'foundation']
}
if (providerName === 'mlx') {
const unique = [...new Set(modelNames)]
return unique.length > 0 ? [unique[0]] : []
}
return modelNames
}
export const consolidateSingleAutodetectedProviderModels = (
providerName: SingleAutodetectedLocalProvider,
mergedModels: VoidStatefulModelInfo[],
): VoidStatefulModelInfo[] => {
const customModels = mergedModels.filter(m => m.type === 'custom')
const autodetected = mergedModels.find(m => m.type === 'autodetected')
if (!autodetected) {
if (providerName === 'appleFoundationModels') {
return [{ modelName: 'foundation', type: 'autodetected', isHidden: false }]
}
return customModels
}
const primaryName = providerName === 'appleFoundationModels'
? canonicalAppleFoundationModelName(autodetected.modelName)
: autodetected.modelName
const primary: VoidStatefulModelInfo = {
...autodetected,
modelName: primaryName,
type: 'autodetected',
}
return [
primary,
...customModels.filter(m => m.modelName !== primaryName),
]
}

View file

@ -0,0 +1,34 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js';
import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js';
import { IMlxMainService, MlxEnsureResult } from './mlxTypes.js';
export interface IMlxService {
readonly _serviceBrand: undefined;
ensureReady(options: { installIfMissing: boolean; startServer: boolean; port?: number; model?: string }): Promise<MlxEnsureResult>;
}
export const IMlxService = createDecorator<IMlxService>('mlxService');
export class MlxService implements IMlxService {
readonly _serviceBrand: undefined;
private readonly _main: IMlxMainService;
constructor(
@IMainProcessService mainProcessService: IMainProcessService,
) {
this._main = ProxyChannel.toService<IMlxMainService>(mainProcessService.getChannel('void-channel-mlx'));
}
ensureReady = (options: { installIfMissing: boolean; startServer: boolean; port?: number; model?: string }): Promise<MlxEnsureResult> => {
return this._main.ensureReady(options);
};
}
registerSingleton(IMlxService, MlxService, InstantiationType.Eager);

View file

@ -0,0 +1,32 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
export const MLX_DEFAULT_ENDPOINT = 'http://127.0.0.1:8080';
export const MLX_DEFAULT_PORT = 8080;
/** Small default model; first start may download weights from Hugging Face. */
export const MLX_DEFAULT_MODEL = 'mlx-community/Qwen2.5-Coder-1.5B-Instruct';
export type MlxEnsureAction = 'already-running' | 'started' | 'installed-and-started';
export type MlxEnsureFailureReason =
| 'not-mac'
| 'python-missing'
| 'install-failed'
| 'mlx-missing'
| 'server-timeout';
export type MlxEnsureResult =
| { ok: true; endpoint: string; action: MlxEnsureAction; log: string[] }
| { ok: false; reason: MlxEnsureFailureReason; log: string[]; errorMessage?: string };
export interface IMlxMainService {
readonly _serviceBrand: undefined;
ensureReady(options: { installIfMissing: boolean; startServer: boolean; port?: number; model?: string }): Promise<MlxEnsureResult>;
stopServerIfSpawnedByVoid(): Promise<void>;
}
export const IMlxMainService = createDecorator<IMlxMainService>('mlxMainService');

View file

@ -48,6 +48,12 @@ export const defaultProviderSettings = {
lmStudio: {
endpoint: 'http://localhost:1234',
},
mlx: {
endpoint: 'http://127.0.0.1:8080',
},
appleFoundationModels: {
endpoint: 'http://127.0.0.1:9999',
},
liteLLM: { // https://docs.litellm.ai/docs/providers/openai_compatible
endpoint: '',
},
@ -111,7 +117,8 @@ export const defaultModelsOfProvider = {
vLLM: [ // autodetected
],
lmStudio: [], // autodetected
mlx: [], // autodetected — mlx_lm.server
appleFoundationModels: [], // autodetected via afm /v1/models (model id: `foundation`)
openRouter: [ // https://openrouter.ai/models
'anthropic/claude-opus-4.7',
'anthropic/claude-sonnet-4.6',
@ -1594,6 +1601,47 @@ const lmStudioSettings: VoidStaticProviderInfo = {
},
}
const mlxSettings: VoidStaticProviderInfo = {
modelOptionsFallback: (modelName) => extensiveModelOptionsFallback(modelName, { downloadable: { sizeGb: 'not-known' } }),
modelOptions: {},
providerReasoningIOSettings: {
input: { includeInPayload: openAICompatIncludeInPayloadReasoning },
output: { needsManualParse: true },
},
}
const appleFoundationModelCapabilities = {
contextWindow: 32_768,
reservedOutputTokenSpace: 8_192,
cost: { input: 0, output: 0 },
downloadable: false as const,
supportsFIM: false,
supportsSystemMessage: 'system-role' as const,
specialToolFormat: 'openai-style' as const,
reasoningCapabilities: false as const,
}
const appleFoundationModelsModelOptions = {
'foundation': { // Apple on-device model via afm — https://github.com/scouzi1966/maclocal-api
...appleFoundationModelCapabilities,
},
} as const satisfies { [s: string]: VoidStaticModelInfo }
const appleFoundationModelsSettings: VoidStaticProviderInfo = {
modelOptions: appleFoundationModelsModelOptions,
modelOptionsFallback: (modelName) => {
const lower = modelName.toLowerCase()
if (lower.includes('foundation') || lower.includes('apple') || lower.includes('afm')) {
return { modelName, recognizedModelName: 'foundation', ...appleFoundationModelsModelOptions['foundation'] }
}
return extensiveModelOptionsFallback(modelName, { downloadable: false, contextWindow: 32_768 })
},
providerReasoningIOSettings: {
input: { includeInPayload: openAICompatIncludeInPayloadReasoning },
output: { needsManualParse: true },
},
}
const ollamaSettings: VoidStaticProviderInfo = {
modelOptionsFallback: (modelName) => extensiveModelOptionsFallback(modelName, { downloadable: { sizeGb: 'not-known' } }),
modelOptions: ollamaModelOptions,
@ -1867,6 +1915,8 @@ const modelSettingsOfProvider: { [providerName in ProviderName]: VoidStaticProvi
liteLLM: liteLLMSettings,
lmStudio: lmStudioSettings,
mlx: mlxSettings,
appleFoundationModels: appleFoundationModelsSettings,
googleVertex: googleVertexSettings,
microsoftAzure: microsoftAzureSettings,

View file

@ -8,6 +8,7 @@ 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 { normalizeAutodetectedModelNamesForProvider } from './localSingleModelProviders.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 +48,8 @@ const refreshBasedOn: { [k in RefreshableProviderName]: (keyof SettingsOfProvide
ollama: ['_didFillInProviderSettings', 'endpoint'],
vLLM: ['_didFillInProviderSettings', 'endpoint'],
lmStudio: ['_didFillInProviderSettings', 'endpoint'],
mlx: ['_didFillInProviderSettings', 'endpoint'],
appleFoundationModels: ['_didFillInProviderSettings', 'endpoint'],
// openAICompatible: ['_didFillInProviderSettings', 'endpoint', 'apiKey'],
}
const REFRESH_INTERVAL = 5_000
@ -144,6 +147,8 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ
ollama: { state: 'init', timeoutId: null },
vLLM: { state: 'init', timeoutId: null },
lmStudio: { state: 'init', timeoutId: null },
mlx: { state: 'init', timeoutId: null },
appleFoundationModels: { state: 'init', timeoutId: null },
}
@ -168,14 +173,17 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ
providerName,
onSuccess: ({ models }) => {
// set the models to the detected models
const modelNames = models.map(model => {
if (providerName === 'ollama') return (model as OllamaModelResponse).name;
else if (providerName === 'vLLM' || providerName === 'lmStudio' || providerName === 'mlx' || providerName === 'appleFoundationModels') {
return (model as OpenaiCompatibleModelResponse).id;
}
else throw new Error('refreshMode fn: unknown provider', providerName);
});
const normalizedModelNames = normalizeAutodetectedModelNamesForProvider(providerName, modelNames);
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);
}),
normalizedModelNames,
{ enableProviderOnSuccess: options.enableProviderOnSuccess, hideRefresh: options.doNotFire }
)

View file

@ -13,6 +13,8 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo
import { IMetricsService } from './metricsService.js';
import { defaultProviderSettings, getModelCapabilities, ModelOverrides } from './modelCapabilities.js';
import { VOID_SETTINGS_STORAGE_KEY } from './storageKeys.js';
import { isMacintosh } from '../../../../base/common/platform.js';
import { consolidateSingleAutodetectedProviderModels, normalizeAutodetectedModelNamesForProvider } from './localSingleModelProviders.js';
import { defaultSettingsOfProvider, FeatureName, ProviderName, ModelSelectionOfFeature, SettingsOfProvider, SettingName, providerNames, ModelSelection, modelSelectionsEqual, featureNames, VoidStatefulModelInfo, GlobalSettings, GlobalSettingName, defaultGlobalSettings, ModelSelectionOptions, OptionsOfModelSelection, ChatMode, OverridesOfModel, defaultOverridesOfModel, MCPUserStateOfName as MCPUserStateOfName, MCPUserState } from './voidSettingsTypes.js';
@ -103,7 +105,6 @@ const _modelsWithSwappedInNewModels = (options: { existingModels: VoidStatefulMo
]
}
export const modelFilterOfFeatureName: {
[featureName in FeatureName]: {
filter: (
@ -292,6 +293,14 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
// add autoAcceptLLMChanges feature
if (readS.globalSettings.autoAcceptLLMChanges === undefined) readS.globalSettings.autoAcceptLLMChanges = false;
// Apple Foundation Models auto-setup (macOS only; default off on other platforms)
if (readS.globalSettings.autoSetupAppleFoundationModels === undefined) {
readS.globalSettings.autoSetupAppleFoundationModels = isMacintosh;
}
if (readS.globalSettings.autoSetupMlx === undefined) {
readS.globalSettings.autoSetupMlx = isMacintosh;
}
}
catch (e) {
readS = defaultState()
@ -329,6 +338,13 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
if (providerName === 'openAICompatible' && !readS.settingsOfProvider[providerName].headersJSON) {
readS.settingsOfProvider[providerName].headersJSON = '{}'
}
if (providerName === 'mlx' || providerName === 'appleFoundationModels') {
readS.settingsOfProvider[providerName].models = consolidateSingleAutodetectedProviderModels(
providerName,
readS.settingsOfProvider[providerName].models,
)
}
}
}
@ -504,7 +520,11 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
const { models } = this.state.settingsOfProvider[providerName]
const oldModelNames = models.map(m => m.modelName)
const newModels = _modelsWithSwappedInNewModels({ existingModels: models, models: autodetectedModelNames, type: 'autodetected' })
const normalizedNames = normalizeAutodetectedModelNamesForProvider(providerName, autodetectedModelNames)
let newModels = _modelsWithSwappedInNewModels({ existingModels: models, models: normalizedNames, type: 'autodetected' })
if (providerName === 'mlx' || providerName === 'appleFoundationModels') {
newModels = consolidateSingleAutodetectedProviderModels(providerName, newModels)
}
this.setSettingOfProvider(providerName, 'models', newModels)
// if the models changed, log it

View file

@ -16,7 +16,7 @@ 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', 'mlx', 'appleFoundationModels'] satisfies ProviderName[] // all local names
export const nonlocalProviderNames = providerNames.filter((name) => !(localProviderNames as string[]).includes(name)) // all non-local names
type CustomSettingName = UnionOfKeys<typeof defaultProviderSettings[ProviderName]>
@ -82,6 +82,12 @@ export const displayInfoOfProviderName = (providerName: ProviderName): DisplayIn
else if (providerName === 'lmStudio') {
return { title: 'LM Studio', }
}
else if (providerName === 'mlx') {
return { title: 'MLX', }
}
else if (providerName === 'appleFoundationModels') {
return { title: 'Apple', }
}
else if (providerName === 'openAICompatible') {
return { title: 'OpenAI-Compatible', }
}
@ -127,6 +133,8 @@ export const subTextMdOfProviderName = (providerName: ProviderName): string => {
if (providerName === 'ollama') return 'Read more about custom [Endpoints here](https://github.com/ollama/ollama/blob/main/docs/faq.md#how-can-i-expose-ollama-on-my-network).'
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 === 'liteLLM') return 'Read more about endpoints [here](https://docs.litellm.ai/docs/providers/openai_compatible).'
throw new Error(`subTextMdOfProviderName: Unknown provider name: "${providerName}"`)
@ -166,6 +174,8 @@ export const displayInfoOfSettingName = (providerName: ProviderName, settingName
title: providerName === 'ollama' ? 'Endpoint' :
providerName === 'vLLM' ? 'Endpoint' :
providerName === 'lmStudio' ? 'Endpoint' :
providerName === 'mlx' ? 'Endpoint' :
providerName === 'appleFoundationModels' ? 'Endpoint' :
providerName === 'openAICompatible' ? 'baseURL' : // (do not include /chat/completions)
providerName === 'googleVertex' ? 'baseURL' :
providerName === 'microsoftAzure' ? 'baseURL' :
@ -177,6 +187,8 @@ export const displayInfoOfSettingName = (providerName: ProviderName, settingName
: providerName === 'vLLM' ? defaultProviderSettings.vLLM.endpoint
: providerName === 'openAICompatible' ? 'https://my-website.com/v1'
: providerName === 'lmStudio' ? defaultProviderSettings.lmStudio.endpoint
: providerName === 'mlx' ? defaultProviderSettings.mlx.endpoint
: providerName === 'appleFoundationModels' ? defaultProviderSettings.appleFoundationModels.endpoint
: providerName === 'liteLLM' ? 'http://localhost:4000'
: providerName === 'awsBedrock' ? 'http://localhost:4000/v1'
: '(never)',
@ -334,6 +346,18 @@ export const defaultSettingsOfProvider: SettingsOfProvider = {
...modelInfoOfDefaultModelNames(defaultModelsOfProvider.vLLM),
_didFillInProviderSettings: undefined,
},
mlx: {
...defaultCustomSettings,
...defaultProviderSettings.mlx,
...modelInfoOfDefaultModelNames(defaultModelsOfProvider.mlx),
_didFillInProviderSettings: undefined,
},
appleFoundationModels: {
...defaultCustomSettings,
...defaultProviderSettings.appleFoundationModels,
...modelInfoOfDefaultModelNames(defaultModelsOfProvider.appleFoundationModels),
_didFillInProviderSettings: undefined,
},
googleVertex: { // aggregator (serves models from multiple providers)
...defaultCustomSettings,
...defaultProviderSettings.googleVertex,
@ -440,6 +464,10 @@ 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 */
autoSetupAppleFoundationModels: boolean;
/** macOS only: install `mlx-lm` via pip and start mlx_lm.server */
autoSetupMlx: boolean;
aiInstructions: string;
enableAutocomplete: boolean;
syncApplyToChat: boolean;
@ -456,6 +484,8 @@ export type GlobalSettings = {
export const defaultGlobalSettings: GlobalSettings = {
autoRefreshModels: true,
autoSetupAppleFoundationModels: true,
autoSetupMlx: true,
aiInstructions: '',
enableAutocomplete: false,
syncApplyToChat: true,

View file

@ -0,0 +1,166 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { spawn, type ChildProcess } from 'child_process';
import { promisify } from 'util';
import { exec as _exec } from 'child_process';
import { isMacintosh } from '../../../../base/common/platform.js';
import {
APPLE_FOUNDATION_MODELS_DEFAULT_PORT,
AppleFoundationModelsEnsureResult,
IAppleFoundationModelsMainService,
} from '../common/appleFoundationModelsTypes.js';
const exec = promisify(_exec);
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
export class AppleFoundationModelsMainService implements IAppleFoundationModelsMainService {
readonly _serviceBrand: undefined;
private _child: ChildProcess | null = null;
private _spawnedByVoid = false;
async ensureReady(options: { installIfMissing: boolean; startServer: boolean; port?: number }): Promise<AppleFoundationModelsEnsureResult> {
const log: string[] = [];
const port = options.port ?? APPLE_FOUNDATION_MODELS_DEFAULT_PORT;
const endpoint = `http://127.0.0.1:${port}`;
if (!isMacintosh) {
return { ok: false, reason: 'not-mac', log };
}
if (await this._isServerUp(endpoint)) {
log.push(`Serveur déjà actif sur ${endpoint}`);
return { ok: true, endpoint, action: 'already-running', log };
}
let didInstall = false;
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' };
}
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 });
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 };
}
afmPath = await this._whichAfm();
if (!afmPath) {
log.push('afm toujours introuvable après installation.');
return { ok: false, reason: 'afm-missing', log };
}
} else {
log.push(`afm trouvé : ${afmPath}`);
}
if (options.startServer) {
await this._startServer(afmPath, port, log);
}
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}`);
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.` };
}
async stopServerIfSpawnedByVoid(): Promise<void> {
if (!this._spawnedByVoid || !this._child || this._child.killed) {
return;
}
try {
this._child.kill();
} catch {
// ignore
}
this._child = null;
this._spawnedByVoid = false;
}
private async _whichAfm(): Promise<string | null> {
try {
const { stdout } = await exec('which afm', { timeout: 5_000 });
const path = stdout.trim();
return path || null;
} catch {
return null;
}
}
private async _whichBrew(): Promise<string | null> {
try {
const { stdout } = await exec('which brew', { timeout: 5_000 });
const path = stdout.trim();
return path || null;
} catch {
return null;
}
}
private async _isServerUp(endpoint: string): Promise<boolean> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2_500);
try {
const health = await fetch(`${endpoint}/health`, { signal: controller.signal });
if (health.ok) {
return true;
}
} catch {
// try models next
} finally {
clearTimeout(timeout);
}
const controller2 = new AbortController();
const timeout2 = setTimeout(() => controller2.abort(), 2_500);
try {
const models = await fetch(`${endpoint}/v1/models`, { signal: controller2.signal });
return models.ok;
} catch {
return false;
} finally {
clearTimeout(timeout2);
}
}
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.');
return;
}
log.push(`Démarrage de afm sur le port ${port}`);
const child = spawn(afmPath, ['-p', String(port), '-H', '127.0.0.1'], {
detached: true,
stdio: 'ignore',
});
child.unref();
this._child = child;
this._spawnedByVoid = true;
}
}

View file

@ -139,6 +139,14 @@ const newOpenAICompatibleSDK = async ({ settingsOfProvider, providerName }: { se
const thisConfig = settingsOfProvider[providerName]
return new OpenAI({ baseURL: `${thisConfig.endpoint}/v1`, apiKey: 'noop', ...commonPayloadOpts })
}
else if (providerName === 'mlx') {
const thisConfig = settingsOfProvider[providerName]
return new OpenAI({ baseURL: `${thisConfig.endpoint}/v1`, apiKey: 'noop', ...commonPayloadOpts })
}
else if (providerName === 'appleFoundationModels') {
const thisConfig = settingsOfProvider[providerName]
return new OpenAI({ baseURL: `${thisConfig.endpoint}/v1`, apiKey: 'noop', ...commonPayloadOpts })
}
else if (providerName === 'openRouter') {
const thisConfig = settingsOfProvider[providerName]
return new OpenAI({
@ -963,6 +971,16 @@ export const sendLLMMessageToProviderImplementation = {
sendFIM: (params) => _sendOpenAICompatibleFIM(params),
list: (params) => _openaiCompatibleList(params),
},
mlx: {
sendChat: (params) => _sendOpenAICompatibleChat(params),
sendFIM: (params) => _sendOpenAICompatibleFIM(params),
list: (params) => _openaiCompatibleList(params),
},
appleFoundationModels: {
sendChat: (params) => _sendOpenAICompatibleChat(params),
sendFIM: null,
list: (params) => _openaiCompatibleList(params),
},
liteLLM: {
sendChat: (params) => _sendOpenAICompatibleChat(params),
sendFIM: (params) => _sendOpenAICompatibleFIM(params),

View file

@ -0,0 +1,174 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { spawn, type ChildProcess } from 'child_process';
import { promisify } from 'util';
import { exec as _exec } from 'child_process';
import { isMacintosh } from '../../../../base/common/platform.js';
import {
MLX_DEFAULT_MODEL,
MLX_DEFAULT_PORT,
IMlxMainService,
MlxEnsureResult,
} from '../common/mlxTypes.js';
const exec = promisify(_exec);
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
type MlxServerCommand = { command: string; baseArgs: string[] };
export class MlxMainService implements IMlxMainService {
readonly _serviceBrand: undefined;
private _child: ChildProcess | null = null;
private _spawnedByVoid = false;
async ensureReady(options: { installIfMissing: boolean; startServer: boolean; port?: number; model?: string }): Promise<MlxEnsureResult> {
const log: string[] = [];
const port = options.port ?? MLX_DEFAULT_PORT;
const model = options.model ?? MLX_DEFAULT_MODEL;
const endpoint = `http://127.0.0.1:${port}`;
if (!isMacintosh) {
return { ok: false, reason: 'not-mac', log };
}
if (await this._isServerUp(endpoint)) {
log.push(`Server already running at ${endpoint}`);
return { ok: true, endpoint, action: 'already-running', log };
}
const pythonPath = await this._whichPython3();
if (!pythonPath) {
log.push('python3 not found.');
return { ok: false, reason: 'python-missing', log, errorMessage: 'Install Python 3, then run: python3 -m pip install mlx-lm' };
}
log.push(`Using ${pythonPath}`);
let didInstall = false;
let serverCmd = await this._resolveMlxServerCommand(pythonPath);
if (!serverCmd) {
if (!options.installIfMissing) {
log.push('mlx-lm is not installed.');
return { ok: false, reason: 'mlx-missing', log, errorMessage: 'Run: python3 -m pip install mlx-lm' };
}
log.push('Installing mlx-lm via pip…');
try {
await exec(`"${pythonPath}" -m pip install --upgrade mlx-lm`, { timeout: 600_000 });
didInstall = true;
log.push('pip install finished.');
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
log.push(`Install failed: ${msg}`);
return { ok: false, reason: 'install-failed', log, errorMessage: msg };
}
serverCmd = await this._resolveMlxServerCommand(pythonPath);
if (!serverCmd) {
log.push('mlx-lm still not available after install.');
return { ok: false, reason: 'mlx-missing', log };
}
}
if (options.startServer) {
await this._startServer(serverCmd, port, model, log);
}
// First launch may download the model from Hugging Face (can take several minutes).
for (let i = 0; i < 300; i++) {
if (await this._isServerUp(endpoint)) {
const action = didInstall ? 'installed-and-started' as const : 'started' as const;
log.push(`Server ready at ${endpoint}`);
return { ok: true, endpoint, action, log };
}
await sleep(2000);
}
log.push('Timed out waiting for mlx_lm.server.');
return {
ok: false,
reason: 'server-timeout',
log,
errorMessage: `Server did not respond at ${endpoint}. Run manually: mlx_lm.server --model ${model} --port ${port}`,
};
}
async stopServerIfSpawnedByVoid(): Promise<void> {
if (!this._spawnedByVoid || !this._child || this._child.killed) {
return;
}
try {
this._child.kill();
} catch {
// ignore
}
this._child = null;
this._spawnedByVoid = 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 _resolveMlxServerCommand(pythonPath: string): Promise<MlxServerCommand | null> {
try {
const { stdout } = await exec('which mlx_lm.server', { timeout: 5_000 });
const path = stdout.trim();
if (path) {
return { command: path, baseArgs: [] };
}
} catch {
// fall through
}
try {
await exec(`"${pythonPath}" -m mlx_lm.server --help`, { timeout: 10_000 });
return { command: pythonPath, baseArgs: ['-m', 'mlx_lm.server'] };
} catch {
return null;
}
}
private async _isServerUp(endpoint: string): Promise<boolean> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2_500);
try {
const res = await fetch(`${endpoint}/v1/models`, { signal: controller.signal });
return res.ok;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
private async _startServer(serverCmd: MlxServerCommand, port: number, model: string, log: string[]): Promise<void> {
if (this._child && !this._child.killed) {
log.push('Void mlx server process already running.');
return;
}
log.push(`Starting mlx_lm.server on port ${port} with model ${model}`);
const args = [...serverCmd.baseArgs, '--model', model, '--port', String(port)];
const child = spawn(serverCmd.command, args, {
detached: true,
stdio: 'ignore',
});
child.unref();
this._child = child;
this._spawnedByVoid = true;
}
}