Skip to content

Commit 105db65

Browse files
committed
Add MLX (Apple Silicon) and ROCm (AMD) inference backends
node-llama-cpp only ships CUDA/Vulkan/Metal prebuilds, so these two run as external server processes the app manages: MLX via Python's mlx_lm.server, and ROCm via a user-supplied HIP build of llama.cpp's llama-server (official releases ship one) against the same GGUF dir as the built-in llama.cpp backend. Both expose the OpenAI-compatible API, so chat traffic reuses the existing openai-compatible client — no per-backend protocol code. The manager pins one port per backend, restarts on model switch, reuses a healthy server across turns, and is torn down on app quit. New "mlx"/"rocm" provider ids appear in the model picker (MLX models and the ROCm group once configured in Settings), and running servers show in the activity view.
1 parent 8357fee commit 105db65

10 files changed

Lines changed: 397 additions & 6 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, it, expect } from "vitest";
2+
import { buildServerCommand, describeSpawnFailure } from "./local-server-manager";
3+
4+
describe("buildServerCommand", () => {
5+
it("builds an mlx_lm.server invocation with the default python", () => {
6+
const { command, args } = buildServerCommand("mlx", "mlx-community/Llama-3.2-3B-Instruct-4bit", {});
7+
expect(command).toBe("python3");
8+
expect(args).toContain("mlx_lm.server");
9+
expect(args).toContain("mlx-community/Llama-3.2-3B-Instruct-4bit");
10+
expect(args).toContain("--host");
11+
expect(args).toContain("127.0.0.1");
12+
});
13+
14+
it("respects a custom python interpreter path", () => {
15+
const { command } = buildServerCommand("mlx", "some/model", { mlxPythonPath: "/opt/python/bin/python3.12" });
16+
expect(command).toBe("/opt/python/bin/python3.12");
17+
});
18+
19+
it("builds a llama-server invocation with full GPU offload for rocm", () => {
20+
const { command, args } = buildServerCommand("rocm", "/models/llama.gguf", {
21+
rocmServerPath: "/opt/rocm-llama/llama-server",
22+
});
23+
expect(command).toBe("/opt/rocm-llama/llama-server");
24+
expect(args).toEqual(
25+
expect.arrayContaining(["-m", "/models/llama.gguf", "--n-gpu-layers", "999"])
26+
);
27+
});
28+
29+
it("falls back to PATH lookup when no rocm binary path is configured", () => {
30+
const { command } = buildServerCommand("rocm", "/models/llama.gguf", {});
31+
expect(command).toBe("llama-server");
32+
});
33+
34+
it("uses distinct fixed ports per backend so both can run at once", () => {
35+
const mlx = buildServerCommand("mlx", "m", {});
36+
const rocm = buildServerCommand("rocm", "m", {});
37+
const portOf = (args: string[]) => args[args.indexOf("--port") + 1];
38+
expect(portOf(mlx.args)).not.toBe(portOf(rocm.args));
39+
});
40+
});
41+
42+
describe("describeSpawnFailure", () => {
43+
it("points mlx failures at the mlx-lm install", () => {
44+
expect(describeSpawnFailure("mlx")).toMatch(/mlx-lm/);
45+
});
46+
47+
it("points rocm failures at the llama-server binary setting", () => {
48+
expect(describeSpawnFailure("rocm")).toMatch(/llama-server/);
49+
});
50+
});

