diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index c3d2dfe5..49a64f59 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -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); diff --git a/src/vs/workbench/contrib/void/browser/appleFoundationModelsWorkbenchContrib.ts b/src/vs/workbench/contrib/void/browser/appleFoundationModelsWorkbenchContrib.ts new file mode 100644 index 00000000..98464373 --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/appleFoundationModelsWorkbenchContrib.ts @@ -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 { + 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); diff --git a/src/vs/workbench/contrib/void/browser/mlxWorkbenchContrib.ts b/src/vs/workbench/contrib/void/browser/mlxWorkbenchContrib.ts new file mode 100644 index 00000000..042d32d6 --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/mlxWorkbenchContrib.ts @@ -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 { + 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); diff --git a/src/vs/workbench/contrib/void/browser/react/src/void-onboarding/VoidOnboarding.tsx b/src/vs/workbench/contrib/void/browser/react/src/void-onboarding/VoidOnboarding.tsx index a27cbf76..e0077a62 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/void-onboarding/VoidOnboarding.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/void-onboarding/VoidOnboarding.tsx @@ -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: "", } diff --git a/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx b/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx index acc3c6d6..0c131792 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx @@ -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' ? - : + : (providerName === 'mlx' || providerName === 'appleFoundationModels' || providerName === 'lmStudio' || providerName === 'vLLM') ? + + : : null} @@ -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 { + 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 { + 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?: } +export const MlxSetupInstructions = () => { + return
+
+
+
+
\`, then click **Refresh** next to MLX in Settings → Models.`} chatMessageLocation={undefined} />
+
+
+} + +export const AppleFoundationModelsSetupInstructions = () => { + if (os !== 'mac') return null + return
+
+
+
+
+
+
+} + const RedoOnboardingButton = ({ className }: { className?: string }) => { const accessor = useAccessor() @@ -1181,6 +1254,8 @@ export const Settings = () => {
+ +
@@ -1194,6 +1269,10 @@ export const Settings = () => {
+
+ + +
diff --git a/src/vs/workbench/contrib/void/browser/void.contribution.ts b/src/vs/workbench/contrib/void/browser/void.contribution.ts index 35c89184..0c069174 100644 --- a/src/vs/workbench/contrib/void/browser/void.contribution.ts +++ b/src/vs/workbench/contrib/void/browser/void.contribution.ts @@ -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' diff --git a/src/vs/workbench/contrib/void/common/appleFoundationModelsService.ts b/src/vs/workbench/contrib/void/common/appleFoundationModelsService.ts new file mode 100644 index 00000000..34631dc1 --- /dev/null +++ b/src/vs/workbench/contrib/void/common/appleFoundationModelsService.ts @@ -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; +} + +export const IAppleFoundationModelsService = createDecorator('appleFoundationModelsService'); + +export class AppleFoundationModelsService implements IAppleFoundationModelsService { + readonly _serviceBrand: undefined; + private readonly _main: IAppleFoundationModelsMainService; + + constructor( + @IMainProcessService mainProcessService: IMainProcessService, + ) { + this._main = ProxyChannel.toService(mainProcessService.getChannel('void-channel-appleFoundationModels')); + } + + ensureReady = (options: { installIfMissing: boolean; startServer: boolean; port?: number }): Promise => { + return this._main.ensureReady(options); + }; +} + +registerSingleton(IAppleFoundationModelsService, AppleFoundationModelsService, InstantiationType.Eager); diff --git a/src/vs/workbench/contrib/void/common/appleFoundationModelsTypes.ts b/src/vs/workbench/contrib/void/common/appleFoundationModelsTypes.ts new file mode 100644 index 00000000..3ad0562e --- /dev/null +++ b/src/vs/workbench/contrib/void/common/appleFoundationModelsTypes.ts @@ -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; + stopServerIfSpawnedByVoid(): Promise; +} + +export const IAppleFoundationModelsMainService = createDecorator('appleFoundationModelsMainService'); diff --git a/src/vs/workbench/contrib/void/common/localSingleModelProviders.ts b/src/vs/workbench/contrib/void/common/localSingleModelProviders.ts new file mode 100644 index 00000000..588ae055 --- /dev/null +++ b/src/vs/workbench/contrib/void/common/localSingleModelProviders.ts @@ -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), + ] +} diff --git a/src/vs/workbench/contrib/void/common/mlxService.ts b/src/vs/workbench/contrib/void/common/mlxService.ts new file mode 100644 index 00000000..dbc50f16 --- /dev/null +++ b/src/vs/workbench/contrib/void/common/mlxService.ts @@ -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; +} + +export const IMlxService = createDecorator('mlxService'); + +export class MlxService implements IMlxService { + readonly _serviceBrand: undefined; + private readonly _main: IMlxMainService; + + constructor( + @IMainProcessService mainProcessService: IMainProcessService, + ) { + this._main = ProxyChannel.toService(mainProcessService.getChannel('void-channel-mlx')); + } + + ensureReady = (options: { installIfMissing: boolean; startServer: boolean; port?: number; model?: string }): Promise => { + return this._main.ensureReady(options); + }; +} + +registerSingleton(IMlxService, MlxService, InstantiationType.Eager); diff --git a/src/vs/workbench/contrib/void/common/mlxTypes.ts b/src/vs/workbench/contrib/void/common/mlxTypes.ts new file mode 100644 index 00000000..efaabeb7 --- /dev/null +++ b/src/vs/workbench/contrib/void/common/mlxTypes.ts @@ -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; + stopServerIfSpawnedByVoid(): Promise; +} + +export const IMlxMainService = createDecorator('mlxMainService'); diff --git a/src/vs/workbench/contrib/void/common/modelCapabilities.ts b/src/vs/workbench/contrib/void/common/modelCapabilities.ts index 3b3344d8..88518c0f 100644 --- a/src/vs/workbench/contrib/void/common/modelCapabilities.ts +++ b/src/vs/workbench/contrib/void/common/modelCapabilities.ts @@ -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, diff --git a/src/vs/workbench/contrib/void/common/refreshModelService.ts b/src/vs/workbench/contrib/void/common/refreshModelService.ts index c4bfe115..0a3cda6b 100644 --- a/src/vs/workbench/contrib/void/common/refreshModelService.ts +++ b/src/vs/workbench/contrib/void/common/refreshModelService.ts @@ -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 } ) diff --git a/src/vs/workbench/contrib/void/common/voidSettingsService.ts b/src/vs/workbench/contrib/void/common/voidSettingsService.ts index 3e0c2295..6dd4055b 100644 --- a/src/vs/workbench/contrib/void/common/voidSettingsService.ts +++ b/src/vs/workbench/contrib/void/common/voidSettingsService.ts @@ -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 diff --git a/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts b/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts index 38497c60..159b4557 100644 --- a/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts +++ b/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts @@ -16,7 +16,7 @@ type UnionOfKeys = 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 @@ -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, diff --git a/src/vs/workbench/contrib/void/electron-main/appleFoundationModelsMainService.ts b/src/vs/workbench/contrib/void/electron-main/appleFoundationModelsMainService.ts new file mode 100644 index 00000000..d4979078 --- /dev/null +++ b/src/vs/workbench/contrib/void/electron-main/appleFoundationModelsMainService.ts @@ -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(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 { + 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 d’installer 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 n’a pas répondu sur ${endpoint}. Lancez \`afm -p ${port}\` manuellement.` }; + } + + async stopServerIfSpawnedByVoid(): Promise { + 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 { + try { + const { stdout } = await exec('which afm', { timeout: 5_000 }); + const path = stdout.trim(); + return path || null; + } catch { + return null; + } + } + + private async _whichBrew(): Promise { + 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 { + 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 { + 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; + } +} diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts index 15033341..60d4f7fa 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts @@ -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), diff --git a/src/vs/workbench/contrib/void/electron-main/mlxMainService.ts b/src/vs/workbench/contrib/void/electron-main/mlxMainService.ts new file mode 100644 index 00000000..ce29399a --- /dev/null +++ b/src/vs/workbench/contrib/void/electron-main/mlxMainService.ts @@ -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(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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +}