From b16b519f4ea025dbc564fdfd9783f02ba5e74ad4 Mon Sep 17 00:00:00 2001 From: Louis Date: Thu, 29 Jan 2026 15:09:54 +0700 Subject: [PATCH] feat: support mlx plugin # Conflicts: # Makefile # web-app/src/routes/settings/providers/$providerName.tsx --- .gitignore | 2 + Makefile | 12 + extensions/mlx-extension/package.json | 40 + extensions/mlx-extension/rolldown.config.mjs | 21 + extensions/mlx-extension/settings.json | 33 + extensions/mlx-extension/src/env.d.ts | 5 + extensions/mlx-extension/src/index.ts | 825 ++++++++++++++++++ extensions/mlx-extension/tsconfig.json | 15 + mlx-server/Package.resolved | 266 ++++++ mlx-server/Package.swift | 36 + .../Sources/MLXServer/MLXServerCommand.swift | 67 ++ .../Sources/MLXServer/ModelRunner.swift | 364 ++++++++ .../Sources/MLXServer/OpenAITypes.swift | 225 +++++ mlx-server/Sources/MLXServer/Server.swift | 340 ++++++++ src-tauri/Cargo.lock | 16 + src-tauri/Cargo.toml | 5 +- src-tauri/capabilities/default.json | 1 + src-tauri/capabilities/desktop.json | 1 + .../tauri-plugin-llamacpp/guest-js/types.ts | 2 + src-tauri/plugins/tauri-plugin-mlx/Cargo.toml | 27 + src-tauri/plugins/tauri-plugin-mlx/build.rs | 14 + .../tauri-plugin-mlx/dist-js/index.cjs | 63 ++ .../tauri-plugin-mlx/dist-js/index.d.ts | 10 + .../plugins/tauri-plugin-mlx/dist-js/index.js | 54 ++ .../tauri-plugin-mlx/dist-js/types.d.ts | 18 + .../tauri-plugin-mlx/guest-js/index.ts | 73 ++ .../tauri-plugin-mlx/guest-js/types.ts | 20 + .../plugins/tauri-plugin-mlx/package.json | 33 + .../commands/cleanup_mlx_processes.toml | 13 + .../commands/find_mlx_session_by_model.toml | 13 + .../commands/get_mlx_all_sessions.toml | 13 + .../commands/get_mlx_loaded_models.toml | 13 + .../commands/get_mlx_random_port.toml | 13 + .../commands/is_mlx_process_running.toml | 13 + .../commands/load_mlx_model.toml | 13 + .../commands/unload_mlx_model.toml | 13 + .../permissions/autogenerated/reference.md | 232 +++++ .../tauri-plugin-mlx/permissions/default.toml | 12 + .../permissions/schemas/schema.json | 402 +++++++++ .../plugins/tauri-plugin-mlx/rollup.config.js | 31 + .../plugins/tauri-plugin-mlx/src/cleanup.rs | 59 ++ .../plugins/tauri-plugin-mlx/src/commands.rs | 360 ++++++++ .../plugins/tauri-plugin-mlx/src/error.rs | 91 ++ src-tauri/plugins/tauri-plugin-mlx/src/lib.rs | 32 + .../plugins/tauri-plugin-mlx/src/process.rs | 120 +++ .../plugins/tauri-plugin-mlx/src/state.rs | 39 + .../plugins/tauri-plugin-mlx/tsconfig.json | 14 + src-tauri/plugins/yarn.lock | 12 + src-tauri/src/lib.rs | 16 + web-app/public/images/model-provider/mlx.png | Bin 0 -> 64130 bytes .../src/containers/dialogs/DeleteModel.tsx | 2 +- .../dialogs/ImportMlxModelDialog.tsx | 264 ++++++ web-app/src/lib/custom-chat-transport.ts | 19 +- web-app/src/lib/model-factory.ts | 58 ++ web-app/src/lib/utils.ts | 4 + .../settings/providers/$providerName.tsx | 151 ++-- web-app/src/services/models/default.ts | 4 +- web-app/src/services/models/types.ts | 2 +- 58 files changed, 4527 insertions(+), 89 deletions(-) create mode 100644 extensions/mlx-extension/package.json create mode 100644 extensions/mlx-extension/rolldown.config.mjs create mode 100644 extensions/mlx-extension/settings.json create mode 100644 extensions/mlx-extension/src/env.d.ts create mode 100644 extensions/mlx-extension/src/index.ts create mode 100644 extensions/mlx-extension/tsconfig.json create mode 100644 mlx-server/Package.resolved create mode 100644 mlx-server/Package.swift create mode 100644 mlx-server/Sources/MLXServer/MLXServerCommand.swift create mode 100644 mlx-server/Sources/MLXServer/ModelRunner.swift create mode 100644 mlx-server/Sources/MLXServer/OpenAITypes.swift create mode 100644 mlx-server/Sources/MLXServer/Server.swift create mode 100644 src-tauri/plugins/tauri-plugin-mlx/Cargo.toml create mode 100644 src-tauri/plugins/tauri-plugin-mlx/build.rs create mode 100644 src-tauri/plugins/tauri-plugin-mlx/dist-js/index.cjs create mode 100644 src-tauri/plugins/tauri-plugin-mlx/dist-js/index.d.ts create mode 100644 src-tauri/plugins/tauri-plugin-mlx/dist-js/index.js create mode 100644 src-tauri/plugins/tauri-plugin-mlx/dist-js/types.d.ts create mode 100644 src-tauri/plugins/tauri-plugin-mlx/guest-js/index.ts create mode 100644 src-tauri/plugins/tauri-plugin-mlx/guest-js/types.ts create mode 100644 src-tauri/plugins/tauri-plugin-mlx/package.json create mode 100644 src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/cleanup_mlx_processes.toml create mode 100644 src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/find_mlx_session_by_model.toml create mode 100644 src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/get_mlx_all_sessions.toml create mode 100644 src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/get_mlx_loaded_models.toml create mode 100644 src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/get_mlx_random_port.toml create mode 100644 src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/is_mlx_process_running.toml create mode 100644 src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/load_mlx_model.toml create mode 100644 src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/unload_mlx_model.toml create mode 100644 src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/reference.md create mode 100644 src-tauri/plugins/tauri-plugin-mlx/permissions/default.toml create mode 100644 src-tauri/plugins/tauri-plugin-mlx/permissions/schemas/schema.json create mode 100644 src-tauri/plugins/tauri-plugin-mlx/rollup.config.js create mode 100644 src-tauri/plugins/tauri-plugin-mlx/src/cleanup.rs create mode 100644 src-tauri/plugins/tauri-plugin-mlx/src/commands.rs create mode 100644 src-tauri/plugins/tauri-plugin-mlx/src/error.rs create mode 100644 src-tauri/plugins/tauri-plugin-mlx/src/lib.rs create mode 100644 src-tauri/plugins/tauri-plugin-mlx/src/process.rs create mode 100644 src-tauri/plugins/tauri-plugin-mlx/src/state.rs create mode 100644 src-tauri/plugins/tauri-plugin-mlx/tsconfig.json create mode 100644 web-app/public/images/model-provider/mlx.png create mode 100644 web-app/src/containers/dialogs/ImportMlxModelDialog.tsx diff --git a/.gitignore b/.gitignore index e78486abd..2d626353c 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,5 @@ src-tauri/resources/ test-data llm-docs .claude/agents +mlx-server/.build +mlx-server/.swiftpm diff --git a/Makefile b/Makefile index 1e3258a9f..7d40143f5 100644 --- a/Makefile +++ b/Makefile @@ -118,6 +118,18 @@ endif cargo test --manifest-path src-tauri/plugins/tauri-plugin-llamacpp/Cargo.toml cargo test --manifest-path src-tauri/utils/Cargo.toml +# Build MLX server (macOS Apple Silicon only) +build-mlx-server: +ifeq ($(shell uname -s),Darwin) + @echo "Building MLX server for Apple Silicon..." +# cd mlx-server && swift build -c release + cd mlx-server && xcodebuild build -scheme mlx-server -destination 'platform=OS X' +# -configuration Release + @echo "MLX server built successfully" +else + @echo "Skipping MLX server build (macOS only)" +endif + # Build build: install-and-build install-rust-targets yarn build diff --git a/extensions/mlx-extension/package.json b/extensions/mlx-extension/package.json new file mode 100644 index 000000000..bc69917d7 --- /dev/null +++ b/extensions/mlx-extension/package.json @@ -0,0 +1,40 @@ +{ + "name": "@janhq/mlx-extension", + "productName": "MLX Inference Engine", + "version": "1.0.0", + "description": "This extension enables MLX-Swift inference on Apple Silicon Macs", + "main": "dist/index.js", + "module": "dist/module.js", + "engine": "mlx", + "author": "Jan ", + "license": "AGPL-3.0", + "scripts": { + "build": "rolldown -c rolldown.config.mjs", + "build:publish": "rimraf *.tgz --glob || true && yarn build && npm pack && cpx *.tgz ../../pre-install" + }, + "devDependencies": { + "cpx": "1.5.0", + "rimraf": "3.0.2", + "rolldown": "1.0.0-beta.1", + "typescript": "5.9.2" + }, + "dependencies": { + "@janhq/core": "../../core/package.tgz", + "@janhq/tauri-plugin-llamacpp-api": "link:../../src-tauri/plugins/tauri-plugin-llamacpp", + "@janhq/tauri-plugin-mlx-api": "link:../../src-tauri/plugins/tauri-plugin-mlx", + "@tauri-apps/api": "2.8.0", + "@tauri-apps/plugin-http": "2.5.0", + "@tauri-apps/plugin-log": "^2.6.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "files": [ + "dist/*", + "package.json" + ], + "installConfig": { + "hoistingLimits": "workspaces" + }, + "packageManager": "yarn@4.5.3" +} diff --git a/extensions/mlx-extension/rolldown.config.mjs b/extensions/mlx-extension/rolldown.config.mjs new file mode 100644 index 000000000..c96d8e8e9 --- /dev/null +++ b/extensions/mlx-extension/rolldown.config.mjs @@ -0,0 +1,21 @@ + +import { defineConfig } from 'rolldown' +import pkgJson from './package.json' with { type: 'json' } +import settingJson from './settings.json' with { type: 'json' } + +export default defineConfig({ + input: 'src/index.ts', + output: { + format: 'esm', + file: 'dist/index.js', + }, + platform: 'browser', + define: { + SETTINGS: JSON.stringify(settingJson), + ENGINE: JSON.stringify(pkgJson.engine), + IS_MAC: JSON.stringify(process.platform === 'darwin'), + }, + inject: process.env.IS_DEV ? {} : { + fetch: ['@tauri-apps/plugin-http', 'fetch'], + }, +}) diff --git a/extensions/mlx-extension/settings.json b/extensions/mlx-extension/settings.json new file mode 100644 index 000000000..f0eda40a2 --- /dev/null +++ b/extensions/mlx-extension/settings.json @@ -0,0 +1,33 @@ +[ + { + "key": "ctx_size", + "title": "Context Size", + "description": "Context window size for MLX inference", + "controllerType": "input", + "controllerProps": { + "value": 4096, + "placeholder": "4096", + "type": "number", + "textAlign": "right" + } + }, + { + "key": "auto_unload", + "title": "Auto unload model", + "description": "Automatically unload other models when loading a new one", + "controllerType": "checkbox", + "controllerProps": { "value": true } + }, + { + "key": "timeout", + "title": "Timeout (seconds)", + "description": "Maximum time to wait for model to load", + "controllerType": "input", + "controllerProps": { + "value": 600, + "placeholder": "600", + "type": "number", + "textAlign": "right" + } + } +] diff --git a/extensions/mlx-extension/src/env.d.ts b/extensions/mlx-extension/src/env.d.ts new file mode 100644 index 000000000..9d0a4528d --- /dev/null +++ b/extensions/mlx-extension/src/env.d.ts @@ -0,0 +1,5 @@ +declare const SETTINGS: SettingComponentProps[] +declare const ENGINE: string +declare const IS_WINDOWS: boolean +declare const IS_MAC: boolean +declare const IS_LINUX: boolean diff --git a/extensions/mlx-extension/src/index.ts b/extensions/mlx-extension/src/index.ts new file mode 100644 index 000000000..a5d00b94d --- /dev/null +++ b/extensions/mlx-extension/src/index.ts @@ -0,0 +1,825 @@ +/** + * MLX Extension - Inference engine for Apple Silicon Macs using MLX-Swift + * + * This extension provides an alternative to llama.cpp for running GGUF models + * locally on Apple Silicon using the MLX framework with Metal GPU acceleration. + * + * It shares the same model directory as llamacpp-extension so users can + * switch between engines without re-downloading models. + */ + +import { + AIEngine, + getJanDataFolderPath, + fs, + joinPath, + modelInfo, + SessionInfo, + UnloadResult, + chatCompletion, + chatCompletionChunk, + ImportOptions, + chatCompletionRequest, + events, + AppEvent, + DownloadEvent, +} from '@janhq/core' + +import { info, warn, error as logError } from '@tauri-apps/plugin-log' +import { invoke } from '@tauri-apps/api/core' +import { + loadMlxModel, + unloadMlxModel, + MlxConfig, +} from '@janhq/tauri-plugin-mlx-api' +import { readGgufMetadata, ModelConfig } from '@janhq/tauri-plugin-llamacpp-api' + +// Error message constant +const OUT_OF_CONTEXT_SIZE = 'the request exceeds the available context size.' + +const logger = { + info: function (...args: any[]) { + console.log(...args) + info(args.map((arg) => ` ${arg}`).join(` `)) + }, + warn: function (...args: any[]) { + console.warn(...args) + warn(args.map((arg) => ` ${arg}`).join(` `)) + }, + error: function (...args: any[]) { + console.error(...args) + logError(args.map((arg) => ` ${arg}`).join(` `)) + }, +} + +export default class mlx_extension extends AIEngine { + provider: string = 'mlx' + autoUnload: boolean = true + timeout: number = 600 + readonly providerId: string = 'mlx' + + private config: any = {} + private providerPath!: string + private apiSecret: string = 'JanMLX' + private loadingModels = new Map>() + + override async onLoad(): Promise { + super.onLoad() + + let settings = structuredClone(SETTINGS) + this.registerSettings(settings) + + let loadedConfig: any = {} + for (const item of settings) { + const defaultValue = item.controllerProps.value + loadedConfig[item.key] = await this.getSetting( + item.key, + defaultValue + ) + } + this.config = loadedConfig + + this.autoUnload = this.config.auto_unload ?? true + this.timeout = this.config.timeout ?? 600 + + this.getProviderPath() + } + + async getProviderPath(): Promise { + if (!this.providerPath) { + // Use mlx folder for models + this.providerPath = await joinPath([ + await getJanDataFolderPath(), + 'mlx', + ]) + } + return this.providerPath + } + + override async onUnload(): Promise { + // Cleanup handled by Tauri plugin on app exit + } + + onSettingUpdate(key: string, value: T): void { + this.config[key] = value + + if (key === 'auto_unload') { + this.autoUnload = value as boolean + } else if (key === 'timeout') { + this.timeout = value as number + } + } + + private async generateApiKey( + modelId: string, + port: string + ): Promise { + // Reuse the llamacpp plugin's API key generation + const hash = await invoke('plugin:llamacpp|generate_api_key', { + modelId: modelId + port, + apiSecret: this.apiSecret, + }) + return hash + } + + override async get(modelId: string): Promise { + const modelPath = await joinPath([ + await this.getProviderPath(), + 'models', + modelId, + ]) + const path = await joinPath([modelPath, 'model.yml']) + + if (!(await fs.existsSync(path))) return undefined + + const modelConfig = await invoke('read_yaml', { path }) + + return { + id: modelId, + name: modelConfig.name ?? modelId, + providerId: this.provider, + port: 0, + sizeBytes: modelConfig.size_bytes ?? 0, + embedding: modelConfig.embedding ?? false, + } as modelInfo + } + + override async list(): Promise { + const modelsDir = await joinPath([await this.getProviderPath(), 'models']) + if (!(await fs.existsSync(modelsDir))) { + await fs.mkdir(modelsDir) + } + + let modelIds: string[] = [] + + // DFS to find all model.yml files + let stack = [modelsDir] + while (stack.length > 0) { + const currentDir = stack.pop() + + const modelConfigPath = await joinPath([currentDir, 'model.yml']) + if (await fs.existsSync(modelConfigPath)) { + modelIds.push(currentDir.slice(modelsDir.length + 1)) + continue + } + + const children = await fs.readdirSync(currentDir) + for (const child of children) { + const dirInfo = await fs.fileStat(child) + if (!dirInfo.isDirectory) continue + stack.push(child) + } + } + + let modelInfos: modelInfo[] = [] + for (const modelId of modelIds) { + const path = await joinPath([modelsDir, modelId, 'model.yml']) + const modelConfig = await invoke('read_yaml', { path }) + + const capabilities: string[] = [] + if (modelConfig.mmproj_path) { + capabilities.push('vision') + } + + // Check for tool support + try { + if (await this.isToolSupported(modelId)) { + capabilities.push('tools') + } + } catch (e) { + logger.warn(`Failed to check tool support for ${modelId}: ${e}`) + } + + modelInfos.push({ + id: modelId, + name: modelConfig.name ?? modelId, + providerId: this.provider, + port: 0, + sizeBytes: modelConfig.size_bytes ?? 0, + embedding: modelConfig.embedding ?? false, + capabilities: capabilities.length > 0 ? capabilities : undefined, + } as modelInfo) + } + + return modelInfos + } + + private async getRandomPort(): Promise { + try { + return await invoke('plugin:mlx|get_mlx_random_port') + } catch { + logger.error('Unable to find a suitable port for MLX server') + throw new Error('Unable to find a suitable port for MLX model') + } + } + + override async load( + modelId: string, + overrideSettings?: any, + isEmbedding: boolean = false + ): Promise { + const sInfo = await this.findSessionByModel(modelId) + if (sInfo) { + throw new Error('Model already loaded!') + } + + if (this.loadingModels.has(modelId)) { + return this.loadingModels.get(modelId)! + } + + const loadingPromise = this.performLoad( + modelId, + overrideSettings, + isEmbedding + ) + this.loadingModels.set(modelId, loadingPromise) + + try { + return await loadingPromise + } finally { + this.loadingModels.delete(modelId) + } + } + + private async performLoad( + modelId: string, + overrideSettings?: any, + isEmbedding: boolean = false + ): Promise { + const loadedModels = await this.getLoadedModels() + + // Auto-unload other models if needed + const otherLoadingPromises = Array.from(this.loadingModels.entries()) + .filter(([id, _]) => id !== modelId) + .map(([_, promise]) => promise) + + if ( + this.autoUnload && + !isEmbedding && + (loadedModels.length > 0 || otherLoadingPromises.length > 0) + ) { + if (otherLoadingPromises.length > 0) { + await Promise.all(otherLoadingPromises) + } + + const allLoadedModels = await this.getLoadedModels() + if (allLoadedModels.length > 0) { + await Promise.all(allLoadedModels.map((id) => this.unload(id))) + } + } + + const cfg = { ...this.config, ...(overrideSettings ?? {}) } + + const janDataFolderPath = await getJanDataFolderPath() + const modelConfigPath = await joinPath([ + this.providerPath, + 'models', + modelId, + 'model.yml', + ]) + const modelConfig = await invoke('read_yaml', { + path: modelConfigPath, + }) + const port = await this.getRandomPort() + + const api_key = await this.generateApiKey(modelId, String(port)) + const envs: Record = { + MLX_API_KEY: api_key, + } + + // Resolve model path - could be absolute or relative + let modelPath: string + if (modelConfig.model_path.startsWith('/') || modelConfig.model_path.includes(':')) { + // Absolute path + modelPath = modelConfig.model_path + } else { + // Relative path - resolve from Jan data folder + modelPath = await joinPath([ + janDataFolderPath, + modelConfig.model_path, + ]) + } + + // Resolve the MLX server binary path + const mlxServerPath = await this.getMlxServerBinaryPath() + + const mlxConfig: MlxConfig = { + ctx_size: cfg.ctx_size ?? 4096, + n_predict: cfg.n_predict ?? 0, + threads: cfg.threads ?? 0, + chat_template: cfg.chat_template ?? '', + } + + logger.info( + 'Loading MLX model:', + modelId, + 'with config:', + JSON.stringify(mlxConfig) + ) + + try { + const sInfo = await loadMlxModel( + mlxServerPath, + modelId, + modelPath, + port, + mlxConfig, + envs, + isEmbedding, + Number(this.timeout) + ) + return sInfo + } catch (error) { + logger.error('Error loading MLX model:', error) + throw error + } + } + + private async getMlxServerBinaryPath(): Promise { + const janDataFolderPath = await getJanDataFolderPath() + // Look for the MLX server binary in the Jan data folder + const binaryPath = await joinPath([ + janDataFolderPath, + 'mlx', + 'mlx-server', + ]) + + if (await fs.existsSync(binaryPath)) { + return binaryPath + } + + // Fallback: check in the app resources + throw new Error( + 'MLX server binary not found. Please ensure mlx-server is installed at ' + + binaryPath + ) + } + + override async unload(modelId: string): Promise { + const sInfo = await this.findSessionByModel(modelId) + if (!sInfo) { + throw new Error(`No active MLX session found for model: ${modelId}`) + } + + try { + const result = await unloadMlxModel(sInfo.pid) + if (result.success) { + logger.info(`Successfully unloaded MLX model with PID ${sInfo.pid}`) + } else { + logger.warn(`Failed to unload MLX model: ${result.error}`) + } + return result + } catch (error) { + logger.error('Error unloading MLX model:', error) + return { + success: false, + error: `Failed to unload model: ${error}`, + } + } + } + + private async findSessionByModel(modelId: string): Promise { + try { + return await invoke( + 'plugin:mlx|find_mlx_session_by_model', + { modelId } + ) + } catch (e) { + logger.error(e) + throw new Error(String(e)) + } + } + + override async chat( + opts: chatCompletionRequest, + abortController?: AbortController + ): Promise> { + const sessionInfo = await this.findSessionByModel(opts.model) + if (!sessionInfo) { + throw new Error(`No active MLX session found for model: ${opts.model}`) + } + + // Check if the process is alive + const isAlive = await invoke('plugin:mlx|is_mlx_process_running', { + pid: sessionInfo.pid, + }) + + if (isAlive) { + try { + await fetch(`http://localhost:${sessionInfo.port}/health`) + } catch (e) { + this.unload(sessionInfo.model_id) + throw new Error('MLX model appears to have crashed! Please reload!') + } + } else { + throw new Error('MLX model has crashed! Please reload!') + } + + const baseUrl = `http://localhost:${sessionInfo.port}/v1` + const url = `${baseUrl}/chat/completions` + const headers = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${sessionInfo.api_key}`, + } + + const body = JSON.stringify(opts) + + if (opts.stream) { + return this.handleStreamingResponse(url, headers, body, abortController) + } + + const response = await fetch(url, { + method: 'POST', + headers, + body, + signal: abortController?.signal, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => null) + throw new Error( + `MLX API request failed with status ${response.status}: ${JSON.stringify(errorData)}` + ) + } + + const completionResponse = (await response.json()) as chatCompletion + + if (completionResponse.choices?.[0]?.finish_reason === 'length') { + throw new Error(OUT_OF_CONTEXT_SIZE) + } + + return completionResponse + } + + private async *handleStreamingResponse( + url: string, + headers: HeadersInit, + body: string, + abortController?: AbortController + ): AsyncIterable { + const response = await fetch(url, { + method: 'POST', + headers, + body, + signal: AbortSignal.any([ + AbortSignal.timeout(this.timeout * 1000), + abortController?.signal, + ]), + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => null) + throw new Error( + `MLX API request failed with status ${response.status}: ${JSON.stringify(errorData)}` + ) + } + + if (!response.body) { + throw new Error('Response body is null') + } + + const reader = response.body.getReader() + const decoder = new TextDecoder('utf-8') + let buffer = '' + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + const trimmedLine = line.trim() + if (!trimmedLine || trimmedLine === 'data: [DONE]') continue + + if (trimmedLine.startsWith('data: ')) { + const jsonStr = trimmedLine.slice(6) + try { + const data = JSON.parse(jsonStr) as chatCompletionChunk + + if (data.choices?.[0]?.finish_reason === 'length') { + throw new Error(OUT_OF_CONTEXT_SIZE) + } + + yield data + } catch (e) { + logger.error('Error parsing MLX stream JSON:', e) + throw e + } + } else if (trimmedLine.startsWith('error: ')) { + const jsonStr = trimmedLine.slice(7) + const error = JSON.parse(jsonStr) + throw new Error(error.message) + } + } + } + } finally { + reader.releaseLock() + } + } + + override async delete(modelId: string): Promise { + const modelDir = await joinPath([ + await this.getProviderPath(), + 'models', + modelId, + ]) + + const modelConfigPath = await joinPath([modelDir, 'model.yml']) + if (!(await fs.existsSync(modelConfigPath))) { + throw new Error(`Model ${modelId} does not exist`) + } + + const modelConfig = await invoke('read_yaml', { + path: modelConfigPath, + }) + + // Check if model_path is a relative path within mlx folder + if (!modelConfig.model_path.startsWith('/') && !modelConfig.model_path.includes(':')) { + // Model file is at {janDataFolder}/{model_path} + // Delete the parent folder containing the actual model file + const janDataFolderPath = await getJanDataFolderPath() + const modelPath = await joinPath([janDataFolderPath, modelConfig.model_path]) + const parentDir = modelPath.substring(0, modelPath.lastIndexOf('/')) + // Only delete if it's different from modelDir (i.e., not the same folder) + if (parentDir !== modelDir) { + await fs.rm(parentDir) + } + } + + // Always delete the model.yml folder + await fs.rm(modelDir) + } + + override async update( + modelId: string, + model: Partial + ): Promise { + // Delegate to the same logic as llamacpp since they share the model dir + const modelFolderPath = await joinPath([ + await this.getProviderPath(), + 'models', + modelId, + ]) + const modelConfig = await invoke('read_yaml', { + path: await joinPath([modelFolderPath, 'model.yml']), + }) + const newFolderPath = await joinPath([ + await this.getProviderPath(), + 'models', + model.id, + ]) + if (await fs.existsSync(newFolderPath)) { + throw new Error(`Model with ID ${model.id} already exists`) + } + const newModelConfigPath = await joinPath([newFolderPath, 'model.yml']) + await fs.mv(modelFolderPath, newFolderPath).then(() => + invoke('write_yaml', { + data: { + ...modelConfig, + model_path: modelConfig?.model_path?.replace( + `mlx/models/${modelId}`, + `mlx/models/${model.id}` + ), + }, + savePath: newModelConfigPath, + }) + ) + } + + override async import(modelId: string, opts: ImportOptions): Promise { + const isValidModelId = (id: string) => { + // only allow alphanumeric, underscore, hyphen, and dot characters in modelId + if (!/^[a-zA-Z0-9/_\-\.]+$/.test(id)) return false + + // check for empty parts or path traversal + const parts = id.split('/') + return parts.every((s) => s !== '' && s !== '.' && s !== '..') + } + + if (!isValidModelId(modelId)) + throw new Error( + `Invalid modelId: ${modelId}. Only alphanumeric and / _ - . characters are allowed.` + ) + + const configPath = await joinPath([ + await this.getProviderPath(), + 'models', + modelId, + 'model.yml', + ]) + if (await fs.existsSync(configPath)) + throw new Error(`Model ${modelId} already exists`) + + const sourcePath = opts.modelPath + + if (sourcePath.startsWith('https://')) { + // Download from URL to mlx models folder + const janDataFolderPath = await getJanDataFolderPath() + const modelDir = await joinPath([janDataFolderPath, 'mlx', 'models', modelId]) + const localPath = await joinPath([modelDir, 'model.safetensors']) + + const downloadManager = window.core.extensionManager.getByName( + '@janhq/download-extension' + ) + await downloadManager.downloadFiles( + [ + { + url: sourcePath, + save_path: localPath, + sha256: opts.modelSha256, + size: opts.modelSize, + model_id: modelId, + }, + ], + `mlx/${modelId}`, + (transferred: number, total: number) => { + events.emit(DownloadEvent.onFileDownloadUpdate, { + modelId, + percent: transferred / total, + size: { transferred, total }, + downloadType: 'Model', + }) + } + ) + + // Create model.yml with relative path + const modelConfig = { + model_path: `mlx/models/${modelId}/model.safetensors`, + name: modelId, + size_bytes: opts.modelSize ?? 0, + } + + await fs.mkdir(modelDir) + await invoke('write_yaml', { + data: modelConfig, + savePath: configPath, + }) + + events.emit(AppEvent.onModelImported, { + modelId, + modelPath: modelConfig.model_path, + size_bytes: modelConfig.size_bytes, + }) + } else { + // Local file - use absolute path directly + if (!(await fs.existsSync(sourcePath))) { + throw new Error(`File not found: ${sourcePath}`) + } + + // Get file size + const stat = await fs.fileStat(sourcePath) + const size_bytes = stat.size + + // Create model.yml with absolute path + const modelConfig = { + model_path: sourcePath, + name: modelId, + size_bytes, + } + + // Create model folder for model.yml only (no copying of safetensors) + const modelDir = await joinPath([ + await this.getProviderPath(), + 'models', + modelId, + ]) + await fs.mkdir(modelDir) + + await invoke('write_yaml', { + data: modelConfig, + savePath: configPath, + }) + + events.emit(AppEvent.onModelImported, { + modelId, + modelPath: sourcePath, + size_bytes, + }) + } + } + + override async abortImport(modelId: string): Promise { + // Not applicable for MLX - imports go through llamacpp extension + } + + override async getLoadedModels(): Promise { + try { + return await invoke('plugin:mlx|get_mlx_loaded_models') + } catch (e) { + logger.error(e) + throw new Error(e) + } + } + + async isToolSupported(modelId: string): Promise { + // Check GGUF/safetensors metadata for tool support + const modelConfigPath = await joinPath([ + this.providerPath, + 'models', + modelId, + 'model.yml', + ]) + const modelConfig = await invoke('read_yaml', { + path: modelConfigPath, + }) + + // model_path could be absolute or relative + let modelPath: string + if (modelConfig.model_path.startsWith('/') || modelConfig.model_path.includes(':')) { + // Absolute path + modelPath = modelConfig.model_path + } else { + // Relative path - resolve from Jan data folder + const janDataFolderPath = await getJanDataFolderPath() + modelPath = await joinPath([janDataFolderPath, modelConfig.model_path]) + } + + // Check if model is safetensors or GGUF + const isSafetensors = modelPath.endsWith('.safetensors') + const modelDir = modelPath.substring(0, modelPath.lastIndexOf('/')) + + // For safetensors models, check multiple sources for tool support + if (isSafetensors) { + // Check 1: tokenizer_config.json (common for tool-capable models) + const tokenizerConfigPath = await joinPath([modelDir, 'tokenizer_config.json']) + if (await fs.existsSync(tokenizerConfigPath)) { + try { + const tokenizerConfigContent = await invoke('read_file_sync', { + args: [tokenizerConfigPath], + }) + // Check for tool/function calling indicators + const tcLower = tokenizerConfigContent.toLowerCase() + if (tcLower.includes('function_call') || + tcLower.includes('tool_use') || + tcLower.includes('tools') || + tcLower.includes('assistant')) { + logger.info(`Tool support detected from tokenizer_config.json for ${modelId}`) + return true + } + } catch (e) { + logger.warn(`Failed to read tokenizer_config.json: ${e}`) + } + } + + // Check 2: chat_template.jinja for tool patterns + const chatTemplatePath = await joinPath([modelDir, 'chat_template.jinja']) + if (await fs.existsSync(chatTemplatePath)) { + try { + const chatTemplateContent = await invoke('read_file_sync', { + args: [chatTemplatePath], + }) + // Common tool/function calling template patterns + const ctLower = chatTemplateContent.toLowerCase() + const toolPatterns = [ + /\{\%.*tool.*\%\}/, // {% tool ... %} + /\{\%.*function.*\%\}/, // {% function ... %} + /\{\%.*tool_call/, + /\{\%.*tools\./, + /\{[-]?#.*tool/, + /\{[-]?%.*tool/, + /"tool_calls"/, // "tool_calls" JSON key + /'tool_calls'/, // 'tool_calls' JSON key + /function_call/, + /tool_use/, + ] + for (const pattern of toolPatterns) { + if (pattern.test(chatTemplateContent)) { + logger.info(`Tool support detected from chat_template.jinja for ${modelId}`) + return true + } + } + } catch (e) { + logger.warn(`Failed to read chat_template.jinja: ${e}`) + } + } + + // Check 3: Look for tool-related files + const toolFiles = ['tools.jinja', 'tool_use.jinja', 'function_calling.jinja'] + for (const toolFile of toolFiles) { + const toolPath = await joinPath([modelDir, toolFile]) + if (await fs.existsSync(toolPath)) { + logger.info(`Tool support detected from ${toolFile} for ${modelId}`) + return true + } + } + + logger.info(`No tool support detected for safetensors model ${modelId}`) + return false + } else { + // For GGUF models, check metadata + try { + const metadata = await readGgufMetadata(modelPath) + const chatTemplate = metadata.metadata?.['tokenizer.chat_template'] + return chatTemplate?.includes('tools') ?? false + } catch (e) { + logger.warn(`Failed to read GGUF metadata: ${e}`) + return false + } + } + } +} diff --git a/extensions/mlx-extension/tsconfig.json b/extensions/mlx-extension/tsconfig.json new file mode 100644 index 000000000..34d31fe4a --- /dev/null +++ b/extensions/mlx-extension/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2018", + "module": "ES6", + "moduleResolution": "node", + "outDir": "./dist", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": false, + "skipLibCheck": true, + "rootDir": "./src" + }, + "include": ["./src"], + "exclude": ["**/*.test.ts"] +} diff --git a/mlx-server/Package.resolved b/mlx-server/Package.resolved new file mode 100644 index 000000000..617d81d00 --- /dev/null +++ b/mlx-server/Package.resolved @@ -0,0 +1,266 @@ +{ + "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "4b99975677236d13f0754339864e5360142ff5a1", + "version" : "1.30.3" + } + }, + { + "identity" : "hummingbird", + "kind" : "remoteSourceControl", + "location" : "https://github.com/hummingbird-project/hummingbird", + "state" : { + "revision" : "daf66bfd4b46c1f3f080a1f3438d8fbecee7ace5", + "version" : "2.19.0" + } + }, + { + "identity" : "mlx-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ml-explore/mlx-swift", + "state" : { + "revision" : "4dccaeda1d83cf8697f235d2786c2d72ad4bb925", + "version" : "0.30.3" + } + }, + { + "identity" : "mlx-swift-lm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ml-explore/mlx-swift-lm", + "state" : { + "branch" : "main", + "revision" : "2c700546340c37f275d23302163701b77c4dcbd9" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "7d5f6124c91a2d06fb63a811695a3400d15a100e", + "version" : "1.17.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "6ffef195ed4ba98ee98029970c94db7edc60d4c6", + "version" : "1.0.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095", + "version" : "4.2.0" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "baa932c1336f7894145cbaafcd34ce2dd0b77c97", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "76d7627bd88b47bf5a0f8497dd244885960dde0b", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-jinja", + "kind" : "remoteSourceControl", + "location" : "https://github.com/huggingface/swift-jinja.git", + "state" : { + "revision" : "d81197f35f41445bc10e94600795e68c6f5e94b0", + "version" : "2.3.1" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181", + "version" : "1.9.1" + } + }, + { + "identity" : "swift-metrics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-metrics.git", + "state" : { + "revision" : "0743a9364382629da3bf5677b46a2c4b1ce5d2a6", + "version" : "2.7.1" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "5e72fc102906ebe75a3487595a653e6f43725552", + "version" : "2.94.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "3df009d563dc9f21a5c85b33d8c2e34d2e4f8c3b", + "version" : "1.32.1" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "c2ba4cfbb83f307c66f5a6df6bb43e3c88dfbf80", + "version" : "1.39.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "173cc69a058623525a58ae6710e2f5727c663793", + "version" : "2.36.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "60c3e187154421171721c1a38e800b390680fb5d", + "version" : "1.26.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "1983448fefc717a2bc2ebde5490fe99873c5b8a6", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348", + "version" : "2.9.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + }, + { + "identity" : "swift-transformers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/huggingface/swift-transformers", + "state" : { + "revision" : "573e5c9036c2f136b3a8a071da8e8907322403d0", + "version" : "1.1.6" + } + } + ], + "version" : 2 +} diff --git a/mlx-server/Package.swift b/mlx-server/Package.swift new file mode 100644 index 000000000..9278d77ae --- /dev/null +++ b/mlx-server/Package.swift @@ -0,0 +1,36 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "mlx-server", + platforms: [ + .macOS(.v14) + ], + products: [ + .executable(name: "mlx-server", targets: ["MLXServer"]) + ], + dependencies: [ + .package(url: "https://github.com/ml-explore/mlx-swift", from: "0.30.3"), + .package(url: "https://github.com/ml-explore/mlx-swift-lm", branch: "main"), + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.7.0"), + .package(url: "https://github.com/hummingbird-project/hummingbird", from: "2.19.0"), + ], + targets: [ + .executableTarget( + name: "MLXServer", + dependencies: [ + .product(name: "MLX", package: "mlx-swift"), + .product(name: "MLXNN", package: "mlx-swift"), + .product(name: "MLXOptimizers", package: "mlx-swift"), + .product(name: "MLXRandom", package: "mlx-swift"), + .product(name: "MLXLLM", package: "mlx-swift-lm"), + .product(name: "MLXVLM", package: "mlx-swift-lm"), + .product(name: "MLXLMCommon", package: "mlx-swift-lm"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Hummingbird", package: "hummingbird"), + ], + path: "Sources/MLXServer" + ) + ] +) diff --git a/mlx-server/Sources/MLXServer/MLXServerCommand.swift b/mlx-server/Sources/MLXServer/MLXServerCommand.swift new file mode 100644 index 000000000..74a97c1bb --- /dev/null +++ b/mlx-server/Sources/MLXServer/MLXServerCommand.swift @@ -0,0 +1,67 @@ +import ArgumentParser +import Foundation +import Hummingbird + +@main +struct MLXServerCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "mlx-server", + abstract: "MLX-Swift inference server with OpenAI-compatible API" + ) + + @Option(name: [.long, .short], help: "Path to the GGUF model file") + var model: String + + @Option(name: .long, help: "Port to listen on") + var port: Int = 8080 + + @Option(name: .long, help: "Context window size") + var ctxSize: Int = 4096 + + @Option(name: .long, help: "API key for authentication (optional)") + var apiKey: String = "" + + @Option(name: .long, help: "Chat template to use (optional)") + var chatTemplate: String = "" + + @Flag(name: .long, help: "Run in embedding mode") + var embedding: Bool = false + + func run() async throws { + // Print startup info + print("[mlx] MLX-Swift Server starting...") + print("[mlx] Model path: \(model)") + print("[mlx] Port: \(port)") + print("[mlx] Context size: \(ctxSize)") + + // Extract model ID from path + let modelURL = URL(fileURLWithPath: model) + let modelId = modelURL.deletingPathExtension().lastPathComponent + + // Load the model + let modelRunner = ModelRunner() + + do { + try await modelRunner.load(modelPath: model, modelId: modelId) + } catch { + print("[mlx] Failed to load model: \(error)") + throw error + } + + // Set up the HTTP server + let server = MLXHTTPServer( + modelRunner: modelRunner, + modelId: modelId, + apiKey: apiKey + ) + + let router = server.buildRouter() + let app = Application(router: router, configuration: .init(address: .hostname("127.0.0.1", port: port))) + + // Print readiness signal (monitored by Tauri plugin) + print("[mlx] http server listening on http://127.0.0.1:\(port)") + print("[mlx] server is listening on 127.0.0.1:\(port)") + + try await app.run() + } +} diff --git a/mlx-server/Sources/MLXServer/ModelRunner.swift b/mlx-server/Sources/MLXServer/ModelRunner.swift new file mode 100644 index 000000000..bf1b29bec --- /dev/null +++ b/mlx-server/Sources/MLXServer/ModelRunner.swift @@ -0,0 +1,364 @@ +import Foundation +import MLX +import MLXLLM +import MLXLMCommon +import MLXRandom +import MLXVLM + +/// Manages loading and running inference with MLX models +actor ModelRunner { + private var container: ModelContainer? + private var modelId: String = "" + + var isLoaded: Bool { + container != nil + } + + var currentModelId: String { + modelId + } + + /// Load a model from the given path, trying LLM first then VLM + func load(modelPath: String, modelId: String) async throws { + print("[mlx] Loading model from: \(modelPath)") + + let modelURL = URL(fileURLWithPath: modelPath) + let modelDir = modelURL.deletingLastPathComponent() + let configuration = ModelConfiguration(directory: modelDir, defaultPrompt: "") + + // Try LLM factory first, fall back to VLM factory + do { + self.container = try await LLMModelFactory.shared.loadContainer( + configuration: configuration + ) { progress in + print("[mlx] Loading progress: \(Int(progress.fractionCompleted * 100))%") + } + print("[mlx] Model loaded as LLM: \(modelId)") + } catch { + print("[mlx] LLM loading failed (\(error.localizedDescription)), trying VLM factory...") + do { + self.container = try await VLMModelFactory.shared.loadContainer( + configuration: configuration + ) { progress in + print("[mlx] Loading progress: \(Int(progress.fractionCompleted * 100))%") + } + print("[mlx] Model loaded as VLM: \(modelId)") + } catch { + print("[mlx] Error: Failed to load model with both LLM and VLM factories: \(error.localizedDescription)") + throw error + } + } + + self.modelId = modelId + print("[mlx] Model ready: \(modelId)") + } + + /// Build Chat.Message array from ChatMessages, including images and videos + private func buildChat(from messages: [ChatMessage]) -> [Chat.Message] { + messages.map { message in + let role: Chat.Message.Role = + switch message.role { + case "assistant": + .assistant + case "user": + .user + case "system": + .system + case "tool": + .tool + default: + .user + } + + let images: [UserInput.Image] = (message.images ?? []).compactMap { urlString in + guard let url = URL(string: urlString) else { + print("[mlx] Warning: Invalid image URL: \(urlString)") + return nil + } + return .url(url) + } + + let videos: [UserInput.Video] = (message.videos ?? []).compactMap { urlString in + guard let url = URL(string: urlString) else { + print("[mlx] Warning: Invalid video URL: \(urlString)") + return nil + } + return .url(url) + } + + if !images.isEmpty { + print("[mlx] Message has \(images.count) image(s)") + } + if !videos.isEmpty { + print("[mlx] Message has \(videos.count) video(s)") + } + + return Chat.Message( + role: role, content: message.content ?? "", images: images, videos: videos) + } + } + + /// Convert AnyCodable tools array to ToolSpec format + private func buildToolSpecs(from tools: [AnyCodable]?) -> [[String: any Sendable]]? { + guard let tools = tools, !tools.isEmpty else { return nil } + let specs = tools.map { tool in + tool.toSendable() as! [String: any Sendable] + } + print("[mlx] Tools provided: \(specs.count)") + return specs + } + + /// Generate a chat completion (non-streaming) + func generate( + messages: [ChatMessage], + temperature: Float = 0.7, + topP: Float = 1.0, + maxTokens: Int = 2048, + repetitionPenalty: Float = 1.0, + stop: [String] = [], + tools: [AnyCodable]? = nil + ) async throws -> (String, [ToolCallInfo], UsageInfo) { + guard let container = container else { + print("[mlx] Error: generate() called but no model is loaded") + throw MLXServerError.modelNotLoaded + } + + print("[mlx] Generate: \(messages.count) messages, temp=\(temperature), topP=\(topP), maxTokens=\(maxTokens)") + + let chat = buildChat(from: messages) + let toolSpecs = buildToolSpecs(from: tools) + + let generateParameters = GenerateParameters( + maxTokens: maxTokens, + temperature: temperature, + topP: topP, + repetitionPenalty: repetitionPenalty + ) + let userInput = UserInput( + chat: chat, + processing: .init(resize: .init(width: 1024, height: 1024)), + tools: toolSpecs + ) + + let result: (String, [ToolCallInfo], UsageInfo) = try await container.perform { context in + let input = try await context.processor.prepare(input: userInput) + let promptTokenCount = input.text.tokens.size + + var output = "" + var completionTokenCount = 0 + var collectedToolCalls: [ToolCallInfo] = [] + var completionInfo: GenerateCompletionInfo? + + do { + for await generation in try MLXLMCommon.generate( + input: input, parameters: generateParameters, context: context + ) { + switch generation { + case .chunk(let chunk): + output += chunk + completionTokenCount += 1 + + // Check stop sequences + var hitStop = false + for s in stop where output.hasSuffix(s) { + output = String(output.dropLast(s.count)) + hitStop = true + print("[mlx] Hit stop sequence: \"\(s)\"") + break + } + if hitStop { break } + + case .info(let info): + completionInfo = info + print("[mlx] Generation info: \(info.promptTokenCount) prompt tokens, \(info.generationTokenCount) generated tokens") + print("[mlx] Prompt: \(String(format: "%.1f", info.promptTokensPerSecond)) tokens/sec") + print("[mlx] Generation: \(String(format: "%.1f", info.tokensPerSecond)) tokens/sec") + + case .toolCall(let toolCall): + let argsData = try JSONSerialization.data( + withJSONObject: toolCall.function.arguments.mapValues { $0.anyValue }, + options: [.sortedKeys] + ) + let argsString = String(data: argsData, encoding: .utf8) ?? "{}" + let info = ToolCallInfo( + id: generateToolCallId(), + type: "function", + function: FunctionCall( + name: toolCall.function.name, + arguments: argsString + ) + ) + collectedToolCalls.append(info) + print("[mlx] Tool call: \(toolCall.function.name)(\(argsString))") + } + } + } catch { + print("[mlx] Error during generation: \(error.localizedDescription)") + throw error + } + + let usage: UsageInfo + if let info = completionInfo { + usage = UsageInfo( + prompt_tokens: info.promptTokenCount, + completion_tokens: info.generationTokenCount, + total_tokens: info.promptTokenCount + info.generationTokenCount + ) + } else { + usage = UsageInfo( + prompt_tokens: promptTokenCount, + completion_tokens: completionTokenCount, + total_tokens: promptTokenCount + completionTokenCount + ) + } + + print("[mlx] Generate complete: \(output.count) chars, \(collectedToolCalls.count) tool call(s)") + return (output, collectedToolCalls, usage) + } + + return result + } + + /// Generate a streaming chat completion + func generateStream( + messages: [ChatMessage], + temperature: Float = 0.7, + topP: Float = 1.0, + maxTokens: Int = 2048, + repetitionPenalty: Float = 1.0, + stop: [String] = [], + tools: [AnyCodable]? = nil + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + guard let container = self.container else { + print("[mlx] Error: generateStream() called but no model is loaded") + continuation.finish(throwing: MLXServerError.modelNotLoaded) + return + } + + print("[mlx] Stream generate: \(messages.count) messages, temp=\(temperature), topP=\(topP), maxTokens=\(maxTokens)") + + let chat = self.buildChat(from: messages) + let toolSpecs = self.buildToolSpecs(from: tools) + + let userInput = UserInput( + chat: chat, + processing: .init(resize: .init(width: 1024, height: 1024)), + tools: toolSpecs + ) + + do { + try await container.perform { context in + let generateParameters = GenerateParameters( + maxTokens: maxTokens, + temperature: temperature, + topP: topP, + repetitionPenalty: repetitionPenalty + ) + + let input = try await context.processor.prepare(input: userInput) + + var completionTokenCount = 0 + var accumulated = "" + var hasToolCalls = false + + do { + for await generation in try MLXLMCommon.generate( + input: input, parameters: generateParameters, context: context + ) { + switch generation { + case .chunk(let chunk): + accumulated += chunk + completionTokenCount += 1 + + continuation.yield(.chunk(chunk)) + + // Check stop sequences + var hitStop = false + for s in stop where accumulated.hasSuffix(s) { + hitStop = true + print("[mlx] Hit stop sequence: \"\(s)\"") + break + } + if hitStop { break } + + case .info(let info): + print("[mlx] Stream generation info: \(info.promptTokenCount) prompt tokens, \(info.generationTokenCount) generated tokens") + print("[mlx] Prompt: \(String(format: "%.1f", info.promptTokensPerSecond)) tokens/sec") + print("[mlx] Generation: \(String(format: "%.1f", info.tokensPerSecond)) tokens/sec") + + let usage = UsageInfo( + prompt_tokens: info.promptTokenCount, + completion_tokens: info.generationTokenCount, + total_tokens: info.promptTokenCount + info.generationTokenCount + ) + let timings = TimingsInfo( + prompt_n: info.promptTokenCount, + predicted_n: info.generationTokenCount, + predicted_per_second: info.tokensPerSecond, + prompt_per_second: info.promptTokensPerSecond + ) + continuation.yield(.done(usage: usage, timings: timings, hasToolCalls: hasToolCalls)) + + case .toolCall(let toolCall): + hasToolCalls = true + let argsData = try JSONSerialization.data( + withJSONObject: toolCall.function.arguments.mapValues { $0.anyValue }, + options: [.sortedKeys] + ) + let argsString = String(data: argsData, encoding: .utf8) ?? "{}" + let info = ToolCallInfo( + id: generateToolCallId(), + type: "function", + function: FunctionCall( + name: toolCall.function.name, + arguments: argsString + ) + ) + print("[mlx] Stream tool call: \(toolCall.function.name)(\(argsString))") + continuation.yield(.toolCall(info)) + } + } + } catch { + print("[mlx] Error during stream generation: \(error.localizedDescription)") + throw error + } + + // If no .info was received, send done with fallback usage + // The .info case already yields .done, so only send if we haven't + print("[mlx] Stream complete: \(accumulated.count) chars") + continuation.finish() + } + } catch { + print("[mlx] Error in stream: \(error.localizedDescription)") + continuation.finish(throwing: error) + } + } + } + } +} + +/// Events emitted during streaming generation +enum StreamEvent { + /// A text chunk + case chunk(String) + /// A tool call from the model + case toolCall(ToolCallInfo) + /// Generation complete with usage and timing info + case done(usage: UsageInfo, timings: TimingsInfo?, hasToolCalls: Bool) +} + +enum MLXServerError: Error, LocalizedError { + case modelNotLoaded + case invalidRequest(String) + + var errorDescription: String? { + switch self { + case .modelNotLoaded: + return "No model is currently loaded" + case .invalidRequest(let msg): + return "Invalid request: \(msg)" + } + } +} diff --git a/mlx-server/Sources/MLXServer/OpenAITypes.swift b/mlx-server/Sources/MLXServer/OpenAITypes.swift new file mode 100644 index 000000000..317abbad9 --- /dev/null +++ b/mlx-server/Sources/MLXServer/OpenAITypes.swift @@ -0,0 +1,225 @@ +import Foundation + +// MARK: - Chat Completion Request + +struct ChatCompletionRequest: Codable { + let model: String + let messages: [ChatMessage] + var temperature: Float? + var top_p: Float? + var max_tokens: Int? + var stream: Bool? + var stop: [String]? + var n_predict: Int? + var repetition_penalty: Float? + var tools: [AnyCodable]? +} + +struct AnyCodable: Codable, @unchecked Sendable { + let value: Any + + init(_ value: Any) { + self.value = value + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + if let string = value as? String { + try container.encode(string) + } else if let int = value as? Int { + try container.encode(int) + } else if let double = value as? Double { + try container.encode(double) + } else if let bool = value as? Bool { + try container.encode(bool) + } else if let array = value as? [Any] { + try container.encode(array.map { AnyCodable($0) }) + } else if let dict = value as? [String: Any] { + try container.encode(dict.mapValues { AnyCodable($0) }) + } else { + throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Unsupported type")) + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let string = try? container.decode(String.self) { + value = string + } else if let int = try? container.decode(Int.self) { + value = int + } else if let double = try? container.decode(Double.self) { + value = double + } else if let bool = try? container.decode(Bool.self) { + value = bool + } else if let array = try? container.decode([AnyCodable].self) { + value = array.map { $0.value } + } else if let dict = try? container.decode([String: AnyCodable].self) { + value = dict.mapValues { $0.value } + } else { + throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unsupported type")) + } + } + + /// Recursively convert the underlying value to `[String: any Sendable]` or primitive Sendable types + func toSendable() -> any Sendable { + switch value { + case let string as String: + return string + case let int as Int: + return int + case let double as Double: + return double + case let bool as Bool: + return bool + case let array as [Any]: + return array.map { AnyCodable($0).toSendable() } + case let dict as [String: Any]: + return dict.mapValues { AnyCodable($0).toSendable() } + default: + return String(describing: value) + } + } +} + +struct ChatMessage: Codable { + let role: String + var content: String? + var images: [String]? + var videos: [String]? + var tool_calls: [ToolCallInfo]? + var tool_call_id: String? + var name: String? +} + +// MARK: - Tool Call Types (OpenAI-compatible) + +struct ToolCallInfo: Codable { + let id: String + let type: String + let function: FunctionCall +} + +struct FunctionCall: Codable { + let name: String + let arguments: String +} + +struct ToolCallDelta: Codable { + let index: Int + var id: String? + var type: String? + var function: FunctionCallDelta? +} + +struct FunctionCallDelta: Codable { + var name: String? + var arguments: String? +} + +// MARK: - Chat Completion Response (non-streaming) + +struct ChatCompletionResponse: Codable { + let id: String + let object: String + let created: Int + let model: String + let choices: [ChatChoice] + var usage: UsageInfo? +} + +struct ChatChoice: Codable { + let index: Int + let message: ChatMessage + let finish_reason: String? +} + +struct UsageInfo: Codable { + let prompt_tokens: Int + let completion_tokens: Int + let total_tokens: Int +} + +// MARK: - Chat Completion Chunk (streaming) + +struct ChatCompletionChunk: Codable { + let id: String + let object: String + let created: Int + let model: String + let choices: [ChatChunkChoice] + var usage: UsageInfo? + var timings: TimingsInfo? +} + +struct ChatChunkChoice: Codable { + let index: Int + let delta: ChatDelta + let finish_reason: String? +} + +struct ChatDelta: Codable { + var role: String? + var content: String? + var tool_calls: [ToolCallDelta]? +} + +struct TimingsInfo: Codable { + var prompt_n: Int? + var predicted_n: Int? + var predicted_per_second: Double? + var prompt_per_second: Double? +} + +// MARK: - Models List Response + +struct ModelsResponse: Codable { + let object: String + let data: [ModelInfo] +} + +struct ModelInfo: Codable { + let id: String + let object: String + let created: Int + let owned_by: String +} + +// MARK: - Health Response + +struct HealthResponse: Codable { + let status: String +} + +// MARK: - Error Response + +struct ErrorResponse: Codable { + let error: ErrorDetail +} + +struct ErrorDetail: Codable { + let message: String + let type_name: String + let code: String? + + enum CodingKeys: String, CodingKey { + case message + case type_name = "type" + case code + } +} + +// MARK: - Helpers + +func generateResponseId() -> String { + "chatcmpl-\(UUID().uuidString.prefix(12))" +} + +func generateToolCallId() -> String { + "call_\(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(24))" +} + +func currentTimestamp() -> Int { + Int(Date().timeIntervalSince1970) +} diff --git a/mlx-server/Sources/MLXServer/Server.swift b/mlx-server/Sources/MLXServer/Server.swift new file mode 100644 index 000000000..085c0d0d3 --- /dev/null +++ b/mlx-server/Sources/MLXServer/Server.swift @@ -0,0 +1,340 @@ +import Foundation +import Hummingbird + +/// HTTP server that exposes an OpenAI-compatible API backed by MLX +struct MLXHTTPServer { + let modelRunner: ModelRunner + let modelId: String + let apiKey: String + + func buildRouter() -> Router { + let router = Router() + + // Health check + router.get("/health") { _, _ in + let response = HealthResponse(status: "ok") + return try encodeJSON(response) + } + + // List models + router.get("/v1/models") { _, _ in + let response = ModelsResponse( + object: "list", + data: [ + ModelInfo( + id: self.modelId, + object: "model", + created: currentTimestamp(), + owned_by: "mlx" + ) + ] + ) + return try encodeJSON(response) + } + + // Chat completions + router.post("/v1/chat/completions") { request, context in + // Validate API key if set + if !self.apiKey.isEmpty { + let authHeader = + request.headers[.authorization] + let expectedAuth = "Bearer \(self.apiKey)" + if authHeader != expectedAuth { + let error = ErrorResponse( + error: ErrorDetail( + message: "Unauthorized", + type_name: "authentication_error", + code: "unauthorized" + ) + ) + let response = try Response( + status: .unauthorized, + headers: [.contentType: "application/json"], + body: .init(byteBuffer: encodeJSONBuffer(error)) + ) + return response + } + } + + // Parse request body + let body = try await request.body.collect(upTo: 10 * 1024 * 1024) // 10MB max + let chatRequest = try JSONDecoder().decode(ChatCompletionRequest.self, from: body) + + let temperature = chatRequest.temperature ?? 0.7 + let topP = chatRequest.top_p ?? 1.0 + let maxTokens = chatRequest.max_tokens ?? chatRequest.n_predict ?? 2048 + let repetitionPenalty = chatRequest.repetition_penalty ?? 1.0 + let stop = chatRequest.stop ?? [] + let isStreaming = chatRequest.stream ?? false + let tools = chatRequest.tools + + print("[mlx] Request: model=\(chatRequest.model), messages=\(chatRequest.messages.count), stream=\(isStreaming), tools=\(tools?.count ?? 0)") + + if isStreaming { + return try await self.handleStreamingRequest( + chatRequest: chatRequest, + temperature: temperature, + topP: topP, + maxTokens: maxTokens, + repetitionPenalty: repetitionPenalty, + stop: stop, + tools: tools + ) + } else { + return try await self.handleNonStreamingRequest( + chatRequest: chatRequest, + temperature: temperature, + topP: topP, + maxTokens: maxTokens, + repetitionPenalty: repetitionPenalty, + stop: stop, + tools: tools + ) + } + } + + return router + } + + private func handleNonStreamingRequest( + chatRequest: ChatCompletionRequest, + temperature: Float, + topP: Float, + maxTokens: Int, + repetitionPenalty: Float, + stop: [String], + tools: [AnyCodable]? = nil + ) async throws -> Response { + let (text, toolCalls, usage) = try await modelRunner.generate( + messages: chatRequest.messages, + temperature: temperature, + topP: topP, + maxTokens: maxTokens, + repetitionPenalty: repetitionPenalty, + stop: stop, + tools: tools + ) + + let finishReason = toolCalls.isEmpty ? "stop" : "tool_calls" + let message = ChatMessage( + role: "assistant", + content: text.isEmpty && !toolCalls.isEmpty ? nil : text, + tool_calls: toolCalls.isEmpty ? nil : toolCalls + ) + + let response = ChatCompletionResponse( + id: generateResponseId(), + object: "chat.completion", + created: currentTimestamp(), + model: chatRequest.model, + choices: [ + ChatChoice( + index: 0, + message: message, + finish_reason: finishReason + ) + ], + usage: usage + ) + + print("[mlx] Response: \(text.count) chars, \(toolCalls.count) tool call(s), finish=\(finishReason)") + + return try Response( + status: .ok, + headers: [.contentType: "application/json"], + body: .init(byteBuffer: encodeJSONBuffer(response)) + ) + } + + private func handleStreamingRequest( + chatRequest: ChatCompletionRequest, + temperature: Float, + topP: Float, + maxTokens: Int, + repetitionPenalty: Float, + stop: [String], + tools: [AnyCodable]? = nil + ) async throws -> Response { + let responseId = generateResponseId() + let created = currentTimestamp() + let model = chatRequest.model + + let stream = await modelRunner.generateStream( + messages: chatRequest.messages, + temperature: temperature, + topP: topP, + maxTokens: maxTokens, + repetitionPenalty: repetitionPenalty, + stop: stop, + tools: tools + ) + + // Build SSE response body + let responseStream = AsyncStream { continuation in + Task { + // Send initial role chunk + let initialChunk = ChatCompletionChunk( + id: responseId, + object: "chat.completion.chunk", + created: created, + model: model, + choices: [ + ChatChunkChoice( + index: 0, + delta: ChatDelta(role: "assistant", content: nil), + finish_reason: nil + ) + ] + ) + if let data = try? encodeJSONData(initialChunk) { + var buffer = ByteBufferAllocator().buffer(capacity: data.count + 8) + buffer.writeString("data: ") + buffer.writeBytes(data) + buffer.writeString("\n\n") + continuation.yield(buffer) + } + + do { + for try await event in stream { + switch event { + case .chunk(let token): + let chunk = ChatCompletionChunk( + id: responseId, + object: "chat.completion.chunk", + created: created, + model: model, + choices: [ + ChatChunkChoice( + index: 0, + delta: ChatDelta(role: nil, content: token), + finish_reason: nil + ) + ] + ) + if let data = try? encodeJSONData(chunk) { + var buffer = ByteBufferAllocator().buffer( + capacity: data.count + 8) + buffer.writeString("data: ") + buffer.writeBytes(data) + buffer.writeString("\n\n") + continuation.yield(buffer) + } + + case .toolCall(let toolCallInfo): + let chunk = ChatCompletionChunk( + id: responseId, + object: "chat.completion.chunk", + created: created, + model: model, + choices: [ + ChatChunkChoice( + index: 0, + delta: ChatDelta( + role: nil, + content: nil, + tool_calls: [ + ToolCallDelta( + index: 0, + id: toolCallInfo.id, + type: toolCallInfo.type, + function: FunctionCallDelta( + name: toolCallInfo.function.name, + arguments: toolCallInfo.function.arguments + ) + ) + ] + ), + finish_reason: nil + ) + ] + ) + if let data = try? encodeJSONData(chunk) { + var buffer = ByteBufferAllocator().buffer( + capacity: data.count + 8) + buffer.writeString("data: ") + buffer.writeBytes(data) + buffer.writeString("\n\n") + continuation.yield(buffer) + } + + case .done(let usage, let timings, let hasToolCalls): + let finishReason = hasToolCalls ? "tool_calls" : "stop" + // Final chunk with finish_reason + let finalChunk = ChatCompletionChunk( + id: responseId, + object: "chat.completion.chunk", + created: created, + model: model, + choices: [ + ChatChunkChoice( + index: 0, + delta: ChatDelta(role: nil, content: nil), + finish_reason: finishReason + ) + ], + usage: usage, + timings: timings + ) + if let data = try? encodeJSONData(finalChunk) { + var buffer = ByteBufferAllocator().buffer( + capacity: data.count + 8) + buffer.writeString("data: ") + buffer.writeBytes(data) + buffer.writeString("\n\n") + continuation.yield(buffer) + } + + // Send [DONE] + var doneBuffer = ByteBufferAllocator().buffer(capacity: 16) + doneBuffer.writeString("data: [DONE]\n\n") + continuation.yield(doneBuffer) + } + } + } catch { + print("[mlx] Error in SSE stream: \(error.localizedDescription)") + // Send error as SSE event + var buffer = ByteBufferAllocator().buffer(capacity: 256) + buffer.writeString( + "error: {\"message\":\"\(error.localizedDescription)\"}\n\n") + continuation.yield(buffer) + } + + continuation.finish() + } + } + + return Response( + status: .ok, + headers: [ + .contentType: "text/event-stream", + .init("Cache-Control")!: "no-cache", + .init("Connection")!: "keep-alive", + ], + body: .init(asyncSequence: responseStream) + ) + } +} + +// MARK: - JSON Encoding Helpers + +private func encodeJSON(_ value: T) throws -> Response { + let data = try JSONEncoder().encode(value) + var buffer = ByteBufferAllocator().buffer(capacity: data.count) + buffer.writeBytes(data) + return Response( + status: .ok, + headers: [.contentType: "application/json"], + body: .init(byteBuffer: buffer) + ) +} + +private func encodeJSONBuffer(_ value: T) throws -> ByteBuffer { + let data = try JSONEncoder().encode(value) + var buffer = ByteBufferAllocator().buffer(capacity: data.count) + buffer.writeBytes(data) + return buffer +} + +private func encodeJSONData(_ value: T) throws -> Data { + try JSONEncoder().encode(value) +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 89cbbac9b..09880ed0e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -41,6 +41,7 @@ dependencies = [ "tauri-plugin-http", "tauri-plugin-llamacpp", "tauri-plugin-log", + "tauri-plugin-mlx", "tauri-plugin-opener", "tauri-plugin-os", "tauri-plugin-rag", @@ -6311,6 +6312,21 @@ dependencies = [ "time", ] +[[package]] +name = "tauri-plugin-mlx" +version = "0.6.599" +dependencies = [ + "jan-utils", + "log", + "nix", + "serde", + "sysinfo", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", + "tokio", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3a76defd4..390b25351 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,9 +27,11 @@ default = [ ] hardware = ["dep:tauri-plugin-hardware"] deep-link = ["dep:tauri-plugin-deep-link"] +mlx = ["dep:tauri-plugin-mlx"] desktop = [ "deep-link", - "hardware" + "hardware", + "mlx" ] mobile = [ "tauri/protocol-asset", @@ -82,6 +84,7 @@ zip = "0.6" tauri-plugin-deep-link = { version = "2", optional = true } tauri-plugin-hardware = { path = "./plugins/tauri-plugin-hardware", optional = true } tauri-plugin-llamacpp = { path = "./plugins/tauri-plugin-llamacpp" } +tauri-plugin-mlx = { path = "./plugins/tauri-plugin-mlx", optional = true } tauri-plugin-vector-db = { path = "./plugins/tauri-plugin-vector-db" } tauri-plugin-rag = { path = "./plugins/tauri-plugin-rag" } tauri-plugin-http = { version = "2", features = ["unsafe-headers"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index c5aa88b8f..5b1c23390 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -26,6 +26,7 @@ "hardware:default", "deep-link:default", "llamacpp:default", + "mlx:default", "updater:default", "updater:allow-check", { diff --git a/src-tauri/capabilities/desktop.json b/src-tauri/capabilities/desktop.json index f23b76b59..cc49c6687 100644 --- a/src-tauri/capabilities/desktop.json +++ b/src-tauri/capabilities/desktop.json @@ -27,6 +27,7 @@ "vector-db:default", "rag:default", "llamacpp:default", + "mlx:default", "deep-link:default", "hardware:default", { diff --git a/src-tauri/plugins/tauri-plugin-llamacpp/guest-js/types.ts b/src-tauri/plugins/tauri-plugin-llamacpp/guest-js/types.ts index 72e0676aa..79a1541a7 100644 --- a/src-tauri/plugins/tauri-plugin-llamacpp/guest-js/types.ts +++ b/src-tauri/plugins/tauri-plugin-llamacpp/guest-js/types.ts @@ -79,6 +79,7 @@ export interface DownloadItem { proxy?: Record sha256?: string size?: number + model_id?: string } export interface ModelConfig { @@ -90,6 +91,7 @@ export interface ModelConfig { sha256?: string mmproj_sha256?: string mmproj_size_bytes?: number + embedding?: boolean } export interface EmbeddingResponse { diff --git a/src-tauri/plugins/tauri-plugin-mlx/Cargo.toml b/src-tauri/plugins/tauri-plugin-mlx/Cargo.toml new file mode 100644 index 000000000..08dfc0e74 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "tauri-plugin-mlx" +version = "0.6.599" +authors = ["Jan "] +description = "Tauri plugin for managing MLX-Swift server processes and model loading on Apple Silicon" +license = "MIT" +repository = "https://github.com/janhq/jan" +edition = "2021" +rust-version = "1.77.2" +exclude = ["/examples", "/dist-js", "/guest-js", "/node_modules"] +links = "tauri-plugin-mlx" + +[dependencies] +log = "0.4" +serde = { version = "1.0", features = ["derive"] } +sysinfo = "0.34.2" +tauri = { version = "2.5.0", default-features = false, features = [] } +thiserror = "2.0.12" +tokio = { version = "1", features = ["full"] } +jan-utils = { path = "../../utils" } + +# Unix-specific dependencies (macOS) +[target.'cfg(unix)'.dependencies] +nix = { version = "=0.30.1", features = ["signal", "process"] } + +[build-dependencies] +tauri-plugin = { version = "2.3.1", features = ["build"] } diff --git a/src-tauri/plugins/tauri-plugin-mlx/build.rs b/src-tauri/plugins/tauri-plugin-mlx/build.rs new file mode 100644 index 000000000..c92e73cf0 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/build.rs @@ -0,0 +1,14 @@ +const COMMANDS: &[&str] = &[ + "cleanup_mlx_processes", + "load_mlx_model", + "unload_mlx_model", + "is_mlx_process_running", + "get_mlx_random_port", + "find_mlx_session_by_model", + "get_mlx_loaded_models", + "get_mlx_all_sessions", +]; + +fn main() { + tauri_plugin::Builder::new(COMMANDS).build(); +} diff --git a/src-tauri/plugins/tauri-plugin-mlx/dist-js/index.cjs b/src-tauri/plugins/tauri-plugin-mlx/dist-js/index.cjs new file mode 100644 index 000000000..bf33a9629 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/dist-js/index.cjs @@ -0,0 +1,63 @@ +'use strict'; + +var core = require('@tauri-apps/api/core'); + +function asNumber(v, defaultValue = 0) { + if (v === '' || v === null || v === undefined) + return defaultValue; + const n = Number(v); + return isFinite(n) ? n : defaultValue; +} +function asString(v, defaultValue = '') { + if (v === '' || v === null || v === undefined) + return defaultValue; + return String(v); +} +function normalizeMlxConfig(config) { + return { + ctx_size: asNumber(config.ctx_size), + n_predict: asNumber(config.n_predict), + threads: asNumber(config.threads), + chat_template: asString(config.chat_template), + }; +} +async function loadMlxModel(binaryPath, modelId, modelPath, port, cfg, envs, isEmbedding = false, timeout = 600) { + const config = normalizeMlxConfig(cfg); + return await core.invoke('plugin:mlx|load_mlx_model', { + binaryPath, + modelId, + modelPath, + port, + config, + envs, + isEmbedding, + timeout, + }); +} +async function unloadMlxModel(pid) { + return await core.invoke('plugin:mlx|unload_mlx_model', { pid }); +} +async function isMlxProcessRunning(pid) { + return await core.invoke('plugin:mlx|is_mlx_process_running', { pid }); +} +async function getMlxRandomPort() { + return await core.invoke('plugin:mlx|get_mlx_random_port'); +} +async function findMlxSessionByModel(modelId) { + return await core.invoke('plugin:mlx|find_mlx_session_by_model', { modelId }); +} +async function getMlxLoadedModels() { + return await core.invoke('plugin:mlx|get_mlx_loaded_models'); +} +async function getMlxAllSessions() { + return await core.invoke('plugin:mlx|get_mlx_all_sessions'); +} + +exports.findMlxSessionByModel = findMlxSessionByModel; +exports.getMlxAllSessions = getMlxAllSessions; +exports.getMlxLoadedModels = getMlxLoadedModels; +exports.getMlxRandomPort = getMlxRandomPort; +exports.isMlxProcessRunning = isMlxProcessRunning; +exports.loadMlxModel = loadMlxModel; +exports.normalizeMlxConfig = normalizeMlxConfig; +exports.unloadMlxModel = unloadMlxModel; diff --git a/src-tauri/plugins/tauri-plugin-mlx/dist-js/index.d.ts b/src-tauri/plugins/tauri-plugin-mlx/dist-js/index.d.ts new file mode 100644 index 000000000..df7e585ce --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/dist-js/index.d.ts @@ -0,0 +1,10 @@ +import { SessionInfo, UnloadResult, MlxConfig } from './types'; +export { SessionInfo, UnloadResult, MlxConfig } from './types'; +export declare function normalizeMlxConfig(config: any): MlxConfig; +export declare function loadMlxModel(binaryPath: string, modelId: string, modelPath: string, port: number, cfg: MlxConfig, envs: Record, isEmbedding?: boolean, timeout?: number): Promise; +export declare function unloadMlxModel(pid: number): Promise; +export declare function isMlxProcessRunning(pid: number): Promise; +export declare function getMlxRandomPort(): Promise; +export declare function findMlxSessionByModel(modelId: string): Promise; +export declare function getMlxLoadedModels(): Promise; +export declare function getMlxAllSessions(): Promise; diff --git a/src-tauri/plugins/tauri-plugin-mlx/dist-js/index.js b/src-tauri/plugins/tauri-plugin-mlx/dist-js/index.js new file mode 100644 index 000000000..39176ec63 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/dist-js/index.js @@ -0,0 +1,54 @@ +import { invoke } from '@tauri-apps/api/core'; + +function asNumber(v, defaultValue = 0) { + if (v === '' || v === null || v === undefined) + return defaultValue; + const n = Number(v); + return isFinite(n) ? n : defaultValue; +} +function asString(v, defaultValue = '') { + if (v === '' || v === null || v === undefined) + return defaultValue; + return String(v); +} +function normalizeMlxConfig(config) { + return { + ctx_size: asNumber(config.ctx_size), + n_predict: asNumber(config.n_predict), + threads: asNumber(config.threads), + chat_template: asString(config.chat_template), + }; +} +async function loadMlxModel(binaryPath, modelId, modelPath, port, cfg, envs, isEmbedding = false, timeout = 600) { + const config = normalizeMlxConfig(cfg); + return await invoke('plugin:mlx|load_mlx_model', { + binaryPath, + modelId, + modelPath, + port, + config, + envs, + isEmbedding, + timeout, + }); +} +async function unloadMlxModel(pid) { + return await invoke('plugin:mlx|unload_mlx_model', { pid }); +} +async function isMlxProcessRunning(pid) { + return await invoke('plugin:mlx|is_mlx_process_running', { pid }); +} +async function getMlxRandomPort() { + return await invoke('plugin:mlx|get_mlx_random_port'); +} +async function findMlxSessionByModel(modelId) { + return await invoke('plugin:mlx|find_mlx_session_by_model', { modelId }); +} +async function getMlxLoadedModels() { + return await invoke('plugin:mlx|get_mlx_loaded_models'); +} +async function getMlxAllSessions() { + return await invoke('plugin:mlx|get_mlx_all_sessions'); +} + +export { findMlxSessionByModel, getMlxAllSessions, getMlxLoadedModels, getMlxRandomPort, isMlxProcessRunning, loadMlxModel, normalizeMlxConfig, unloadMlxModel }; diff --git a/src-tauri/plugins/tauri-plugin-mlx/dist-js/types.d.ts b/src-tauri/plugins/tauri-plugin-mlx/dist-js/types.d.ts new file mode 100644 index 000000000..3f7feb0f4 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/dist-js/types.d.ts @@ -0,0 +1,18 @@ +export interface SessionInfo { + pid: number; + port: number; + model_id: string; + model_path: string; + is_embedding: boolean; + api_key: string; +} +export interface UnloadResult { + success: boolean; + error?: string; +} +export type MlxConfig = { + ctx_size: number; + n_predict: number; + threads: number; + chat_template: string; +}; diff --git a/src-tauri/plugins/tauri-plugin-mlx/guest-js/index.ts b/src-tauri/plugins/tauri-plugin-mlx/guest-js/index.ts new file mode 100644 index 000000000..f436738be --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/guest-js/index.ts @@ -0,0 +1,73 @@ +import { invoke } from '@tauri-apps/api/core' +import { SessionInfo, UnloadResult, MlxConfig } from './types' + +export { SessionInfo, UnloadResult, MlxConfig } from './types' + +function asNumber(v: any, defaultValue = 0): number { + if (v === '' || v === null || v === undefined) return defaultValue + const n = Number(v) + return isFinite(n) ? n : defaultValue +} + +function asString(v: any, defaultValue = ''): string { + if (v === '' || v === null || v === undefined) return defaultValue + return String(v) +} + +export function normalizeMlxConfig(config: any): MlxConfig { + return { + ctx_size: asNumber(config.ctx_size), + n_predict: asNumber(config.n_predict), + threads: asNumber(config.threads), + chat_template: asString(config.chat_template), + } +} + +export async function loadMlxModel( + binaryPath: string, + modelId: string, + modelPath: string, + port: number, + cfg: MlxConfig, + envs: Record, + isEmbedding: boolean = false, + timeout: number = 600 +): Promise { + const config = normalizeMlxConfig(cfg) + return await invoke('plugin:mlx|load_mlx_model', { + binaryPath, + modelId, + modelPath, + port, + config, + envs, + isEmbedding, + timeout, + }) +} + +export async function unloadMlxModel(pid: number): Promise { + return await invoke('plugin:mlx|unload_mlx_model', { pid }) +} + +export async function isMlxProcessRunning(pid: number): Promise { + return await invoke('plugin:mlx|is_mlx_process_running', { pid }) +} + +export async function getMlxRandomPort(): Promise { + return await invoke('plugin:mlx|get_mlx_random_port') +} + +export async function findMlxSessionByModel( + modelId: string +): Promise { + return await invoke('plugin:mlx|find_mlx_session_by_model', { modelId }) +} + +export async function getMlxLoadedModels(): Promise { + return await invoke('plugin:mlx|get_mlx_loaded_models') +} + +export async function getMlxAllSessions(): Promise { + return await invoke('plugin:mlx|get_mlx_all_sessions') +} diff --git a/src-tauri/plugins/tauri-plugin-mlx/guest-js/types.ts b/src-tauri/plugins/tauri-plugin-mlx/guest-js/types.ts new file mode 100644 index 000000000..2da8d442a --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/guest-js/types.ts @@ -0,0 +1,20 @@ +export interface SessionInfo { + pid: number + port: number + model_id: string + model_path: string + is_embedding: boolean + api_key: string +} + +export interface UnloadResult { + success: boolean + error?: string +} + +export type MlxConfig = { + ctx_size: number + n_predict: number + threads: number + chat_template: string +} diff --git a/src-tauri/plugins/tauri-plugin-mlx/package.json b/src-tauri/plugins/tauri-plugin-mlx/package.json new file mode 100644 index 000000000..d02608dbd --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/package.json @@ -0,0 +1,33 @@ +{ + "name": "@janhq/tauri-plugin-mlx-api", + "version": "0.6.6", + "private": true, + "description": "Tauri plugin API for MLX-Swift inference on Apple Silicon", + "type": "module", + "types": "./dist-js/index.d.ts", + "main": "./dist-js/index.cjs", + "module": "./dist-js/index.js", + "exports": { + "types": "./dist-js/index.d.ts", + "import": "./dist-js/index.js", + "require": "./dist-js/index.cjs" + }, + "files": [ + "dist-js", + "README.md" + ], + "scripts": { + "build": "rollup -c", + "prepublishOnly": "yarn build", + "pretest": "yarn build" + }, + "dependencies": { + "@tauri-apps/api": ">=2.0.0-beta.6" + }, + "devDependencies": { + "@rollup/plugin-typescript": "^12.0.0", + "rollup": "^4.9.6", + "tslib": "^2.6.2", + "typescript": "^5.3.3" + } +} diff --git a/src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/cleanup_mlx_processes.toml b/src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/cleanup_mlx_processes.toml new file mode 100644 index 000000000..00c16f6ac --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/cleanup_mlx_processes.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-cleanup-mlx-processes" +description = "Enables the cleanup_mlx_processes command without any pre-configured scope." +commands.allow = ["cleanup_mlx_processes"] + +[[permission]] +identifier = "deny-cleanup-mlx-processes" +description = "Denies the cleanup_mlx_processes command without any pre-configured scope." +commands.deny = ["cleanup_mlx_processes"] diff --git a/src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/find_mlx_session_by_model.toml b/src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/find_mlx_session_by_model.toml new file mode 100644 index 000000000..ac56de8e7 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/find_mlx_session_by_model.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-find-mlx-session-by-model" +description = "Enables the find_mlx_session_by_model command without any pre-configured scope." +commands.allow = ["find_mlx_session_by_model"] + +[[permission]] +identifier = "deny-find-mlx-session-by-model" +description = "Denies the find_mlx_session_by_model command without any pre-configured scope." +commands.deny = ["find_mlx_session_by_model"] diff --git a/src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/get_mlx_all_sessions.toml b/src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/get_mlx_all_sessions.toml new file mode 100644 index 000000000..cfcea444e --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/get_mlx_all_sessions.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-get-mlx-all-sessions" +description = "Enables the get_mlx_all_sessions command without any pre-configured scope." +commands.allow = ["get_mlx_all_sessions"] + +[[permission]] +identifier = "deny-get-mlx-all-sessions" +description = "Denies the get_mlx_all_sessions command without any pre-configured scope." +commands.deny = ["get_mlx_all_sessions"] diff --git a/src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/get_mlx_loaded_models.toml b/src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/get_mlx_loaded_models.toml new file mode 100644 index 000000000..0c887b592 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/get_mlx_loaded_models.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-get-mlx-loaded-models" +description = "Enables the get_mlx_loaded_models command without any pre-configured scope." +commands.allow = ["get_mlx_loaded_models"] + +[[permission]] +identifier = "deny-get-mlx-loaded-models" +description = "Denies the get_mlx_loaded_models command without any pre-configured scope." +commands.deny = ["get_mlx_loaded_models"] diff --git a/src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/get_mlx_random_port.toml b/src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/get_mlx_random_port.toml new file mode 100644 index 000000000..f4b830113 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/get_mlx_random_port.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-get-mlx-random-port" +description = "Enables the get_mlx_random_port command without any pre-configured scope." +commands.allow = ["get_mlx_random_port"] + +[[permission]] +identifier = "deny-get-mlx-random-port" +description = "Denies the get_mlx_random_port command without any pre-configured scope." +commands.deny = ["get_mlx_random_port"] diff --git a/src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/is_mlx_process_running.toml b/src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/is_mlx_process_running.toml new file mode 100644 index 000000000..370f77e49 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/is_mlx_process_running.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-is-mlx-process-running" +description = "Enables the is_mlx_process_running command without any pre-configured scope." +commands.allow = ["is_mlx_process_running"] + +[[permission]] +identifier = "deny-is-mlx-process-running" +description = "Denies the is_mlx_process_running command without any pre-configured scope." +commands.deny = ["is_mlx_process_running"] diff --git a/src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/load_mlx_model.toml b/src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/load_mlx_model.toml new file mode 100644 index 000000000..3bc1891c1 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/load_mlx_model.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-load-mlx-model" +description = "Enables the load_mlx_model command without any pre-configured scope." +commands.allow = ["load_mlx_model"] + +[[permission]] +identifier = "deny-load-mlx-model" +description = "Denies the load_mlx_model command without any pre-configured scope." +commands.deny = ["load_mlx_model"] diff --git a/src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/unload_mlx_model.toml b/src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/unload_mlx_model.toml new file mode 100644 index 000000000..220624ce8 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/commands/unload_mlx_model.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-unload-mlx-model" +description = "Enables the unload_mlx_model command without any pre-configured scope." +commands.allow = ["unload_mlx_model"] + +[[permission]] +identifier = "deny-unload-mlx-model" +description = "Denies the unload_mlx_model command without any pre-configured scope." +commands.deny = ["unload_mlx_model"] diff --git a/src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/reference.md b/src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/reference.md new file mode 100644 index 000000000..8dbc0e7be --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/permissions/autogenerated/reference.md @@ -0,0 +1,232 @@ +## Default Permission + +Default permissions for the MLX plugin + +#### This default permission set includes the following: + +- `allow-cleanup-mlx-processes` +- `allow-load-mlx-model` +- `allow-unload-mlx-model` +- `allow-is-mlx-process-running` +- `allow-get-mlx-random-port` +- `allow-find-mlx-session-by-model` +- `allow-get-mlx-loaded-models` +- `allow-get-mlx-all-sessions` + +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`mlx:allow-cleanup-mlx-processes` + + + +Enables the cleanup_mlx_processes command without any pre-configured scope. + +
+ +`mlx:deny-cleanup-mlx-processes` + + + +Denies the cleanup_mlx_processes command without any pre-configured scope. + +
+ +`mlx:allow-find-mlx-session-by-model` + + + +Enables the find_mlx_session_by_model command without any pre-configured scope. + +
+ +`mlx:deny-find-mlx-session-by-model` + + + +Denies the find_mlx_session_by_model command without any pre-configured scope. + +
+ +`mlx:allow-get-mlx-all-sessions` + + + +Enables the get_mlx_all_sessions command without any pre-configured scope. + +
+ +`mlx:deny-get-mlx-all-sessions` + + + +Denies the get_mlx_all_sessions command without any pre-configured scope. + +
+ +`mlx:allow-get-mlx-loaded-models` + + + +Enables the get_mlx_loaded_models command without any pre-configured scope. + +
+ +`mlx:deny-get-mlx-loaded-models` + + + +Denies the get_mlx_loaded_models command without any pre-configured scope. + +
+ +`mlx:allow-get-mlx-random-port` + + + +Enables the get_mlx_random_port command without any pre-configured scope. + +
+ +`mlx:deny-get-mlx-random-port` + + + +Denies the get_mlx_random_port command without any pre-configured scope. + +
+ +`mlx:allow-is-mlx-process-running` + + + +Enables the is_mlx_process_running command without any pre-configured scope. + +
+ +`mlx:deny-is-mlx-process-running` + + + +Denies the is_mlx_process_running command without any pre-configured scope. + +
+ +`mlx:allow-load-mlx-model` + + + +Enables the load_mlx_model command without any pre-configured scope. + +
+ +`mlx:deny-load-mlx-model` + + + +Denies the load_mlx_model command without any pre-configured scope. + +
+ +`mlx:allow-unload-mlx-model` + + + +Enables the unload_mlx_model command without any pre-configured scope. + +
+ +`mlx:deny-unload-mlx-model` + + + +Denies the unload_mlx_model command without any pre-configured scope. + +
diff --git a/src-tauri/plugins/tauri-plugin-mlx/permissions/default.toml b/src-tauri/plugins/tauri-plugin-mlx/permissions/default.toml new file mode 100644 index 000000000..d5e0c18d8 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/permissions/default.toml @@ -0,0 +1,12 @@ +[default] +description = "Default permissions for the MLX plugin" +permissions = [ + "allow-cleanup-mlx-processes", + "allow-load-mlx-model", + "allow-unload-mlx-model", + "allow-is-mlx-process-running", + "allow-get-mlx-random-port", + "allow-find-mlx-session-by-model", + "allow-get-mlx-loaded-models", + "allow-get-mlx-all-sessions", +] diff --git a/src-tauri/plugins/tauri-plugin-mlx/permissions/schemas/schema.json b/src-tauri/plugins/tauri-plugin-mlx/permissions/schemas/schema.json new file mode 100644 index 000000000..1f91760bd --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/permissions/schemas/schema.json @@ -0,0 +1,402 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the cleanup_mlx_processes command without any pre-configured scope.", + "type": "string", + "const": "allow-cleanup-mlx-processes", + "markdownDescription": "Enables the cleanup_mlx_processes command without any pre-configured scope." + }, + { + "description": "Denies the cleanup_mlx_processes command without any pre-configured scope.", + "type": "string", + "const": "deny-cleanup-mlx-processes", + "markdownDescription": "Denies the cleanup_mlx_processes command without any pre-configured scope." + }, + { + "description": "Enables the find_mlx_session_by_model command without any pre-configured scope.", + "type": "string", + "const": "allow-find-mlx-session-by-model", + "markdownDescription": "Enables the find_mlx_session_by_model command without any pre-configured scope." + }, + { + "description": "Denies the find_mlx_session_by_model command without any pre-configured scope.", + "type": "string", + "const": "deny-find-mlx-session-by-model", + "markdownDescription": "Denies the find_mlx_session_by_model command without any pre-configured scope." + }, + { + "description": "Enables the get_mlx_all_sessions command without any pre-configured scope.", + "type": "string", + "const": "allow-get-mlx-all-sessions", + "markdownDescription": "Enables the get_mlx_all_sessions command without any pre-configured scope." + }, + { + "description": "Denies the get_mlx_all_sessions command without any pre-configured scope.", + "type": "string", + "const": "deny-get-mlx-all-sessions", + "markdownDescription": "Denies the get_mlx_all_sessions command without any pre-configured scope." + }, + { + "description": "Enables the get_mlx_loaded_models command without any pre-configured scope.", + "type": "string", + "const": "allow-get-mlx-loaded-models", + "markdownDescription": "Enables the get_mlx_loaded_models command without any pre-configured scope." + }, + { + "description": "Denies the get_mlx_loaded_models command without any pre-configured scope.", + "type": "string", + "const": "deny-get-mlx-loaded-models", + "markdownDescription": "Denies the get_mlx_loaded_models command without any pre-configured scope." + }, + { + "description": "Enables the get_mlx_random_port command without any pre-configured scope.", + "type": "string", + "const": "allow-get-mlx-random-port", + "markdownDescription": "Enables the get_mlx_random_port command without any pre-configured scope." + }, + { + "description": "Denies the get_mlx_random_port command without any pre-configured scope.", + "type": "string", + "const": "deny-get-mlx-random-port", + "markdownDescription": "Denies the get_mlx_random_port command without any pre-configured scope." + }, + { + "description": "Enables the is_mlx_process_running command without any pre-configured scope.", + "type": "string", + "const": "allow-is-mlx-process-running", + "markdownDescription": "Enables the is_mlx_process_running command without any pre-configured scope." + }, + { + "description": "Denies the is_mlx_process_running command without any pre-configured scope.", + "type": "string", + "const": "deny-is-mlx-process-running", + "markdownDescription": "Denies the is_mlx_process_running command without any pre-configured scope." + }, + { + "description": "Enables the load_mlx_model command without any pre-configured scope.", + "type": "string", + "const": "allow-load-mlx-model", + "markdownDescription": "Enables the load_mlx_model command without any pre-configured scope." + }, + { + "description": "Denies the load_mlx_model command without any pre-configured scope.", + "type": "string", + "const": "deny-load-mlx-model", + "markdownDescription": "Denies the load_mlx_model command without any pre-configured scope." + }, + { + "description": "Enables the unload_mlx_model command without any pre-configured scope.", + "type": "string", + "const": "allow-unload-mlx-model", + "markdownDescription": "Enables the unload_mlx_model command without any pre-configured scope." + }, + { + "description": "Denies the unload_mlx_model command without any pre-configured scope.", + "type": "string", + "const": "deny-unload-mlx-model", + "markdownDescription": "Denies the unload_mlx_model command without any pre-configured scope." + }, + { + "description": "Default permissions for the MLX plugin\n#### This default permission set includes:\n\n- `allow-cleanup-mlx-processes`\n- `allow-load-mlx-model`\n- `allow-unload-mlx-model`\n- `allow-is-mlx-process-running`\n- `allow-get-mlx-random-port`\n- `allow-find-mlx-session-by-model`\n- `allow-get-mlx-loaded-models`\n- `allow-get-mlx-all-sessions`", + "type": "string", + "const": "default", + "markdownDescription": "Default permissions for the MLX plugin\n#### This default permission set includes:\n\n- `allow-cleanup-mlx-processes`\n- `allow-load-mlx-model`\n- `allow-unload-mlx-model`\n- `allow-is-mlx-process-running`\n- `allow-get-mlx-random-port`\n- `allow-find-mlx-session-by-model`\n- `allow-get-mlx-loaded-models`\n- `allow-get-mlx-all-sessions`" + } + ] + } + } +} \ No newline at end of file diff --git a/src-tauri/plugins/tauri-plugin-mlx/rollup.config.js b/src-tauri/plugins/tauri-plugin-mlx/rollup.config.js new file mode 100644 index 000000000..8b4768ff6 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/rollup.config.js @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { cwd } from 'node:process' +import typescript from '@rollup/plugin-typescript' + +const pkg = JSON.parse(readFileSync(join(cwd(), 'package.json'), 'utf8')) + +export default { + input: 'guest-js/index.ts', + output: [ + { + file: pkg.exports.import, + format: 'esm' + }, + { + file: pkg.exports.require, + format: 'cjs' + } + ], + plugins: [ + typescript({ + declaration: true, + declarationDir: dirname(pkg.exports.import) + }) + ], + external: [ + /^@tauri-apps\/api/, + ...Object.keys(pkg.dependencies || {}), + ...Object.keys(pkg.peerDependencies || {}) + ] +} diff --git a/src-tauri/plugins/tauri-plugin-mlx/src/cleanup.rs b/src-tauri/plugins/tauri-plugin-mlx/src/cleanup.rs new file mode 100644 index 000000000..cbce70628 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/src/cleanup.rs @@ -0,0 +1,59 @@ +use tauri::{Manager, Runtime}; + +pub async fn cleanup_processes(app_handle: &tauri::AppHandle) { + let app_state = match app_handle.try_state::() { + Some(state) => state, + None => { + log::warn!("MlxState not found in app_handle"); + return; + } + }; + let mut map = app_state.mlx_server_process.lock().await; + let pids: Vec = map.keys().cloned().collect(); + for pid in pids { + if let Some(session) = map.remove(&pid) { + let mut child = session.child; + #[cfg(unix)] + { + use nix::sys::signal::{kill, Signal}; + use nix::unistd::Pid; + use tokio::time::{timeout, Duration}; + + if let Some(raw_pid) = child.id() { + let raw_pid = raw_pid as i32; + log::info!("Sending SIGTERM to MLX PID {} during shutdown", raw_pid); + let _ = kill(Pid::from_raw(raw_pid), Signal::SIGTERM); + + match timeout(Duration::from_secs(2), child.wait()).await { + Ok(Ok(status)) => { + log::info!("MLX process {} exited gracefully: {}", raw_pid, status) + } + Ok(Err(e)) => { + log::error!( + "Error waiting after SIGTERM for MLX process {}: {}", + raw_pid, + e + ) + } + Err(_) => { + log::warn!( + "SIGTERM timed out for MLX PID {}; sending SIGKILL", + raw_pid + ); + let _ = kill(Pid::from_raw(raw_pid), Signal::SIGKILL); + let _ = child.wait().await; + } + } + } + } + } + } +} + +#[tauri::command] +pub async fn cleanup_mlx_processes( + app_handle: tauri::AppHandle, +) -> Result<(), String> { + cleanup_processes(&app_handle).await; + Ok(()) +} diff --git a/src-tauri/plugins/tauri-plugin-mlx/src/commands.rs b/src-tauri/plugins/tauri-plugin-mlx/src/commands.rs new file mode 100644 index 000000000..def1a8ceb --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/src/commands.rs @@ -0,0 +1,360 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::process::Stdio; +use std::time::Duration; +use tauri::{Manager, Runtime, State}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; +use tokio::sync::mpsc; +use tokio::time::Instant; + +use crate::error::{ErrorCode, MlxError, ServerError, ServerResult}; +use crate::process::{ + find_session_by_model_id, get_all_active_sessions, get_all_loaded_model_ids, + get_random_available_port, is_process_running_by_pid, +}; +use crate::state::{MlxBackendSession, MlxState, SessionInfo}; + +#[cfg(unix)] +use crate::process::graceful_terminate_process; + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct UnloadResult { + success: bool, + error: Option, +} + +/// MLX server configuration passed from the frontend +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct MlxConfig { + #[serde(default)] + pub ctx_size: i32, + #[serde(default)] + pub n_predict: i32, + #[serde(default)] + pub threads: i32, + #[serde(default)] + pub chat_template: String, +} + +/// Load a model using the MLX server binary +#[tauri::command] +pub async fn load_mlx_model( + app_handle: tauri::AppHandle, + binary_path: String, + model_id: String, + model_path: String, + port: u16, + config: MlxConfig, + envs: HashMap, + is_embedding: bool, + timeout: u64, +) -> ServerResult { + let state: State = app_handle.state(); + let mut process_map = state.mlx_server_process.lock().await; + + log::info!("Attempting to launch MLX server at path: {:?}", binary_path); + log::info!("Using MLX configuration: {:?}", config); + + // Validate binary path + let bin_path = PathBuf::from(&binary_path); + if !bin_path.exists() { + return Err(MlxError::new( + ErrorCode::BinaryNotFound, + format!("MLX server binary not found at: {}", binary_path), + None, + ) + .into()); + } + + // Validate model path + let model_path_pb = PathBuf::from(&model_path); + if !model_path_pb.exists() { + return Err(MlxError::new( + ErrorCode::ModelFileNotFound, + format!("Model file not found at: {}", model_path), + None, + ) + .into()); + } + + let api_key: String = envs + .get("MLX_API_KEY") + .map(|s| s.to_string()) + .unwrap_or_else(|| { + log::warn!("API key not provided for MLX server"); + String::new() + }); + + // Build command arguments + let mut args: Vec = vec![ + "--model".to_string(), + model_path.clone(), + "--port".to_string(), + port.to_string(), + ]; + + if config.ctx_size > 0 { + args.push("--ctx-size".to_string()); + args.push(config.ctx_size.to_string()); + } + + if !api_key.is_empty() { + args.push("--api-key".to_string()); + args.push(api_key.clone()); + } + + if !config.chat_template.is_empty() { + args.push("--chat-template".to_string()); + args.push(config.chat_template.clone()); + } + + if is_embedding { + args.push("--embedding".to_string()); + } + + log::info!("MLX server arguments: {:?}", args); + + // Configure the command + let mut command = Command::new(&bin_path); + command.args(&args); + command.envs(envs); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + + // Spawn the child process + let mut child = command.spawn().map_err(ServerError::Io)?; + + let stderr = child.stderr.take().expect("stderr was piped"); + let stdout = child.stdout.take().expect("stdout was piped"); + + // Create channels for communication between tasks + let (ready_tx, mut ready_rx) = mpsc::channel::(1); + + // Spawn task to monitor stdout for readiness + let stdout_ready_tx = ready_tx.clone(); + let _stdout_task = tokio::spawn(async move { + let mut reader = BufReader::new(stdout); + let mut byte_buffer = Vec::new(); + + loop { + byte_buffer.clear(); + match reader.read_until(b'\n', &mut byte_buffer).await { + Ok(0) => break, + Ok(_) => { + let line = String::from_utf8_lossy(&byte_buffer); + let line = line.trim_end(); + if !line.is_empty() { + log::info!("[mlx stdout] {}", line); + } + + let line_lower = line.to_lowercase(); + if line_lower.contains("http server listening") + || line_lower.contains("server is listening") + || line_lower.contains("server started") + || line_lower.contains("ready to accept") + || line_lower.contains("server started and listening on") + { + log::info!( + "MLX server appears to be ready based on stdout: '{}'", + line + ); + let _ = stdout_ready_tx.send(true).await; + } + } + Err(e) => { + log::error!("Error reading MLX stdout: {}", e); + break; + } + } + } + }); + + // Spawn task to capture stderr and monitor for errors + let stderr_task = tokio::spawn(async move { + let mut reader = BufReader::new(stderr); + let mut byte_buffer = Vec::new(); + let mut stderr_buffer = String::new(); + + loop { + byte_buffer.clear(); + match reader.read_until(b'\n', &mut byte_buffer).await { + Ok(0) => break, + Ok(_) => { + let line = String::from_utf8_lossy(&byte_buffer); + let line = line.trim_end(); + + if !line.is_empty() { + stderr_buffer.push_str(line); + stderr_buffer.push('\n'); + log::info!("[mlx] {}", line); + + let line_lower = line.to_lowercase(); + if line_lower.contains("server is listening") + || line_lower.contains("server listening on") + || line_lower.contains("server started and listening on") + { + log::info!( + "MLX model appears to be ready based on logs: '{}'", + line + ); + let _ = ready_tx.send(true).await; + } + } + } + Err(e) => { + log::error!("Error reading MLX logs: {}", e); + break; + } + } + } + + stderr_buffer + }); + + // Check if process exited early + if let Some(status) = child.try_wait()? { + if !status.success() { + let stderr_output = stderr_task.await.unwrap_or_default(); + log::error!("MLX server failed early with code {:?}", status); + log::error!("{}", stderr_output); + return Err(MlxError::from_stderr(&stderr_output).into()); + } + } + + // Wait for server to be ready or timeout + let timeout_duration = Duration::from_secs(timeout); + let start_time = Instant::now(); + log::info!("Waiting for MLX model session to be ready..."); + + loop { + tokio::select! { + Some(true) = ready_rx.recv() => { + log::info!("MLX model is ready to accept requests!"); + break; + } + _ = tokio::time::sleep(Duration::from_millis(50)) => { + if let Some(status) = child.try_wait()? { + let stderr_output = stderr_task.await.unwrap_or_default(); + if !status.success() { + log::error!("MLX server exited with error code {:?}", status); + return Err(MlxError::from_stderr(&stderr_output).into()); + } else { + log::error!("MLX server exited successfully but without ready signal"); + return Err(MlxError::from_stderr(&stderr_output).into()); + } + } + + if start_time.elapsed() > timeout_duration { + log::error!("Timeout waiting for MLX server to be ready"); + let _ = child.kill().await; + let stderr_output = stderr_task.await.unwrap_or_default(); + return Err(MlxError::new( + ErrorCode::ModelLoadTimedOut, + "The MLX model took too long to load and timed out.".into(), + Some(format!( + "Timeout: {}s\n\nStderr:\n{}", + timeout_duration.as_secs(), + stderr_output + )), + ) + .into()); + } + } + } + } + + let pid = child.id().map(|id| id as i32).unwrap_or(-1); + + log::info!("MLX server process started with PID: {} and is ready", pid); + let session_info = SessionInfo { + pid, + port: port.into(), + model_id, + model_path: model_path_pb.display().to_string(), + is_embedding, + api_key, + }; + + process_map.insert( + pid, + MlxBackendSession { + child, + info: session_info.clone(), + }, + ); + + Ok(session_info) +} + +/// Unload an MLX model by terminating its process +#[tauri::command] +pub async fn unload_mlx_model( + app_handle: tauri::AppHandle, + pid: i32, +) -> ServerResult { + let state: State = app_handle.state(); + let mut map = state.mlx_server_process.lock().await; + + if let Some(session) = map.remove(&pid) { + let mut child = session.child; + + #[cfg(unix)] + { + graceful_terminate_process(&mut child).await; + } + + Ok(UnloadResult { + success: true, + error: None, + }) + } else { + log::warn!("No MLX server with PID '{}' found", pid); + Ok(UnloadResult { + success: true, + error: None, + }) + } +} + +/// Check if a process is still running +#[tauri::command] +pub async fn is_mlx_process_running( + app_handle: tauri::AppHandle, + pid: i32, +) -> Result { + is_process_running_by_pid(app_handle, pid).await +} + +/// Get a random available port +#[tauri::command] +pub async fn get_mlx_random_port( + app_handle: tauri::AppHandle, +) -> Result { + get_random_available_port(app_handle).await +} + +/// Find session information by model ID +#[tauri::command] +pub async fn find_mlx_session_by_model( + app_handle: tauri::AppHandle, + model_id: String, +) -> Result, String> { + find_session_by_model_id(app_handle, &model_id).await +} + +/// Get all loaded model IDs +#[tauri::command] +pub async fn get_mlx_loaded_models( + app_handle: tauri::AppHandle, +) -> Result, String> { + get_all_loaded_model_ids(app_handle).await +} + +/// Get all active sessions +#[tauri::command] +pub async fn get_mlx_all_sessions( + app_handle: tauri::AppHandle, +) -> Result, String> { + get_all_active_sessions(app_handle).await +} diff --git a/src-tauri/plugins/tauri-plugin-mlx/src/error.rs b/src-tauri/plugins/tauri-plugin-mlx/src/error.rs new file mode 100644 index 000000000..02cb9346e --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/src/error.rs @@ -0,0 +1,91 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ErrorCode { + BinaryNotFound, + ModelFileNotFound, + ModelLoadFailed, + ModelLoadTimedOut, + OutOfMemory, + MlxProcessError, + IoError, + InternalError, +} + +#[derive(Debug, Clone, Serialize, thiserror::Error)] +#[error("MlxError {{ code: {code:?}, message: \"{message}\" }}")] +pub struct MlxError { + pub code: ErrorCode, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl MlxError { + pub fn new(code: ErrorCode, message: String, details: Option) -> Self { + Self { + code, + message, + details, + } + } + + /// Parses stderr from the MLX server and creates a specific MlxError. + pub fn from_stderr(stderr: &str) -> Self { + let lower_stderr = stderr.to_lowercase(); + + if lower_stderr.contains("out of memory") + || lower_stderr.contains("failed to allocate") + || lower_stderr.contains("insufficient memory") + { + return Self::new( + ErrorCode::OutOfMemory, + "Out of memory. The model requires more RAM than available.".into(), + Some(stderr.into()), + ); + } + + Self::new( + ErrorCode::MlxProcessError, + "The MLX model process encountered an unexpected error.".into(), + Some(stderr.into()), + ) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ServerError { + #[error(transparent)] + Mlx(#[from] MlxError), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Tauri error: {0}")] + Tauri(#[from] tauri::Error), +} + +impl serde::Serialize for ServerError { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let error_to_serialize: MlxError = match self { + ServerError::Mlx(err) => err.clone(), + ServerError::Io(e) => MlxError::new( + ErrorCode::IoError, + "An input/output error occurred.".into(), + Some(e.to_string()), + ), + ServerError::Tauri(e) => MlxError::new( + ErrorCode::InternalError, + "An internal application error occurred.".into(), + Some(e.to_string()), + ), + }; + error_to_serialize.serialize(serializer) + } +} + +pub type ServerResult = Result; diff --git a/src-tauri/plugins/tauri-plugin-mlx/src/lib.rs b/src-tauri/plugins/tauri-plugin-mlx/src/lib.rs new file mode 100644 index 000000000..1f28c535a --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/src/lib.rs @@ -0,0 +1,32 @@ +use tauri::{ + plugin::{Builder, TauriPlugin}, + Manager, Runtime, +}; + +pub mod cleanup; +mod commands; +mod error; +mod process; +pub mod state; + +pub use cleanup::cleanup_mlx_processes; + +/// Initializes the MLX plugin. +pub fn init() -> TauriPlugin { + Builder::new("mlx") + .invoke_handler(tauri::generate_handler![ + cleanup::cleanup_mlx_processes, + commands::load_mlx_model, + commands::unload_mlx_model, + commands::is_mlx_process_running, + commands::get_mlx_random_port, + commands::find_mlx_session_by_model, + commands::get_mlx_loaded_models, + commands::get_mlx_all_sessions, + ]) + .setup(|app, _api| { + app.manage(state::MlxState::new()); + Ok(()) + }) + .build() +} diff --git a/src-tauri/plugins/tauri-plugin-mlx/src/process.rs b/src-tauri/plugins/tauri-plugin-mlx/src/process.rs new file mode 100644 index 000000000..2ebe8fd5e --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/src/process.rs @@ -0,0 +1,120 @@ +use std::collections::HashSet; +use sysinfo::{Pid, System}; +use tauri::{Manager, Runtime, State}; + +use crate::state::{MlxState, SessionInfo}; +use jan_utils::generate_random_port; + +/// Check if a process is running by PID +pub async fn is_process_running_by_pid( + app_handle: tauri::AppHandle, + pid: i32, +) -> Result { + let mut system = System::new(); + system.refresh_processes(sysinfo::ProcessesToUpdate::All, true); + let process_pid = Pid::from(pid as usize); + let alive = system.process(process_pid).is_some(); + + if !alive { + let state: State = app_handle.state(); + let mut map = state.mlx_server_process.lock().await; + map.remove(&pid); + } + + Ok(alive) +} + +/// Get a random available port, avoiding ports used by existing sessions +pub async fn get_random_available_port( + app_handle: tauri::AppHandle, +) -> Result { + let state: State = app_handle.state(); + let map = state.mlx_server_process.lock().await; + + let used_ports: HashSet = map + .values() + .filter_map(|session| { + if session.info.port > 0 && session.info.port <= u16::MAX as i32 { + Some(session.info.port as u16) + } else { + None + } + }) + .collect(); + + drop(map); + + generate_random_port(&used_ports) +} + +/// Gracefully terminate a process on Unix systems (macOS) +#[cfg(unix)] +pub async fn graceful_terminate_process(child: &mut tokio::process::Child) { + use nix::sys::signal::{kill, Signal}; + use nix::unistd::Pid; + use std::time::Duration; + use tokio::time::timeout; + + if let Some(raw_pid) = child.id() { + let raw_pid = raw_pid as i32; + log::info!("Sending SIGTERM to MLX process PID {}", raw_pid); + let _ = kill(Pid::from_raw(raw_pid), Signal::SIGTERM); + + match timeout(Duration::from_secs(5), child.wait()).await { + Ok(Ok(status)) => log::info!("MLX process exited gracefully: {}", status), + Ok(Err(e)) => log::error!("Error waiting after SIGTERM for MLX process: {}", e), + Err(_) => { + log::warn!( + "SIGTERM timed out for MLX PID {}; sending SIGKILL", + raw_pid + ); + let _ = kill(Pid::from_raw(raw_pid), Signal::SIGKILL); + match child.wait().await { + Ok(s) => log::info!("Force-killed MLX process exited: {}", s), + Err(e) => log::error!("Error waiting after SIGKILL for MLX process: {}", e), + } + } + } + } +} + +/// Find a session by model ID +pub async fn find_session_by_model_id( + app_handle: tauri::AppHandle, + model_id: &str, +) -> Result, String> { + let state: State = app_handle.state(); + let map = state.mlx_server_process.lock().await; + + let session_info = map + .values() + .find(|backend_session| backend_session.info.model_id == model_id) + .map(|backend_session| backend_session.info.clone()); + + Ok(session_info) +} + +/// Get all loaded model IDs +pub async fn get_all_loaded_model_ids( + app_handle: tauri::AppHandle, +) -> Result, String> { + let state: State = app_handle.state(); + let map = state.mlx_server_process.lock().await; + + let model_ids = map + .values() + .map(|backend_session| backend_session.info.model_id.clone()) + .collect(); + + Ok(model_ids) +} + +/// Get all active sessions +pub async fn get_all_active_sessions( + app_handle: tauri::AppHandle, +) -> Result, String> { + let state: State = app_handle.state(); + let map = state.mlx_server_process.lock().await; + let sessions: Vec = map.values().map(|s| s.info.clone()).collect(); + Ok(sessions) +} diff --git a/src-tauri/plugins/tauri-plugin-mlx/src/state.rs b/src-tauri/plugins/tauri-plugin-mlx/src/state.rs new file mode 100644 index 000000000..e8f31b585 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/src/state.rs @@ -0,0 +1,39 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::process::Child; +use tokio::sync::Mutex; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionInfo { + pub pid: i32, + pub port: i32, + pub model_id: String, + pub model_path: String, + pub is_embedding: bool, + pub api_key: String, +} + +pub struct MlxBackendSession { + pub child: Child, + pub info: SessionInfo, +} + +/// MLX plugin state +pub struct MlxState { + pub mlx_server_process: Arc>>, +} + +impl Default for MlxState { + fn default() -> Self { + Self { + mlx_server_process: Arc::new(Mutex::new(HashMap::new())), + } + } +} + +impl MlxState { + pub fn new() -> Self { + Self::default() + } +} diff --git a/src-tauri/plugins/tauri-plugin-mlx/tsconfig.json b/src-tauri/plugins/tauri-plugin-mlx/tsconfig.json new file mode 100644 index 000000000..059112270 --- /dev/null +++ b/src-tauri/plugins/tauri-plugin-mlx/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es2021", + "module": "esnext", + "moduleResolution": "bundler", + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": true, + "noImplicitAny": true, + "noEmit": true + }, + "include": ["guest-js/*.ts"], + "exclude": ["dist-js", "node_modules"] +} diff --git a/src-tauri/plugins/yarn.lock b/src-tauri/plugins/yarn.lock index 7474ac631..ec6cad55b 100644 --- a/src-tauri/plugins/yarn.lock +++ b/src-tauri/plugins/yarn.lock @@ -52,6 +52,18 @@ __metadata: languageName: unknown linkType: soft +"@janhq/tauri-plugin-mlx-api@workspace:tauri-plugin-mlx": + version: 0.0.0-use.local + resolution: "@janhq/tauri-plugin-mlx-api@workspace:tauri-plugin-mlx" + dependencies: + "@rollup/plugin-typescript": "npm:^12.0.0" + "@tauri-apps/api": "npm:>=2.0.0-beta.6" + rollup: "npm:^4.9.6" + tslib: "npm:^2.6.2" + typescript: "npm:^5.3.3" + languageName: unknown + linkType: soft + "@janhq/tauri-plugin-rag-api@workspace:tauri-plugin-rag": version: 0.0.0-use.local resolution: "@janhq/tauri-plugin-rag-api@workspace:tauri-plugin-rag" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cc17eb1d8..6103caa73 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -41,6 +41,11 @@ pub fn run() { app_builder = app_builder.plugin(tauri_plugin_deep_link::init()); } + #[cfg(feature = "mlx")] + { + app_builder = app_builder.plugin(tauri_plugin_mlx::init()); + } + #[cfg(not(any(target_os = "android", target_os = "ios")))] { app_builder = app_builder.plugin(tauri_plugin_hardware::init()); @@ -328,6 +333,17 @@ pub fn run() { } else { log::info!("Llama processes cleaned up successfully"); } + + #[cfg(feature = "mlx")] + { + use tauri_plugin_mlx::cleanup_mlx_processes; + if let Err(e) = cleanup_mlx_processes(app_handle.clone()).await { + log::warn!("Failed to cleanup MLX processes: {}", e); + } else { + log::info!("MLX processes cleaned up successfully"); + } + } + log::info!("App cleanup completed"); }); }); diff --git a/web-app/public/images/model-provider/mlx.png b/web-app/public/images/model-provider/mlx.png new file mode 100644 index 0000000000000000000000000000000000000000..1e521e03ddf099c5ca3a70ff421ac91d7b417a56 GIT binary patch literal 64130 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 { serviceHub .providers() diff --git a/web-app/src/containers/dialogs/ImportMlxModelDialog.tsx b/web-app/src/containers/dialogs/ImportMlxModelDialog.tsx new file mode 100644 index 000000000..f9c36bc8b --- /dev/null +++ b/web-app/src/containers/dialogs/ImportMlxModelDialog.tsx @@ -0,0 +1,264 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { useServiceHub } from '@/hooks/useServiceHub' +import { useState } from 'react' +import { toast } from 'sonner' +import { + IconLoader2, + IconCheck, +} from '@tabler/icons-react' +import { ExtensionManager } from '@/lib/extension' + +type ImportMlxModelDialogProps = { + provider: ModelProvider + trigger?: React.ReactNode + onSuccess?: (importedModelName?: string) => void +} + +export const ImportMlxModelDialog = ({ + provider, + trigger, + onSuccess, +}: ImportMlxModelDialogProps) => { + const serviceHub = useServiceHub() + const [open, setOpen] = useState(false) + const [importing, setImporting] = useState(false) + const [selectedPath, setSelectedPath] = useState(null) + const [modelName, setModelName] = useState('') + + const handleFileSelect = async () => { + const result = await serviceHub.dialog().open({ + multiple: false, + directory: false, + filters: [ + { + name: 'Safetensor Files', + extensions: ['safetensors'], + }, + { + name: 'All Files', + extensions: ['*'], + }, + ], + }) + + if (result && typeof result === 'string') { + setSelectedPath(result) + + // Extract model name from path + const pathParts = result.split(/[\\/]/) + const nameFromPath = pathParts[pathParts.length - 1] || 'mlx-model' + const sanitizedName = nameFromPath + .replace(/\s/g, '-') + .replace(/[^a-zA-Z0-9/_.\-]/g, '') + setModelName(sanitizedName) + } + } + + const handleImport = async () => { + if (!selectedPath) { + toast.error('Please select a safetensor file or folder') + return + } + + if (!modelName) { + toast.error('Please enter a model name') + return + } + + // Validate model name - only allow alphanumeric, underscore, hyphen, and dot + if (!/^[a-zA-Z0-9/_.\-]+$/.test(modelName)) { + toast.error('Invalid model name. Only alphanumeric and _ - . characters are allowed.') + return + } + + // Check if model already exists + const modelExists = provider.models.some( + (model) => model.id === modelName + ) + + if (modelExists) { + toast.error('Model already exists', { + description: `${modelName} already imported`, + }) + return + } + + setImporting(true) + + try { + console.log('[MLX Import] Starting import:', { modelName, selectedPath }) + + // Get the MLX engine and call its import method + const engine = ExtensionManager.getInstance().getEngine('mlx') + if (!engine) { + throw new Error('MLX engine not found') + } + + console.log('[MLX Import] Calling engine.import()...') + await engine.import(modelName, { + modelPath: selectedPath, + }) + console.log('[MLX Import] Import completed') + + toast.success('Model imported successfully', { + description: `${modelName} has been imported`, + }) + + // Reset form and close dialog + setSelectedPath(null) + setModelName('') + setOpen(false) + onSuccess?.(modelName) + } catch (error) { + console.error('[MLX Import] Import model error:', error) + toast.error('Failed to import model', { + description: + error instanceof Error ? error.message : String(error), + }) + } finally { + setImporting(false) + } + } + + const resetForm = () => { + setSelectedPath(null) + setModelName('') + } + + const handleOpenChange = (newOpen: boolean) => { + if (!importing) { + setOpen(newOpen) + if (!newOpen) { + resetForm() + } + } + } + + const displayPath = selectedPath + ? selectedPath.split(/[\\/]/).pop() || selectedPath + : null + + return ( + + {trigger} + { + e.preventDefault() + }} + > + + + Import MLX Model + + + Import a safetensor model file or folder for use with MLX. MLX models + are typically downloaded from HuggingFace and use the safetensors format. + + + +
+ {/* Model Name Input */} +
+ + setModelName(e.target.value)} + placeholder="my-mlx-model" + className="w-full px-3 py-2 bg-background border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent/50" + /> +

+ Only alphanumeric and _ - . characters are allowed +

+
+ + {/* File Selection Area */} +
+
+

+ Safetensor File or Folder +

+ + Required + +
+ + {displayPath ? ( +
+
+
+ + + {displayPath} + +
+ +
+
+ ) : ( + + )} +
+ + {/* Preview */} + {modelName && ( +
+
+ + Model will be saved as: + +
+

+ mlx/models/{modelName}/ +

+
+ )} +
+ +
+ + +
+
+
+ ) +} \ No newline at end of file diff --git a/web-app/src/lib/custom-chat-transport.ts b/web-app/src/lib/custom-chat-transport.ts index e1c13bb44..e03f8806a 100644 --- a/web-app/src/lib/custom-chat-transport.ts +++ b/web-app/src/lib/custom-chat-transport.ts @@ -55,10 +55,7 @@ export class CustomChatTransport implements ChatTransport { private serviceHub: ServiceHub | null private threadId?: string - constructor( - systemMessage?: string, - threadId?: string - ) { + constructor(systemMessage?: string, threadId?: string) { this.systemMessage = systemMessage this.threadId = threadId this.serviceHub = useServiceStore.getState().serviceHub @@ -248,17 +245,17 @@ export class CustomChatTransport implements ChatTransport { return result.toUIMessageStream({ messageMetadata: ({ part }) => { - if (!streamStartTime) { + // Track stream start time on start + if (part.type === 'start' && !streamStartTime) { streamStartTime = Date.now() } - // Track stream start time on first text delta if (part.type === 'text-delta') { // Count text deltas as a rough token approximation // Each delta typically represents one token in streaming textDeltaCount++ // Report streaming token speed in real-time - if (this.onStreamingTokenSpeed) { + if (this.onStreamingTokenSpeed && streamStartTime) { const elapsedMs = Date.now() - streamStartTime this.onStreamingTokenSpeed(textDeltaCount, elapsedMs) } @@ -279,22 +276,18 @@ export class CustomChatTransport implements ChatTransport { } } const usage = finishPart.totalUsage - const llamacppMeta = finishPart.providerMetadata?.llamacpp const durationMs = streamStartTime ? Date.now() - streamStartTime : 0 const durationSec = durationMs / 1000 // Use provider's outputTokens, or llama.cpp completionTokens, or fall back to text delta count const outputTokens = usage?.outputTokens ?? - llamacppMeta?.completionTokens ?? textDeltaCount - const inputTokens = usage?.inputTokens ?? llamacppMeta?.promptTokens + const inputTokens = usage?.inputTokens // Use llama.cpp's tokens per second if available, otherwise calculate from duration let tokenSpeed: number - if (llamacppMeta?.tokensPerSecond != null) { - tokenSpeed = llamacppMeta.tokensPerSecond - } else if (durationSec > 0 && outputTokens > 0) { + if (durationSec > 0 && outputTokens > 0) { tokenSpeed = outputTokens / durationSec } else { tokenSpeed = 0 diff --git a/web-app/src/lib/model-factory.ts b/web-app/src/lib/model-factory.ts index db91c5ae7..e88118145 100644 --- a/web-app/src/lib/model-factory.ts +++ b/web-app/src/lib/model-factory.ts @@ -7,6 +7,7 @@ * * Supported Providers: * - llamacpp: Local models via llama.cpp (requires running session) + * - mlx: Local models via MLX-Swift on Apple Silicon (requires running session) * - anthropic: Claude models via Anthropic API (@ai-sdk/anthropic v2.0) * - google/gemini: Gemini models via Google Generative AI API (@ai-sdk/google v2.0) * - openai: OpenAI models via OpenAI API (@ai-sdk/openai) @@ -30,6 +31,7 @@ import { createOpenAICompatible } from '@ai-sdk/openai-compatible' import { createAnthropic } from '@ai-sdk/anthropic' import { invoke } from '@tauri-apps/api/core' import { SessionInfo } from '@janhq/core' +import { fetch } from '@tauri-apps/plugin-http' /** * Llama.cpp timings structure from the response @@ -109,6 +111,9 @@ export class ModelFactory { case 'llamacpp': return this.createLlamaCppModel(modelId, provider) + case 'mlx': + return this.createMlxModel(modelId, provider) + case 'anthropic': return this.createAnthropicModel(modelId, provider) @@ -173,6 +178,59 @@ export class ModelFactory { Origin: 'tauri://localhost', }, includeUsage: true, + fetch: fetch, + }) + + return openAICompatible.languageModel(modelId, { + metadataExtractor: llamaCppMetadataExtractor, + }) + } + + /** + * Create an MLX model by starting the model and finding the running session. + * MLX uses the same OpenAI-compatible API pattern as llamacpp. + */ + private static async createMlxModel( + modelId: string, + provider?: ProviderObject + ): Promise { + // Start the model first if provider is available + if (provider) { + try { + const { useServiceStore } = await import('@/hooks/useServiceHub') + const serviceHub = useServiceStore.getState().serviceHub + + if (serviceHub) { + await serviceHub.models().startModel(provider, modelId) + } + } catch (error) { + console.error('Failed to start MLX model:', error) + throw new Error( + `Failed to start model: ${error instanceof Error ? error.message : JSON.stringify(error)}` + ) + } + } + + // Get session info which includes port and api_key + const sessionInfo = await invoke( + 'plugin:mlx|find_mlx_session_by_model', + { modelId } + ) + + if (!sessionInfo) { + throw new Error(`No running MLX session found for model: ${modelId}`) + } + + // Create OpenAI-compatible client for MLX server + const openAICompatible = createOpenAICompatible({ + name: 'mlx', + baseURL: `http://localhost:${sessionInfo.port}/v1`, + headers: { + Authorization: `Bearer ${sessionInfo.api_key}`, + Origin: 'tauri://localhost', + }, + includeUsage: true, + fetch: fetch, }) return openAICompatible.languageModel(modelId, { diff --git a/web-app/src/lib/utils.ts b/web-app/src/lib/utils.ts index c7eb4af5e..e30409200 100644 --- a/web-app/src/lib/utils.ts +++ b/web-app/src/lib/utils.ts @@ -68,6 +68,8 @@ export function getProviderLogo(provider: string) { return '/images/model-provider/jan.png' case 'llamacpp': return '/images/model-provider/llamacpp.svg' + case 'mlx': + return '/images/model-provider/mlx.png' case 'anthropic': return '/images/model-provider/anthropic.svg' case 'huggingface': @@ -97,6 +99,8 @@ export const getProviderTitle = (provider: string) => { return 'Jan' case 'llamacpp': return 'Llama.cpp' + case 'mlx': + return 'MLX' case 'openai': return 'OpenAI' case 'openrouter': diff --git a/web-app/src/routes/settings/providers/$providerName.tsx b/web-app/src/routes/settings/providers/$providerName.tsx index 5edb4f944..514e0ee94 100644 --- a/web-app/src/routes/settings/providers/$providerName.tsx +++ b/web-app/src/routes/settings/providers/$providerName.tsx @@ -4,17 +4,14 @@ import HeaderPage from '@/containers/HeaderPage' import SettingsMenu from '@/containers/SettingsMenu' import { useModelProvider } from '@/hooks/useModelProvider' import { cn, getProviderTitle, getModelDisplayName } from '@/lib/utils' -import { - createFileRoute, - Link, - useParams, -} from '@tanstack/react-router' +import { createFileRoute, Link, useParams } from '@tanstack/react-router' import { useTranslation } from '@/i18n/react-i18next-compat' import Capabilities from '@/containers/Capabilities' import { DynamicControllerSetting } from '@/containers/dynamicControllerSetting' import { RenderMarkdown } from '@/containers/RenderMarkdown' import { DialogEditModel } from '@/containers/dialogs/EditModel' import { ImportVisionModelDialog } from '@/containers/dialogs/ImportVisionModelDialog' +import { ImportMlxModelDialog } from '@/containers/dialogs/ImportMlxModelDialog' import { ModelSetting } from '@/containers/ModelSetting' import { DialogDeleteModel } from '@/containers/dialogs/DeleteModel' import { FavoriteModelAction } from '@/containers/FavoriteModelAction' @@ -68,9 +65,9 @@ function ProviderDetail() { const { getProviderByName, setProviders, updateProvider } = useModelProvider() const provider = getProviderByName(providerName) - // Check if llamacpp provider needs backend configuration + // Check if llamacpp/mlx provider needs backend configuration const needsBackendConfig = - provider?.provider === 'llamacpp' && + (provider?.provider === 'llamacpp' || provider?.provider === 'mlx') && provider.settings?.some( (setting) => setting.key === 'version_backend' && @@ -144,12 +141,14 @@ function ProviderDetail() { } useEffect(() => { - // Initial data fetch - serviceHub - .models() - .getActiveModels() - .then((models) => setActiveModels(models || [])) - }, [serviceHub, setActiveModels]) + // Initial data fetch - load active models for the current provider + if (provider?.provider) { + serviceHub + .models() + .getActiveModels(provider.provider) + .then((models) => setActiveModels(models || [])) + } + }, [serviceHub, setActiveModels, provider?.provider]) // Clear importing state when model appears in the provider's model list useEffect(() => { @@ -270,10 +269,10 @@ function ProviderDetail() { // Start the model with plan result await serviceHub.models().startModel(provider, modelId) - // Refresh active models after starting + // Refresh active models after starting (pass provider to get correct engine's loaded models) serviceHub .models() - .getActiveModels() + .getActiveModels(provider.provider) .then((models) => setActiveModels(models || [])) } catch (error) { setModelLoadError(error as ErrorObject) @@ -288,12 +287,12 @@ function ProviderDetail() { // Original: stopModel(modelId).then(() => { setActiveModels((prevModels) => prevModels.filter((model) => model !== modelId)) }) serviceHub .models() - .stopModel(modelId) + .stopModel(modelId, provider?.provider) .then(() => { - // Refresh active models after stopping + // Refresh active models after stopping (pass provider to get correct engine's loaded models) serviceHub .models() - .getActiveModels() + .getActiveModels(provider?.provider) .then((models) => setActiveModels(models || [])) }) .catch((error) => { @@ -302,7 +301,8 @@ function ProviderDetail() { } const handleCheckForBackendUpdate = useCallback(async () => { - if (provider?.provider !== 'llamacpp') return + if (provider?.provider !== 'llamacpp' && provider?.provider !== 'mlx') + return setIsCheckingBackendUpdate(true) try { @@ -320,7 +320,8 @@ function ProviderDetail() { }, [provider, checkForBackendUpdate, t]) const handleInstallBackendFromFile = useCallback(async () => { - if (provider?.provider !== 'llamacpp') return + if (provider?.provider !== 'llamacpp' && provider?.provider !== 'mlx') + return setIsInstallingBackend(true) try { @@ -345,8 +346,12 @@ function ProviderDetail() { // Extract filename from the selected file path and replace spaces with dashes const fileName = basenameNoExt(selectedFile).replace(/\s+/g, '-') + // Capitalize provider name for display + const providerDisplayName = + provider?.provider === 'llamacpp' ? 'Llamacpp' : 'MLX' + toast.success(t('settings:backendInstallSuccess'), { - description: `Llamacpp ${fileName} installed`, + description: `${providerDisplayName} ${fileName} installed`, }) // Refresh settings to update backend configuration @@ -367,7 +372,9 @@ function ProviderDetail() {
- {t('common:settings')} + + {t('common:settings')} +
@@ -384,8 +391,9 @@ function ProviderDetail() { className={cn( 'flex flex-col gap-3', provider && - provider.provider === 'llamacpp' && - 'flex-col-reverse' + (provider.provider === 'llamacpp' || + provider.provider === 'mlx') && + 'flex-col-reverse' )} > {/* Settings */} @@ -395,7 +403,7 @@ function ProviderDetail() { const actionComponent = (
{needsBackendConfig && - setting.key === 'version_backend' ? ( + setting.key === 'version_backend' ? (
loading @@ -404,21 +412,18 @@ function ProviderDetail() { { if (provider) { const newSettings = [...provider.settings] - // Handle different value types by forcing the type - // Use type assertion to bypass type checking + // Handle different value types by forcing the type + // Use type assertion to bypass type checking - ; ( - newSettings[settingIndex] - .controller_props as { - value: string | boolean | number - } - ).value = newValue + ;( + newSettings[settingIndex].controller_props as { + value: string | boolean | number + } + ).value = newValue // Create update object with updated settings const updateObj: Partial = { @@ -446,11 +451,11 @@ function ProviderDetail() { ) if (deviceSettingIndex !== -1) { - ( + ;( newSettings[deviceSettingIndex] .controller_props as { - value: string - } + value: string + } ).value = '' } @@ -480,9 +485,7 @@ function ProviderDetail() { serviceHub .models() .getActiveModels() - .then((models) => - setActiveModels(models || []) - ) + .then((models) => setActiveModels(models || [])) } }} /> @@ -497,7 +500,7 @@ function ProviderDetail() { className={cn(setting.key === 'device' && 'hidden')} column={ setting.controller_type === 'input' && - setting.controller_props.type !== 'number' + setting.controller_props.type !== 'number' ? true : false } @@ -535,7 +538,8 @@ function ProviderDetail() {
)} {setting.key === 'version_backend' && - provider?.provider === 'llamacpp' && ( + (provider?.provider === 'llamacpp' || + provider?.provider === 'mlx') && (
+ } + /> + )}
} @@ -676,10 +690,7 @@ function ProviderDetail() { modelId={model.id} /> {model.settings && ( - + )} {((provider && !predefinedProviders.some( @@ -690,8 +701,8 @@ function ProviderDetail() { (p) => p.provider === provider.provider ) && Boolean(provider.api_key?.length))) && ( - - )} + + )} handleStartModel(model.id)} > {loadingModels.includes(model.id) ? ( diff --git a/web-app/src/services/models/default.ts b/web-app/src/services/models/default.ts index 11d167a3a..229b91951 100644 --- a/web-app/src/services/models/default.ts +++ b/web-app/src/services/models/default.ts @@ -282,8 +282,8 @@ export class DefaultModelsService implements ModelsService { } } - async deleteModel(id: string): Promise { - return this.getEngine()?.delete(id) + async deleteModel(id: string, provider?: string): Promise { + return this.getEngine(provider)?.delete(id) } async getActiveModels(provider?: string): Promise { diff --git a/web-app/src/services/models/types.ts b/web-app/src/services/models/types.ts index 6a1b0504e..4932fd52d 100644 --- a/web-app/src/services/models/types.ts +++ b/web-app/src/services/models/types.ts @@ -125,7 +125,7 @@ export interface ModelsService { skipVerification?: boolean ): Promise abortDownload(id: string): Promise - deleteModel(id: string): Promise + deleteModel(id: string, provider?: string): Promise getActiveModels(provider?: string): Promise stopModel(model: string, provider?: string): Promise stopAllModels(): Promise