From 210d73477a4d96ee09cc0acb3a67ce045e1a3290 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 17 Aug 2026 12:31:05 +0200 Subject: [PATCH 1/4] Inject live local models into whichever agent the bot is on. Custom now probes the same hosts the sidecar did (oMLX, Ollama, Unsloth, LM Studio, EXO). Picking one injects it into the selected agent: Grok gets a config.toml slug, Codex keeps provider routing, Claude is pointed at the host via ANTHROPIC_BASE_URL. --- server/drivers/acp/droid.ts | 12 +- server/drivers/acp/grok.ts | 91 ++++++++++++- server/drivers/acp/kimi.ts | 8 +- server/drivers/acp/opencode-go.ts | 5 +- server/drivers/antigravity.ts | 5 +- server/drivers/claude.ts | 13 +- server/drivers/codex-catalog.ts | 13 +- server/drivers/local-inject.test.ts | 76 +++++++++++ server/drivers/local-inject.ts | 193 ++++++++++++++++++++++++++++ src/components/ModelPicker.tsx | 4 +- 10 files changed, 397 insertions(+), 23 deletions(-) create mode 100644 server/drivers/local-inject.test.ts create mode 100644 server/drivers/local-inject.ts diff --git a/server/drivers/acp/droid.ts b/server/drivers/acp/droid.ts index e1285fb332..ed5ba416e7 100644 --- a/server/drivers/acp/droid.ts +++ b/server/drivers/acp/droid.ts @@ -19,6 +19,7 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { injectedApiModel, mergeLocalInject } from "../local-inject.ts"; import { createAcpDriver, type AcpSupport } from "./core.ts"; // FACTORY_HOME_OVERRIDE replaces the HOME the CLI resolves, NOT the data root: @@ -56,12 +57,12 @@ function readSettings(env: Record): FactorySettings return JSON.parse(readFileSync(join(home, ".factory", "settings.json"), "utf8")) as FactorySettings; } -function resolveModels(env: Record) { +async function resolveModels(env: Record) { let settings: FactorySettings; try { settings = readSettings(env); } catch { - return MODELS; // no settings file yet, or unreadable: ship the built-ins + return mergeLocalInject(MODELS, env); // no settings file yet, or unreadable: ship the built-ins } const custom = (settings.customModels ?? []).flatMap((m) => @@ -76,7 +77,10 @@ function resolveModels(env: Record) { const configured = settings.sessionDefaultSettings?.model; const fallback = options[0]?.id ?? MODELS.default; - return { default: configured && options.some((o) => o.id === configured) ? configured : fallback, options }; + return mergeLocalInject( + { default: configured && options.some((o) => o.id === configured) ? configured : fallback, options }, + env, + ); } // droid answers a rejected setting with a bare JSON-RPC message ("Model not @@ -165,7 +169,7 @@ const support: AcpSupport = { // Pin the model for the same reason as the mode: with no set_model the // session runs whatever ~/.factory/settings.json selected, which can be a // `custom:` provider pointing at its own endpoint and key. - const modelId = turn.model || MODELS.default; + const modelId = injectedApiModel(turn.model) ?? turn.model ?? MODELS.default; await applySetting(request, "session/set_model", { sessionId, modelId }, `model "${modelId}"`); }, diff --git a/server/drivers/acp/grok.ts b/server/drivers/acp/grok.ts index ef73517e6f..16a83006b6 100644 --- a/server/drivers/acp/grok.ts +++ b/server/drivers/acp/grok.ts @@ -3,11 +3,12 @@ // (~/.grok/auth.json), NOT the xAI API key (that driver is drivers/grok.ts). // The generic protocol runtime lives in acp/core.ts; this file is only the // per-harness quirks. Verified against grok 1.0.0. -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import type { ModelCatalog } from "../../contracts.ts"; +import { decodeInjectId, localHost, mergeLocalInject } from "../local-inject.ts"; import { createAcpDriver, type AcpSupport } from "./core.ts"; export const STATIC_GROK_MODELS: ModelCatalog = { @@ -95,11 +96,95 @@ export function readGrokModelCatalog(env: Record = p }; } +function suggestGrokSlug(host: string, model: string, taken: Set): string { + let base = `${host}-${model}`.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); + if (!base || !/^[a-z]/.test(base)) base = `m-${base || "model"}`; + let slug = base; + let n = 2; + while (taken.has(slug)) { + slug = `${base}-${n}`; + n += 1; + } + return slug; +} + +function quoteToml(value: string): string { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +/** Write a [model.slug] block so `grok -m` can reach the injected host. */ +export function ensureGrokInjectSlug( + modelId: string, + env: Record = process.env, +): string { + const inject = decodeInjectId(modelId); + if (!inject) return modelId; + const host = localHost(inject.host); + if (!host) return modelId; + + const path = join(grokHome(env), "config.toml"); + let text = ""; + try { + text = readFileSync(path, "utf8"); + } catch { + text = ""; + } + + const taken = new Set(STATIC_GROK_MODELS.options.map((option) => option.id)); + let current: { slug: string; model?: string; baseUrl?: string } | null = null; + const flush = () => { + if (!current) return; + taken.add(current.slug); + if (current.model === inject.model && current.baseUrl === host.baseUrl) { + found = current.slug; + } + current = null; + }; + let found: string | null = null; + for (const line of text.split(/\r?\n/)) { + const stripped = line.trim(); + if (stripped.startsWith("[model.") && stripped.endsWith("]")) { + flush(); + let inner = stripped.slice("[model.".length, -1); + if (inner.startsWith('"') && inner.endsWith('"')) inner = inner.slice(1, -1); + current = { slug: inner }; + continue; + } + if (stripped.startsWith("[")) { + flush(); + continue; + } + if (!current || !stripped.includes("=")) continue; + const eq = stripped.indexOf("="); + const key = stripped.slice(0, eq).trim(); + const value = unquote(stripped.slice(eq + 1)); + if (key === "model") current.model = value; + if (key === "base_url") current.baseUrl = value; + } + flush(); + if (found) return found; + + const slug = suggestGrokSlug(inject.host, inject.model, taken); + const heading = /[^a-z0-9_-]/i.test(slug) ? `[model."${slug}"]` : `[model.${slug}]`; + const block = [ + heading, + `model = ${quoteToml(inject.model)}`, + `base_url = ${quoteToml(host.baseUrl)}`, + `name = ${quoteToml(`${inject.model} (${host.label})`)}`, + `api_backend = "chat_completions"`, + `api_key = ${quoteToml(host.apiKey ?? "local")}`, + "", + ].join("\n"); + const next = text && !text.endsWith("\n") ? `${text}\n\n${block}` : `${text}${text ? "\n" : ""}${block}`; + writeFileSync(path, next); + return slug; +} + const support: AcpSupport = { driverKind: "grokAgent", displayName: "Grok", models: STATIC_GROK_MODELS, - resolveModels: (env) => readGrokModelCatalog(env), + resolveModels: (env) => mergeLocalInject(readGrokModelCatalog(env), env), // Grok's accepted levels vary by model and the CLI validates lazily — a // rejected level only logs and falls back. Offer the intersection shared // by every model in this driver's picker; notably, grok-4.5 rejects xhigh. @@ -126,7 +211,7 @@ const support: AcpSupport = { spawnArgs: (config, turn) => [ "--permission-mode", config.fullAuto ? "bypassPermissions" : "default", - ...(turn.model ? ["-m", turn.model] : []), + ...(turn.model ? ["-m", ensureGrokInjectSlug(turn.model)] : []), // long form on purpose: `--effort` is documented as an alias, and an // alias is the part a CLI is free to rename ...(turn.effort ? ["--reasoning-effort", turn.effort] : []), diff --git a/server/drivers/acp/kimi.ts b/server/drivers/acp/kimi.ts index 9f99e070d6..3a940bf47e 100644 --- a/server/drivers/acp/kimi.ts +++ b/server/drivers/acp/kimi.ts @@ -11,6 +11,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import type { ModelCatalog } from "../../contracts.ts"; +import { injectedApiModel, mergeLocalInject } from "../local-inject.ts"; import { createAcpDriver, type AcpSupport } from "./core.ts"; function credentialsPath(env: Record) { @@ -80,7 +81,7 @@ const support: AcpSupport = { // Aliases from the CLI's own catalog (~/.kimi-code/config.toml // [models."kimi-code/…"] — `kimi provider list` reports the same four). models: STATIC_KIMI_MODELS, - resolveModels: (env) => readKimiModelCatalog(env), + resolveModels: (env) => mergeLocalInject(readKimiModelCatalog(env), env), defaultCli: "kimi", nativeSource: "kimi.acp", loginNote: "Kimi Code CLI is not signed in — run `kimi login` in a terminal", @@ -100,7 +101,10 @@ const support: AcpSupport = { // -m is a global commander option and must precede the `acp` subcommand // (verified against 0.29.1). - spawnArgs: (_config, turn) => [...(turn.model ? ["-m", turn.model] : []), "acp"], + spawnArgs: (_config, turn) => { + const model = injectedApiModel(turn.model) ?? turn.model; + return [...(model ? ["-m", model] : []), "acp"]; + }, // Subscription CLI: a leaked Moonshot/Kimi API key must not flip billing // to pay-as-you-go inside the spawned agent (mirrors claude/grok). diff --git a/server/drivers/acp/opencode-go.ts b/server/drivers/acp/opencode-go.ts index 7c2eeb4172..c8c60c3f53 100644 --- a/server/drivers/acp/opencode-go.ts +++ b/server/drivers/acp/opencode-go.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { createAcpDriver, type AcpSupport } from "./core.ts"; import type { ModelCatalog, ProviderErrorCode } from "../../contracts.ts"; +import { mergeLocalInject } from "../local-inject.ts"; const CATALOG_URL = "https://opencode.ai/zen/go/v1/models"; const STATIC_MODELS: ModelCatalog = { @@ -57,7 +58,9 @@ export async function fetchOpenCodeGoModels(fetcher: typeof fetch = fetch): Prom seen.add(full); options.push({ id: full, label: labelForModel(id), custom: true }); } - const catalog = { default: STATIC_MODELS.default, options } satisfies ModelCatalog; + const catalog = await mergeLocalInject( + { default: STATIC_MODELS.default, options } satisfies ModelCatalog, + ); lastSuccessfulCatalog = catalog; return catalog; } finally { diff --git a/server/drivers/antigravity.ts b/server/drivers/antigravity.ts index ef26b50344..56c10c913e 100644 --- a/server/drivers/antigravity.ts +++ b/server/drivers/antigravity.ts @@ -30,6 +30,7 @@ import type { SendTurnInput, } from "../contracts.ts"; import { newEventId, newId } from "../contracts.ts"; +import { injectedApiModel, mergeLocalInject } from "./local-inject.ts"; import { appendNative } from "./native.ts"; const DRIVER_KIND = "antigravityAgent"; @@ -136,7 +137,7 @@ export const AntigravityDriver: ProviderDriver = { let models = STATIC_ANTIGRAVITY_MODELS; const refreshModels = async () => { try { - const resolved = readAntigravityModelCatalog(catalogEnv); + const resolved = await mergeLocalInject(readAntigravityModelCatalog(catalogEnv), catalogEnv); if (resolved.options.length) models = resolved; } catch { // Keep the last usable catalog when settings.json is unreadable. @@ -239,7 +240,7 @@ export const AntigravityDriver: ProviderDriver = { config.fullAuto ? "--dangerously-skip-permissions" : "--mode", ]; if (!config.fullAuto) args.push("accept-edits"); - if (turn.model) args.push("--model", turn.model); + if (turn.model) args.push("--model", injectedApiModel(turn.model) ?? turn.model); if (resumeCursor) args.push("--conversation", resumeCursor); const env: Record = { ...process.env, PATH: augmentedPath() }; diff --git a/server/drivers/claude.ts b/server/drivers/claude.ts index ac25b75eaa..4743a23f7b 100644 --- a/server/drivers/claude.ts +++ b/server/drivers/claude.ts @@ -30,6 +30,7 @@ import type { } from "../contracts.ts"; import { computerProxyEnv } from "../container-computer.ts"; import { newEventId, newId } from "../contracts.ts"; +import { applyClaudeInject, mergeLocalInject } from "./local-inject.ts"; import { appendNative } from "./native.ts"; /** Whether `claude` has been signed in. @@ -65,11 +66,12 @@ export function claudeSignedIn( * Keeping the probe and turn environments identical prevents setup from * claiming an API-key login that the turn itself would deliberately remove. */ -function claudeEnvironment(): NodeJS.ProcessEnv { +function claudeEnvironment(model?: string | null): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env, PATH: augmentedPath(), NPM_CONFIG_LOGLEVEL: "error" }; - delete env.ANTHROPIC_API_KEY; delete env.CLAUDECODE; delete env.CLAUDE_CODE_ENTRYPOINT; + const applied = applyClaudeInject(env, model); + if (!applied.injected) delete env.ANTHROPIC_API_KEY; return env; } @@ -319,7 +321,7 @@ export const ClaudeDriver: ProviderDriver = { let models = STATIC_CLAUDE_MODELS; const refreshModels = async () => { try { - const resolved = readClaudeModelCatalog(catalogEnv); + const resolved = await mergeLocalInject(readClaudeModelCatalog(catalogEnv), catalogEnv); if (resolved.options.length) models = resolved; } catch { // Keep the last usable catalog when settings.json is unreadable. @@ -360,7 +362,8 @@ export const ClaudeDriver: ProviderDriver = { ]; if (sessionId) args.push("--resume", sessionId); else args.push("--session-id", newSessionId!); - if (turn.model) args.push("--model", turn.model); + const injected = applyClaudeInject({}, turn.model); + if (injected.model) args.push("--model", injected.model); if (turn.effort) args.push("--effort", turn.effort); if (turn.system) args.push("--append-system-prompt", turn.system); @@ -456,7 +459,7 @@ export const ClaudeDriver: ProviderDriver = { args.push("--allowedTools", allowed.join(",")); } - const env = claudeEnvironment(); + const env = claudeEnvironment(turn.model); const child = spawnCli(config.cli, args, { cwd: turn.cwd ?? homedir(), diff --git a/server/drivers/codex-catalog.ts b/server/drivers/codex-catalog.ts index d87ad7034d..c00d6f5b96 100644 --- a/server/drivers/codex-catalog.ts +++ b/server/drivers/codex-catalog.ts @@ -8,6 +8,7 @@ import { homedir } from "node:os"; import { basename, join } from "node:path"; import type { ModelCatalog } from "../contracts.ts"; +import { mergeLocalInject } from "./local-inject.ts"; export const STATIC_CODEX_MODELS: ModelCatalog = { default: "gpt-5.6-sol", @@ -300,8 +301,12 @@ export async function readCodexModelCatalog( ? main.model : null; - return { - default: configured && seen.has(configured) ? configured : STATIC_CODEX_MODELS.default, - options, - }; + return mergeLocalInject( + { + default: configured && seen.has(configured) ? configured : STATIC_CODEX_MODELS.default, + options, + }, + env, + fetchImpl, + ); } diff --git a/server/drivers/local-inject.test.ts b/server/drivers/local-inject.test.ts new file mode 100644 index 0000000000..e7ae91ab25 --- /dev/null +++ b/server/drivers/local-inject.test.ts @@ -0,0 +1,76 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { ensureGrokInjectSlug } from "./acp/grok.ts"; +import { + applyClaudeInject, + decodeInjectId, + encodeInjectId, + mergeLocalInject, +} from "./local-inject.ts"; + +const scratchDirs: string[] = []; + +afterEach(() => { + for (const dir of scratchDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("inject ids", () => { + it("round-trips a host and API id", () => { + expect(decodeInjectId(encodeInjectId("omlx", "GLM-5.2-fp8"))).toEqual({ + host: "omlx", + model: "GLM-5.2-fp8", + }); + }); + + it("rejects official cloud slugs", () => { + expect(decodeInjectId("claude-sonnet-5")).toBeNull(); + expect(decodeInjectId("gpt-5.6-sol")).toBeNull(); + }); +}); + +describe("mergeLocalInject", () => { + it("appends live host models as custom without touching official rows", async () => { + const catalog = await mergeLocalInject( + { default: "claude-sonnet-5", options: [{ id: "claude-sonnet-5", label: "Claude Sonnet 5" }] }, + { VITEST: "true", OPENMAUSBOT_PROBE_LOCAL_INJECT: "1" }, + async (url) => { + if (String(url).includes(":8080")) { + return new Response(JSON.stringify({ data: [{ id: "GLM-5.2-fp8" }, { id: "nomic-embed" }] }), { status: 200 }); + } + return new Response("nope", { status: 500 }); + }, + ); + expect(catalog.options[0]).toEqual({ id: "claude-sonnet-5", label: "Claude Sonnet 5" }); + expect(catalog.options.some((option) => option.id === "omlx::GLM-5.2-fp8" && option.custom)).toBe(true); + expect(catalog.options.some((option) => option.id.includes("nomic"))).toBe(false); + }); +}); + +describe("applyClaudeInject", () => { + it("points Claude at the local host instead of Anthropic", () => { + const env: Record = {}; + const applied = applyClaudeInject(env, "omlx::MiniMax-M3-4bit"); + expect(applied).toEqual({ model: "MiniMax-M3-4bit", injected: true }); + expect(env.ANTHROPIC_BASE_URL).toBe("http://127.0.0.1:8080"); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe("omlx"); + expect(env.ANTHROPIC_MODEL).toBe("MiniMax-M3-4bit"); + }); +}); + +describe("ensureGrokInjectSlug", () => { + it("writes a config block the first time and reuses it after", () => { + const home = mkdtempSync(join(tmpdir(), "omb-grok-inject-")); + scratchDirs.push(home); + mkdirSync(join(home, ".grok"), { recursive: true }); + writeFileSync(join(home, ".grok", "config.toml"), "[cli]\nchannel = \"alpha\"\n"); + const first = ensureGrokInjectSlug("omlx::GLM-5.2-fp8", { HOME: home }); + const again = ensureGrokInjectSlug("omlx::GLM-5.2-fp8", { HOME: home }); + expect(first).toBe(again); + const text = readFileSync(join(home, ".grok", "config.toml"), "utf8"); + expect(text).toContain(`model = "GLM-5.2-fp8"`); + expect(text).toContain(`base_url = "http://127.0.0.1:8080/v1"`); + }); +}); diff --git a/server/drivers/local-inject.ts b/server/drivers/local-inject.ts new file mode 100644 index 0000000000..8d7e9b92bd --- /dev/null +++ b/server/drivers/local-inject.ts @@ -0,0 +1,193 @@ +// Shared local-host inject — the sidecar workflow, inside the picker. +// Probe oMLX / Ollama / EXO / LM Studio / Unsloth, list whatever they +// serve under Custom on every agent, and decode a pick back into a host +// + API id the selected driver can inject. +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +import type { ModelCatalog } from "../contracts.ts"; + +export interface LocalHost { + id: string; + label: string; + baseUrl: string; + apiKey?: string; + apiKeyEnv?: string; +} + +export const LOCAL_HOSTS: LocalHost[] = [ + { id: "omlx", label: "oMLX", baseUrl: "http://127.0.0.1:8080/v1", apiKey: "omlx" }, + { id: "ollama", label: "Ollama", baseUrl: "http://127.0.0.1:11434/v1", apiKey: "ollama" }, + { id: "local_ollama", label: "Ollama", baseUrl: "http://127.0.0.1:11434/v1", apiKey: "ollama" }, + { id: "exo", label: "EXO", baseUrl: "http://127.0.0.1:52415/v1", apiKey: "exo" }, + { id: "lmstudio", label: "LM Studio", baseUrl: "http://127.0.0.1:1234/v1", apiKey: "lm-studio" }, + { id: "unsloth", label: "Unsloth", baseUrl: "http://127.0.0.1:8888/v1", apiKeyEnv: "UNSLOTH_STUDIO_AUTH_TOKEN" }, + { id: "unsloth_api", label: "Unsloth", baseUrl: "http://127.0.0.1:8888/v1", apiKeyEnv: "UNSLOTH_STUDIO_AUTH_TOKEN" }, +]; + +export const INJECT_SEP = "::"; + +const HOST_BY_ID = new Map(LOCAL_HOSTS.map((host) => [host.id, host])); +const MODEL_ID = /^[\w][\w./:+-]*$/; + +export interface InjectedModel { + id: string; + host: string; + model: string; + label: string; +} + +export function encodeInjectId(host: string, model: string): string { + return `${host}${INJECT_SEP}${model}`; +} + +export function decodeInjectId(id: string | null | undefined): { host: string; model: string } | null { + if (!id) return null; + const sep = id.indexOf(INJECT_SEP); + if (sep <= 0) return null; + const host = id.slice(0, sep); + const model = id.slice(sep + INJECT_SEP.length); + if (!HOST_BY_ID.has(host) || !MODEL_ID.test(model)) return null; + return { host, model }; +} + +export function localHost(id: string): LocalHost | undefined { + return HOST_BY_ID.get(id); +} + +export function injectedApiModel(id: string | null | undefined): string | null { + return decodeInjectId(id)?.model ?? null; +} + +/** Anthropic-compatible base (Claude Code wants this without a trailing /v1). */ +export function anthropicBaseUrl(host: LocalHost): string { + return host.baseUrl.replace(/\/v1\/?$/, ""); +} + +export function hostApiKey(host: LocalHost, env: Record = process.env): string { + if (host.apiKeyEnv && env[host.apiKeyEnv]) return env[host.apiKeyEnv]!; + if (host.apiKey) return host.apiKey; + if (host.id === "unsloth" || host.id === "unsloth_api") { + const fromFile = readUnslothKey(env); + if (fromFile) return fromFile; + } + return "local"; +} + +function readUnslothKey(env: Record): string | null { + const home = env.HOME || env.USERPROFILE || homedir(); + try { + const raw = JSON.parse(readFileSync(join(home, ".unsloth", "studio", "auth", "agent_api_key.json"), "utf8")) as { + api_key?: unknown; + }; + return typeof raw.api_key === "string" && raw.api_key ? raw.api_key : null; + } catch { + return null; + } +} + +function idsFromModelsPayload(payload: unknown): string[] { + const records = Array.isArray(payload) + ? payload + : payload && typeof payload === "object" && Array.isArray((payload as { data?: unknown }).data) + ? (payload as { data: unknown[] }).data + : payload && typeof payload === "object" && Array.isArray((payload as { models?: unknown }).models) + ? (payload as { models: unknown[] }).models + : []; + return records.flatMap((record) => { + if (typeof record === "string") return MODEL_ID.test(record) ? [record] : []; + if (!record || typeof record !== "object") return []; + const id = (record as { id?: unknown; name?: unknown }).id ?? (record as { name?: unknown }).name; + if (typeof id !== "string" || !MODEL_ID.test(id)) return []; + const low = id.toLowerCase(); + if (low.includes("embed") || low.includes("bge-") || low.includes("nomic")) return []; + return [id]; + }); +} + +async function probeHost( + host: LocalHost, + env: Record, + fetchImpl: typeof fetch, +): Promise { + const url = `${host.baseUrl.replace(/\/$/, "")}/models`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 1200); + timer.unref?.(); + try { + const response = await fetchImpl(url, { + signal: controller.signal, + headers: { Authorization: `Bearer ${hostApiKey(host, env)}` }, + }); + if (!response.ok) return []; + return idsFromModelsPayload(await response.json()); + } catch { + return []; + } finally { + clearTimeout(timer); + } +} + +/** Live models from the same local hosts the sidecar probed. */ +export async function probeLocalInjects( + env: Record = process.env, + fetchImpl: typeof fetch = fetch, +): Promise { + const seenHosts = new Set(); + const hosts = LOCAL_HOSTS.filter((host) => { + const key = host.baseUrl.replace(/\/$/, ""); + if (seenHosts.has(key)) return false; + seenHosts.add(key); + return true; + }); + const found: InjectedModel[] = []; + const pages = await Promise.all(hosts.map(async (host) => ({ host, ids: await probeHost(host, env, fetchImpl) }))); + for (const { host, ids } of pages) { + for (const model of ids) { + found.push({ + id: encodeInjectId(host.id, model), + host: host.id, + model, + label: `${model} (${host.label})`, + }); + } + } + return found; +} + +/** Append live local models as custom rows. Official rows stay first. */ +export async function mergeLocalInject( + catalog: ModelCatalog, + env: Record = process.env, + fetchImpl: typeof fetch = fetch, +): Promise { + if (env.VITEST === "true" && env.OPENMAUSBOT_PROBE_LOCAL_INJECT !== "1") return catalog; + const extras = await probeLocalInjects(env, fetchImpl); + if (!extras.length) return catalog; + const options = catalog.options.map((option) => ({ ...option })); + const seen = new Set(options.map((option) => option.id)); + for (const extra of extras) { + if (seen.has(extra.id)) continue; + seen.add(extra.id); + options.push({ id: extra.id, label: extra.label, custom: true }); + } + return { default: catalog.default, options }; +} + +/** Point Claude Code at the injected host instead of Anthropic cloud. */ +export function applyClaudeInject( + env: Record, + modelId: string | null | undefined, +): { model: string | null; injected: boolean } { + const inject = decodeInjectId(modelId); + if (!inject) return { model: modelId ?? null, injected: false }; + const host = localHost(inject.host); + if (!host) return { model: modelId ?? null, injected: false }; + const key = hostApiKey(host, env); + env.ANTHROPIC_BASE_URL = anthropicBaseUrl(host); + env.ANTHROPIC_AUTH_TOKEN = key; + env.ANTHROPIC_API_KEY = key; + env.ANTHROPIC_MODEL = inject.model; + return { model: inject.model, injected: true }; +} diff --git a/src/components/ModelPicker.tsx b/src/components/ModelPicker.tsx index 59fc7c6322..b0eae7c341 100644 --- a/src/components/ModelPicker.tsx +++ b/src/components/ModelPicker.tsx @@ -132,7 +132,7 @@ export function ModelPicker({ bot, className }: { bot: Bot; className?: string }
{pane === "custom" - ? "Models this agent already knows" + ? "Inject a local model into this agent" : (railInstance.snapshot.version ?? (railInstance.snapshot.state === "available" ? "ready" @@ -200,7 +200,7 @@ export function ModelPicker({ bot, className }: { bot: Bot; className?: string } {rows.map(row)} {pane === "custom" && custom.length === 0 && (
- No extra models on this machine yet + Start oMLX, Ollama, Unsloth, LM Studio, or EXO — live models show up here
)}
From 170007082bc066aa84920b23611d3f05df9e9f08 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 17 Aug 2026 12:43:54 +0200 Subject: [PATCH 2/4] Show live Custom models even when the packaged app cannot find the CLI. Claude was `unavailable` because ~/.npm-global/bin was not on the Finder PATH, and that greying plus the install card hid the oMLX list. Custom rows stay pickable; EngineSetup stays on the official pane only. --- server/env-path.test.ts | 7 +++++++ server/env-path.ts | 3 +++ src/components/ModelPicker.tsx | 13 +++++++------ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/server/env-path.test.ts b/server/env-path.test.ts index 4b0aee662f..c417c7e239 100644 --- a/server/env-path.test.ts +++ b/server/env-path.test.ts @@ -50,6 +50,13 @@ describe("augmentedPath", () => { expect(v24).toBeLessThan(v9); }); + posixIt("includes a user npm prefix at ~/.npm-global/bin", () => { + const npmGlobal = join(homedir(), ".npm-global", "bin"); + mkdirSync(npmGlobal, { recursive: true }); + resetPathCacheForTests(); + expect(augmentedPath().split(delimiter)).toContain(npmGlobal); + }); + posixIt("makes a CLI in a known install dir spawnable despite a bare PATH", async () => { const bin = join(homedir(), ".local", "bin"); mkdirSync(bin, { recursive: true }); diff --git a/server/env-path.ts b/server/env-path.ts index 371864e569..c9b5cceaf4 100644 --- a/server/env-path.ts +++ b/server/env-path.ts @@ -32,6 +32,9 @@ function knownDirs(): string[] { const home = homedir(); return [ join(home, ".local", "bin"), // claude installer default + join(home, ".npm-global", "bin"), // npm prefix ~/.npm-global (claude, opencode) + join(home, ".kimi-code", "bin"), // kimi-code installer + join(home, ".grok", "bin"), // x.ai installer join(home, ".claude", "local"), // claude "local install" "/opt/homebrew/bin", // brew, Apple silicon "/usr/local/bin", // brew Intel / classic installs diff --git a/src/components/ModelPicker.tsx b/src/components/ModelPicker.tsx index b0eae7c341..74390f3916 100644 --- a/src/components/ModelPicker.tsx +++ b/src/components/ModelPicker.tsx @@ -139,10 +139,11 @@ export function ModelPicker({ bot, className }: { bot: Bot; className?: string } : (railInstance.snapshot.reason ?? "ready")))} - {/* Keep cloud sign-in guidance on the official pane, while - leaving Custom reachable for locally configured models. */} - {(railInstance.snapshot.state !== "available" || - (pane === "main" && needsSignIn(railInstance))) && ( + {/* Official-pane setup only. Custom is the inject list and + must stay visible even when the cloud CLI is unsigned + or the packaged app has not found it on PATH yet. */} + {pane === "main" && + (railInstance.snapshot.state !== "available" || needsSignIn(railInstance)) && (
@@ -159,8 +160,8 @@ export function ModelPicker({ bot, className }: { bot: Bot; className?: string } const current = selection.instanceId === railInstance.instanceId && selection.model === option.id; const disabled = - railInstance.snapshot.state !== "available" || - (!option.custom && needsSignIn(railInstance)); + !option.custom && + (railInstance.snapshot.state !== "available" || needsSignIn(railInstance)); return (