app/src/local-server-manager.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { spawn, ChildProcess } from "node:child_process";
2+
import { logger } from "./logger";
3+
4+
// Inference backends node-llama-cpp can't serve (it only ships CUDA/Vulkan/
5+
// Metal prebuilds) are run as external server processes that expose the
6+
// OpenAI-compatible chat-completions API, and chat traffic goes through the
7+
// same openai-compatible client the cloud providers use:
8+
//
9+
// - "mlx": Apple-Silicon inference via Python's mlx_lm package
10+
// (`python3 -m mlx_lm.server`). Models are Hugging Face repo ids or local
11+
// paths; the server downloads/loads them itself.
12+
// - "rocm": AMD-GPU inference via a ROCm/HIP build of llama.cpp's
13+
// `llama-server` binary (the official llama.cpp releases ship one), run
14+
// against the same GGUF files the built-in llama.cpp backend uses.
15+
export type LocalBackendId = "mlx" | "rocm";
16+
17+
export interface LocalBackendConfig {
18+
// Path to the ROCm llama-server binary. No sensible default beyond PATH
19+
// lookup — the user downloads a HIP build themselves.
20+
rocmServerPath?: string;
21+
// Python interpreter used to launch mlx_lm.server (needs `pip install mlx-lm`).
22+
mlxPythonPath?: string;
23+
}
24+
25+
interface RunningServer {
26+
process: ChildProcess;
27+
model: string;
28+
baseUrl: string;
29+
exited: boolean;
30+
}
31+
32+
// Fixed per-backend ports so a restarted app reconnects rather than leaking
33+
// orphan servers across random ports.
34+
const PORTS: Record<LocalBackendId, number> = { mlx: 8790, rocm: 8791 };
35+
// First startup can include downloading/loading a multi-GB model.
36+
const STARTUP_TIMEOUT_MS = 180_000;
37+
const HEALTH_POLL_MS = 750;
38+
39+
const servers = new Map<LocalBackendId, RunningServer>();
40+
41+
export function buildServerCommand(
42+
backend: LocalBackendId,
43+
model: string,
44+
config: LocalBackendConfig
45+
): { command: string; args: string[] } {
46+
const port = PORTS[backend];
47+
if (backend === "mlx") {
48+
return {
49+
command: config.mlxPythonPath?.trim() || "python3",
50+
args: ["-m", "mlx_lm.server", "--model", model, "--port", String(port), "--host", "127.0.0.1"],
51+
};
52+
}
53+
return {
54+
command: config.rocmServerPath?.trim() || "llama-server",
55+
args: [
56+
"-m", model,
57+
"--port", String(port),
58+
"--host", "127.0.0.1",
59+
// Offload everything; llama-server clamps to what actually fits.
60+
"--n-gpu-layers", "999",
61+
],
62+
};
63+
}
64+
65+
export function describeSpawnFailure(backend: LocalBackendId): string {
66+
return backend === "mlx"
67+
? "Couldn't launch the MLX server — it needs Python with the mlx-lm package (pip install mlx-lm), available on Apple Silicon Macs."
68+
: "Couldn't launch llama-server — set the path to a ROCm (HIP) build of llama.cpp's llama-server binary in Settings.";
69+
}
70+
71+
// Any HTTP response means the server socket is up (a 404 from a route probe
72+
// is still proof of life); only a network-level failure counts as down.
73+
async function isReachable(baseUrl: string): Promise<boolean> {
74+
try {
75+
await fetch(`${baseUrl}/v1/models`, { signal: AbortSignal.timeout(2000) });
76+
return true;
77+
} catch {
78+
return false;
79+
}
80+
}
81+
82+
function sleep(ms: number): Promise<void> {
83+
return new Promise((resolve) => setTimeout(resolve, ms));
84+
}
85+
86+
export async function ensureServer(
87+
backend: LocalBackendId,
88+
model: string,
89+
config: LocalBackendConfig
90+
): Promise<string> {
91+
const existing = servers.get(backend);
92+
if (existing && !existing.exited && existing.model === model) {
93+
if (await isReachable(existing.baseUrl)) return existing.baseUrl;
94+
// Process alive but unresponsive — restart it below.
95+
}
96+
if (existing) {
97+
existing.process.kill();
98+
servers.delete(backend);
99+
}
100+
101+
const baseUrl = `http://127.0.0.1:${PORTS[backend]}`;
102+
const { command, args } = buildServerCommand(backend, model, config);
103+
logger.info(`Starting ${backend} server: ${command} ${args.join(" ")}`);
104+
105+
let child: ChildProcess;
106+
try {
107+
child = spawn(command, args, { stdio: "ignore" });
108+
} catch {
109+
throw new Error(describeSpawnFailure(backend));
110+
}
111+
112+
const entry: RunningServer = { process: child, model, baseUrl, exited: false };
113+
servers.set(backend, entry);
114+
115+
let spawnError: string | null = null;
116+
child.on("error", (err) => {
117+
spawnError = (err as NodeJS.ErrnoException).code === "ENOENT" ? describeSpawnFailure(backend) : err.message;
118+
entry.exited = true;
119+
});
120+
child.on("exit", (code) => {
121+
entry.exited = true;
122+
if (code !== 0 && code !== null) logger.warn(`${backend} server exited with code ${code}`);
123+
});
124+
125+
const deadline = Date.now() + STARTUP_TIMEOUT_MS;
126+
while (Date.now() < deadline) {
127+
if (spawnError) throw new Error(spawnError);
128+
if (entry.exited) {
129+
servers.delete(backend);
130+
throw new Error(
131+
backend === "mlx"
132+
? "The MLX server exited during startup — check that mlx-lm is installed and the model id is valid."
133+
: "llama-server exited during startup — check that the binary is a working ROCm build and the model file is a valid GGUF."
134+
);
135+
}
136+
if (await isReachable(baseUrl)) return baseUrl;
137+
await sleep(HEALTH_POLL_MS);
138+
}
139+
child.kill();
140+
servers.delete(backend);
141+
throw new Error(`The ${backend} server didn't become reachable within ${STARTUP_TIMEOUT_MS / 1000}s.`);
142+
}
143+
144+
export function stopServer(backend: LocalBackendId): void {
145+
const entry = servers.get(backend);
146+
if (entry) {
147+
entry.process.kill();
148+
servers.delete(backend);
149+
}
150+
}
151+
152+
export function stopAll(): void {
153+
for (const backend of [...servers.keys()]) stopServer(backend);
154+
}
155+
156+
export function getRunningBackends(): { backend: LocalBackendId; model: string }[] {
157+
return [...servers.entries()]
158+
.filter(([, s]) => !s.exited)
159+
.map(([backend, s]) => ({ backend, model: s.model }));
160+
}

app/src/main.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import * as huggingface from "./huggingface";
2020
import * as llamacpp from "./llamacpp-manager";
2121
import * as scheduledTasksStore from "./scheduled-tasks-store";
2222
import * as scheduler from "./scheduler";
23+
import * as localServers from "./local-server-manager";
2324
import type { McpServerConfig } from "./mcp-client";
2425
import type { AttachedFile } from "./file-reader";
2526
import * as openaiProvider from "./providers/openai";
@@ -30,7 +31,7 @@ import { setupMenu } from "./menu";
3031
import { setupAutoUpdater, checkForUpdatesManually } from "./updater";
3132
import type { ChatMessage, ChatChunk, ChatOptions, ProviderId, ToolDefinition } from "./providers/types";
3233

33-
const PROVIDER_SECRET_KEYS: Record<Exclude<ProviderId, "ollama" | "llamacpp" | "custom">, string> = {
34+
const PROVIDER_SECRET_KEYS: Record<Exclude<ProviderId, "ollama" | "llamacpp" | "custom" | "mlx" | "rocm">, string> = {
3435
openai: "openai_api_key",
3536
anthropic: "anthropic_api_key",
3637
gemini: "gemini_api_key",
@@ -184,6 +185,35 @@ async function dispatchChat(
184185
} else if (provider === "llamacpp") {
185186
const modelPath = path.join(getLlamaCppModelsDir(), model);
186187
await llamacpp.chat(modelPath, messages, options, onToken, signal, tools);
188+
} else if (provider === "mlx" || provider === "rocm") {
189+
const settings = settingsStore.getSettings();
190+
// ROCm serves the same GGUF files as the llama.cpp backend, so the
191+
// model ref is a filename that must stay inside the models dir; MLX
192+
// models are HF repo ids the server resolves itself.
193+
let serverModel = model;
194+
if (provider === "rocm") {
195+
const root = path.resolve(getLlamaCppModelsDir());
196+
const resolved = path.resolve(root, model);
197+
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
198+
throw new Error(`Model file "${model}" is outside the models directory.`);
199+
}
200+
serverModel = resolved;
201+
}
202+
const baseUrl = await localServers.ensureServer(provider, serverModel, {
203+
mlxPythonPath: settings.mlxPythonPath,
204+
rocmServerPath: settings.rocmServerPath,
205+
});
206+
// These servers are local and unauthenticated — the "api key" is a
207+
// placeholder the OpenAI-compatible client requires but they ignore.
208+
await createOpenAiCompatibleChat(`${baseUrl}/v1`, provider === "mlx" ? "MLX" : "ROCm llama-server")(
209+
"local",
210+
model,
211+
messages,
212+
options,
213+
onToken,
214+
signal,
215+
tools
216+
);
187217
} else if (provider === "custom") {
188218
// model is "<customProviderId>::<actual model id>" — see
189219
// frontend/src/lib/providers.ts's formatCustomModelRef.
@@ -354,6 +384,7 @@ function registerIpcHandlers(): void {
354384
ollamaRunning,
355385
ollamaLoadedModels,
356386
llamacppLoadedModels: llamacpp.listLoadedModels(),
387+
localBackendServers: localServers.getRunningBackends(),
357388
mcpServers: mcpClient.getServerStatuses(),
358389
memory: { rssMB: +(mem.rss / 1e6).toFixed(1), heapUsedMB: +(mem.heapUsed / 1e6).toFixed(1) },
359390
};
@@ -720,10 +751,12 @@ app.whenReady().then(async () => {
720751

721752
app.on("window-all-closed", () => {
722753
ollama.stop();
754+
localServers.stopAll();
723755
mcpClient.disconnectAll();
724756
if (process.platform !== "darwin") app.quit();
725757
});
726758

727759
app.on("before-quit", () => {
728760
ollama.stop();
761+
localServers.stopAll();
729762
});

app/src/providers/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export interface ChatChunk {
5050
toolCalls?: ToolCall[];
5151
}
5252

53-
export type ProviderId = "ollama" | "openai" | "anthropic" | "llamacpp" | "gemini" | "custom";
53+
export type ProviderId = "ollama" | "openai" | "anthropic" | "llamacpp" | "gemini" | "custom" | "mlx" | "rocm";
5454

5555
export interface ChatOptions {
5656
temperature?: number;

app/src/settings-store.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,15 @@ export interface AppSettings {
7979
// in JS (command palette, shortcuts dialog) — this store doesn't
8080
// distinguish the two, it just persists whatever the renderer sends.
8181
keybindings?: Record<string, string>;
82+
// MLX backend (Apple Silicon): Hugging Face repo ids (e.g.
83+
// "mlx-community/Llama-3.2-3B-Instruct-4bit") or local paths served via
84+
// `python -m mlx_lm.server`.
85+
mlxModels?: string[];
86+
// Python interpreter used to launch mlx_lm.server. Default: python3.
87+
mlxPythonPath?: string;
88+
// Path to a ROCm/HIP build of llama.cpp's llama-server binary — enables
89+
// the "rocm" provider against the same GGUF dir as the llama.cpp backend.
90+
rocmServerPath?: string;
8291
}
8392

8493
const DEFAULTS: AppSettings = {

frontend/src/lib/providers.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,13 @@ export const PROVIDER_LABELS: Record<ProviderId, string> = {
1212
llamacpp: "llama.cpp (local)",
1313
gemini: "Gemini",
1414
custom: "Custom",
15+
mlx: "MLX (Apple Silicon)",
16+
rocm: "ROCm (AMD)",
1517
};
1618

19+
// Providers that run models on this machine — no API key, no per-token cost.
20+
export const LOCAL_PROVIDERS: ProviderId[] = ["ollama", "llamacpp", "mlx", "rocm"];
21+
1722
// Curated as of this app's last update — model lineups change often, so the
1823
// model picker also lets you type a custom model ID directly.
1924
export const OPENAI_MODELS: CuratedModel[] = [
@@ -76,7 +81,7 @@ export function parseCustomModelId(modelId: string): { customProviderId: string;
7681
return { customProviderId: modelId.slice(0, sep), actualModel: modelId.slice(sep + 2) };
7782
}
7883

79-
const VALID_PROVIDERS: ProviderId[] = ["ollama", "openai", "anthropic", "llamacpp", "gemini", "custom"];
84+
const VALID_PROVIDERS: ProviderId[] = ["ollama", "openai", "anthropic", "llamacpp", "gemini", "custom", "mlx", "rocm"];
8085

8186
export function parseModelRef(ref: string): { provider: ProviderId; modelId: string } | null {
8287
const sepIndex = ref.indexOf(":");

frontend/src/lib/translations.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,15 @@ export interface Dictionary {
164164
gpuAmdRocmNote: string;
165165
gpuIntelVulkanNote: string;
166166
gpuNoneDetectedNote: string;
167+
otherBackendsSection: string;
168+
otherBackendsHint: string;
169+
rocmServerPathLabel: string;
170+
rocmServerPathHint: string;
171+
rocmPathSaved: string;
172+
rocmPathCleared: string;
173+
mlxModelsLabel: string;
174+
mlxModelsHint: string;
175+
mlxModelAdded: string;
167176
llamaCppNoModels: string;
168177
huggingFaceResults: string;
169178
huggingFaceResultsHint: string;
@@ -465,6 +474,15 @@ export const en: Dictionary = {
465474
"AMD GPUs are accelerated through Vulkan here. For native ROCm acceleration, run your models through the Ollama backend instead — it supports ROCm directly.",
466475
gpuIntelVulkanNote: "Intel GPUs (Arc and integrated) are accelerated through Vulkan.",
467476
gpuNoneDetectedNote: "No GPU detected — inference will run on the CPU.",
477+
otherBackendsSection: "Other GPU backends (ROCm & MLX)",
478+
otherBackendsHint: "Backends the built-in llama.cpp engine can't serve — run as local server processes the app manages for you.",
479+
rocmServerPathLabel: "ROCm llama-server binary",
480+
rocmServerPathHint: "Path to a ROCm (HIP) build of llama.cpp's llama-server — official llama.cpp releases ship one for AMD GPUs. Once set, your GGUF models appear under a ROCm group in the model picker.",
481+
rocmPathSaved: "ROCm server path saved",
482+
rocmPathCleared: "ROCm server path cleared",
483+
mlxModelsLabel: "MLX models (Apple Silicon)",
484+
mlxModelsHint: "Hugging Face model ids served via Python's mlx-lm (pip install mlx-lm). The app launches and manages the MLX server itself.",
485+
mlxModelAdded: "MLX model added",
468486
llamaCppNoModels: "No GGUF models downloaded yet — search Hugging Face below and choose \"Download for llama.cpp\".",
469487
huggingFaceResults: "Hugging Face results",
470488
huggingFaceResultsHint: "Real search results from huggingface.co — expand a model to see its GGUF files.",
@@ -774,6 +792,15 @@ export const tr: Dictionary = {
774792
"AMD GPU'lar burada Vulkan üzerinden hızlandırılır. Yerel ROCm hızlandırması için modellerinizi ROCm'u doğrudan destekleyen Ollama arka ucu üzerinden çalıştırın.",
775793
gpuIntelVulkanNote: "Intel GPU'lar (Arc ve tümleşik) Vulkan üzerinden hızlandırılır.",
776794
gpuNoneDetectedNote: "GPU algılanmadı — çıkarım CPU üzerinde çalışacak.",
795+
otherBackendsSection: "Diğer GPU arka uçları (ROCm ve MLX)",
796+
otherBackendsHint: "Yerleşik llama.cpp motorunun sunamadığı arka uçlar — uygulamanın sizin için yönettiği yerel sunucu süreçleri olarak çalışır.",
797+
rocmServerPathLabel: "ROCm llama-server ikili dosyası",
798+
rocmServerPathHint: "llama.cpp'nin ROCm (HIP) derlemesi llama-server yolu — resmi llama.cpp sürümleri AMD GPU'lar için bir tane içerir. Ayarlandığında GGUF modelleriniz model seçicide ROCm grubu altında görünür.",
799+
rocmPathSaved: "ROCm sunucu yolu kaydedildi",
800+
rocmPathCleared: "ROCm sunucu yolu temizlendi",
801+
mlxModelsLabel: "MLX modelleri (Apple Silicon)",
802+
mlxModelsHint: "Python'un mlx-lm paketi ile sunulan Hugging Face model kimlikleri (pip install mlx-lm). Uygulama MLX sunucusunu kendisi başlatır ve yönetir.",
803+
mlxModelAdded: "MLX modeli eklendi",
777804
llamaCppNoModels: "Henüz indirilmiş GGUF modeli yok — aşağıdan Hugging Face'te arayın ve \"llama.cpp için indir\"i seçin.",
778805
huggingFaceResults: "Hugging Face sonuçları",
779806
huggingFaceResultsHint: "huggingface.co'dan gerçek arama sonuçları — GGUF dosyalarını görmek için bir modeli genişletin.",

0 commit comments

Comments
 (0)