From 727bff15e7070c1f36bf10defb6423fc812b58f7 Mon Sep 17 00:00:00 2001 From: gus <42593099+lightcloud00@users.noreply.github.com> Date: Sat, 22 Aug 2026 04:16:21 -0400 Subject: [PATCH 1/3] feat(models): project fleet catalog into OpenMausBot --- server/config.test.ts | 16 +- server/config.ts | 12 + server/contracts.ts | 49 ++- server/drivers/acp/hermes.test.ts | 16 + server/drivers/acp/hermes.ts | 11 +- server/drivers/builtIn.ts | 2 + server/drivers/local.test.ts | 88 +++++ server/drivers/local.ts | 343 ++++++++++++++++++ server/fleet-model-catalog.test.ts | 512 +++++++++++++++++++++++++++ server/fleet-model-catalog.ts | 546 +++++++++++++++++++++++++++++ server/harness/registry.ts | 10 +- server/index.test.ts | 128 +++++++ server/index.ts | 145 +++++++- server/tasks.test.ts | 19 + src/components/ModelPicker.tsx | 102 ++++-- src/lib/custom-models.test.ts | 12 + src/lib/custom-models.ts | 28 +- src/lib/model-catalog.test.ts | 44 +++ src/lib/model-catalog.ts | 51 +++ src/state/store.test.ts | 31 ++ src/state/store.tsx | 88 ++++- 21 files changed, 2180 insertions(+), 73 deletions(-) create mode 100644 server/drivers/acp/hermes.test.ts create mode 100644 server/drivers/local.test.ts create mode 100644 server/drivers/local.ts create mode 100644 server/fleet-model-catalog.test.ts create mode 100644 server/fleet-model-catalog.ts create mode 100644 src/lib/model-catalog.test.ts create mode 100644 src/lib/model-catalog.ts diff --git a/server/config.test.ts b/server/config.test.ts index 0d40215d9..5c7b12e4e 100644 --- a/server/config.test.ts +++ b/server/config.test.ts @@ -89,10 +89,22 @@ describe("configuration boundaries", () => { }); describe("default fleet", () => { - it("ships Qwen and Hermes as custom-only engines", () => { + it("ships Qwen, Hermes, and direct Mac/Windows models as custom-only engines", () => { const map = instanceConfigs({}); expect(map.qwen).toEqual({ driver: "qwenAgent", environment: {} }); expect(map.hermes).toEqual({ driver: "hermesAgent", environment: {} }); + expect(map.localMac).toEqual({ + driver: "local", + displayName: "Mac M5 models", + config: { host: "ollama", fleetHost: "mac" }, + environment: {}, + }); + expect(map.localWindows).toEqual({ + driver: "local", + displayName: "Windows models", + config: { host: "custom", url: "http://127.0.0.1:18134/v1", fleetHost: "windows" }, + environment: {}, + }); }); it("ships Cursor as a default-fleet subscription engine", () => { @@ -105,6 +117,8 @@ describe("default fleet", () => { expect(map.claude.driver).toBe("claudeAgent"); expect(map.qwen?.driver).toBe("qwenAgent"); expect(map.hermes?.driver).toBe("hermesAgent"); + expect(map.localMac?.driver).toBe("local"); + expect(map.localWindows?.driver).toBe("local"); expect(map.cursor?.driver).toBe("cursorAgent"); }); diff --git a/server/config.ts b/server/config.ts index d20beddcf..2595ae274 100644 --- a/server/config.ts +++ b/server/config.ts @@ -381,11 +381,23 @@ export function instanceConfigs(cfg: AppConfig): InstanceConfigMap { qwen: { driver: "qwenAgent" }, hermes: { driver: "hermesAgent" }, pi: { driver: "piAgent" }, + localMac: { driver: "local", displayName: "Mac M5 models", config: { host: "ollama", fleetHost: "mac" } }, + localWindows: { + driver: "local", + displayName: "Windows models", + config: { host: "custom", url: "http://127.0.0.1:18134/v1", fleetHost: "windows" }, + }, }; const CUSTOM_ONLY = { qwen: { driver: "qwenAgent" }, hermes: { driver: "hermesAgent" }, pi: { driver: "piAgent" }, + localMac: { driver: "local", displayName: "Mac M5 models", config: { host: "ollama", fleetHost: "mac" } }, + localWindows: { + driver: "local", + displayName: "Windows models", + config: { host: "custom", url: "http://127.0.0.1:18134/v1", fleetHost: "windows" }, + }, } as const; // New default-fleet engines that existing product configs would otherwise // never see. Custom-only engines stay in CUSTOM_ONLY so a one-off test map diff --git a/server/contracts.ts b/server/contracts.ts index 63a051fa2..1b3a5e94d 100644 --- a/server/contracts.ts +++ b/server/contracts.ts @@ -290,18 +290,47 @@ export interface EngineInstall { // `create` owns ALL per-instance state; two create calls share nothing. // Failures must reject, never throw synchronously — the registry downgrades // a rejection to an unavailable shadow snapshot. +export type ModelCostClass = "free" | "paid" | "paid_subscription" | "paid_metered" | "local" | "unknown"; + +export interface ModelRuntimeStatus { + configured: boolean; + reachable: boolean; + verified: boolean; + admitted: boolean; + busy: boolean; +} + +export interface ModelOption { + /** The model id understood by this concrete OpenMausBot driver. */ + id: string; + label: string; + custom?: boolean; + loaded?: boolean; + /** Fleet-wide stable id. Present only for rows projected by the guarded + * secret-free AOS model catalog. */ + canonicalId?: string; + provider?: string; + host?: string; + costClass?: ModelCostClass; + manualOnly?: boolean; + isDefault?: boolean; + capabilities?: string[]; + status?: ModelRuntimeStatus; + /** False means the row stays visible for inventory/truth, but cannot be + * selected until a fresh catalog refresh marks it admitted and idle. */ + selectable?: boolean; + reason?: string; + lastVerified?: string; + verificationReceipt?: string; + /** total context window in tokens, when the driver knows it — sizes + * the model-facing rebuild (server/context-rebuild.ts). Unknown falls + * back to a pattern table over the model id, then a conservative default. */ + contextWindow?: number; +} + export interface ModelCatalog { default: string; - options: Array<{ - id: string; - label: string; - custom?: boolean; - loaded?: boolean; - /** total context window in tokens, when the driver knows it — sizes - * the model-facing rebuild (server/context-rebuild.ts). Unknown falls - * back to a pattern table over the model id, then a conservative default. */ - contextWindow?: number; - }>; + options: ModelOption[]; } export interface DriverCreateInput { diff --git a/server/drivers/acp/hermes.test.ts b/server/drivers/acp/hermes.test.ts new file mode 100644 index 000000000..89f027800 --- /dev/null +++ b/server/drivers/acp/hermes.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +import { hermesAcpModelId } from "./hermes.ts"; + +describe("hermes fleet model translation", () => { + it("passes a guarded Hermes route alias to session/set_model", () => { + expect(hermesAcpModelId("litellm-local:minimax-m3-light")).toBe("litellm-local:minimax-m3-light"); + expect(hermesAcpModelId("litellm-local:MiniMax-M3")).toBe("litellm-local:MiniMax-M3"); + expect(hermesAcpModelId("minimax-m3-light")).toBeNull(); + }); + + it("keeps local host injection syntax and rejects malformed ids", () => { + expect(hermesAcpModelId("ollama::qwen3:14b")).toBe("custom:ollama:qwen3:14b"); + expect(hermesAcpModelId("bad model\nnext")).toBeNull(); + }); +}); diff --git a/server/drivers/acp/hermes.ts b/server/drivers/acp/hermes.ts index 09c4561f6..6685794f2 100644 --- a/server/drivers/acp/hermes.ts +++ b/server/drivers/acp/hermes.ts @@ -13,6 +13,10 @@ import { decodeInjectId, hostApiKey, localHost, mergeLocalInject } from "../loca import { createAcpDriver, type AcpSupport } from "./core.ts"; const EMPTY: ModelCatalog = { default: "", options: [] }; +// Canonical fleet routes use Hermes' provider:model dialect. Keep ordinary +// provider slugs on the existing ACP default path; only a producer-owned +// route alias (or a guarded local inject id below) is sent to set_model. +const HERMES_FLEET_MODEL_ID = /^[\w][\w./+-]*:[\w][\w./:+-]*$/; function hermesHome(env: Record): string { return env.HERMES_HOME || join(env.HOME || env.USERPROFILE || homedir(), ".hermes"); @@ -75,11 +79,12 @@ export function ensureHermesInjectProvider( return hermesAcpModelId(modelId) ?? modelId; } -/** ACP session/set_model id. Hermes parse_model_input treats `custom:name:model`. */ +/** ACP session/set_model id. Local inject rows become `custom:name:model`; + * fleet-catalog rows are already Hermes-native aliases and pass through. */ export function hermesAcpModelId(modelId: string | null | undefined): string | null { const inject = decodeInjectId(modelId); - if (!inject) return null; - return `custom:${inject.host}:${inject.model}`; + if (inject) return `custom:${inject.host}:${inject.model}`; + return modelId && HERMES_FLEET_MODEL_ID.test(modelId) ? modelId : null; } async function resolveModels(env: Record): Promise { diff --git a/server/drivers/builtIn.ts b/server/drivers/builtIn.ts index 928bfee60..684448111 100644 --- a/server/drivers/builtIn.ts +++ b/server/drivers/builtIn.ts @@ -6,6 +6,7 @@ import { BoxAgentDriver } from "./boxagent.ts"; import { ClaudeDriver } from "./claude.ts"; import { CodexDriver } from "./codex.ts"; import { GrokDriver } from "./grok.ts"; +import { LocalDriver } from "./local.ts"; import { GrokAgentDriver } from "./acp/grok.ts"; import { GeminiAgentDriver } from "./acp/gemini.ts"; import { KimiAgentDriver } from "./acp/kimi.ts"; @@ -31,4 +32,5 @@ export const BUILT_IN_DRIVERS: readonly AnyProviderDriver[] = [ CodexDriver, AntigravityDriver, BoxAgentDriver, + LocalDriver, ]; diff --git a/server/drivers/local.test.ts b/server/drivers/local.test.ts new file mode 100644 index 000000000..05727d6d8 --- /dev/null +++ b/server/drivers/local.test.ts @@ -0,0 +1,88 @@ +import { createServer, type Server } from "node:http"; +import { afterEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import type { ProviderInstance } from "../contracts.ts"; +import { parseJson, type JsonValue } from "../schema.ts"; +import { recordEvents, type EventRecorder } from "../testing/events.ts"; +import { decodeFleetLocalSelector, LocalDriver } from "./local.ts"; + +let server: Server | null = null; +let instance: ProviderInstance | null = null; +let recorder: EventRecorder | null = null; +const requests: Array<{ url: string; body: JsonValue | null }> = []; +const chatRequestSchema = z.object({ model: z.string() }).passthrough(); + +async function fakeHost(): Promise { + server = createServer((request, response) => { + let raw = ""; + request.on("data", (chunk) => raw += chunk); + request.on("end", () => { + const body = raw ? parseJson(raw) : null; + requests.push({ url: request.url ?? "", body }); + const json = (payload: JsonValue) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify(payload)); + }; + if (request.url === "/v1/models") return json({ data: [{ id: "qwen3.8:27b-mlx" }] }); + if (request.url === "/api/ps") return json({ + models: [{ name: "qwen3.8:27b-mlx", context_length: 65_536 }], + }); + if (request.url === "/v1/chat/completions") { + response.writeHead(200, { "content-type": "text/event-stream" }); + response.write(`data: ${JSON.stringify({ choices: [{ delta: { content: "hello" } }] })}\n\n`); + response.end("data: [DONE]\n\n"); + return; + } + response.writeHead(404).end(); + }); + }); + const running = server; + return new Promise((resolve) => running.listen(0, "127.0.0.1", () => { + // SAFETY: a TCP server listening on an ephemeral IPv4 port returns an AddressInfo object. + const address = running.address() as { port: number }; + resolve(`http://127.0.0.1:${address.port}/v1`); + })); +} + +afterEach(async () => { + recorder?.stop(); + recorder = null; + await instance?.dispose(); + instance = null; + await new Promise((resolve) => server ? server.close(() => resolve()) : resolve()); + server = null; + requests.length = 0; +}); + +describe("fleet local selectors", () => { + it("keeps Mac and Windows namespaces disjoint", () => { + expect(decodeFleetLocalSelector("ollama-mac/qwen3.8:27b-mlx", "mac")).toBe("qwen3.8:27b-mlx"); + expect(decodeFleetLocalSelector("ollama-windows/qwen3.8:27b-mlx", "mac")).toBeNull(); + expect(decodeFleetLocalSelector("bad model", "mac")).toBeNull(); + }); + + it("runs the canonical Mac selector as the host-native model", async () => { + instance = await LocalDriver.create({ + instanceId: "localMac", + displayName: "Mac M5 models", + environment: {}, + enabled: true, + config: { host: "custom", url: await fakeHost(), fleetHost: "mac" }, + }); + recorder = recordEvents(instance.adapter); + // The transport checks only readiness; the guarded fleet projection owns + // every picker row and its chat/non-chat classification. + expect(instance.models.options).toEqual([]); + expect(await instance.snapshot()).toMatchObject({ state: "available" }); + await instance.adapter.sendTurn({ + threadId: "local-turn", + text: "hi", + model: "ollama-mac/qwen3.8:27b-mlx", + }); + await recorder.until((event) => event.type === "turn.completed"); + const chatRequest = requests.find((request) => request.url === "/v1/chat/completions"); + expect(chatRequestSchema.parse(chatRequest?.body).model).toBe("qwen3.8:27b-mlx"); + expect(recorder.events).toContainEqual(expect.objectContaining({ type: "item.completed", text: "hello" })); + }); +}); diff --git a/server/drivers/local.ts b/server/drivers/local.ts new file mode 100644 index 000000000..721dd61e8 --- /dev/null +++ b/server/drivers/local.ts @@ -0,0 +1,343 @@ +// Direct local OpenAI-compatible driver. The model catalog is projected by +// the guarded fleet registry; this transport only talks to the one configured +// host after the user selects a row. It never scans other providers. +import type { + DriverCreateInput, + ModelCatalog, + ProviderDriver, + ProviderInstance, + ProviderSnapshot, + RuntimeEvent, + RuntimeEventListener, + SendTurnInput, +} from "../contracts.ts"; +import { newEventId, newId } from "../contracts.ts"; +import { z } from "zod"; +import { hostApiKey, LOCAL_HOSTS, type LocalHost } from "./local-inject.ts"; +import { appendNative } from "./native.ts"; + +const DRIVER_KIND = "local"; +// A configured local endpoint should answer on LAN/loopback promptly. Keep +// startup and explicit refresh bounded even when the host is asleep; catalog +// admission remains the authoritative longer-running health signal. +const PROBE_MS = 750; +const TURN_MS = 10 * 60_000; +const MODEL_ID = /^[\w][\w./:+-]*$/; + +export interface LocalConfig { + host: string; + url?: string; + /** Which canonical direct-local selector this instance owns. */ + fleetHost?: "mac" | "windows"; +} + +interface LocalProbe { + ok: boolean; + reason?: string; +} + +const localConfigSchema = z.object({ + host: z.string().min(1).default("ollama").refine( + (value) => value === "custom" || LOCAL_HOSTS.some((host) => host.id === value), + "unknown local host", + ), + url: z.string().url().refine((value) => value.startsWith("http://") || value.startsWith("https://"), { + message: "local server url must be http(s)", + }).optional(), + fleetHost: z.enum(["mac", "windows"]).optional(), +}); +const streamChunkSchema = z.object({ + choices: z.array(z.object({ + delta: z.object({ content: z.string().optional() }).passthrough(), + }).passthrough()).optional(), + usage: z.object({ + prompt_tokens: z.number().optional(), + completion_tokens: z.number().optional(), + }).nullable().optional(), +}).passthrough(); + +const CUSTOM: LocalHost = { + id: "custom", + label: "Local server", + baseUrl: "http://127.0.0.1:8000/v1", + apiKey: "local", +}; + +function hostFor(config: LocalConfig): LocalHost { + const known = LOCAL_HOSTS.find((host) => host.id === config.host); + const base = known ?? CUSTOM; + return config.url ? { ...base, baseUrl: config.url.replace(/\/$/, "") } : base; +} + +// oxlint-disable-next-line anti-slop/no-unknown-parameters -- ProviderDriver's opaque boundary is parsed immediately by the locked Zod schema. +function decodeConfig(raw: unknown): LocalConfig { + const parsed = localConfigSchema.parse(raw ?? {}); + if (parsed.host === "custom" && !parsed.url) throw new Error("a custom local server needs a url"); + return parsed; +} + +/** `translations.openmausbot` is stable across machines. The API host wants + * only its native model id, and an instance must refuse the other machine's + * selector rather than silently running a same-named model locally. */ +export function decodeFleetLocalSelector(model: string, fleetHost?: "mac" | "windows"): string | null { + const match = /^ollama-(mac|windows)\/(.+)$/.exec(model); + if (!match) return MODEL_ID.test(model) ? model : null; + if (!fleetHost || match[1] !== fleetHost || !MODEL_ID.test(match[2]!)) return null; + return match[2]!; +} + +const EMPTY: ModelCatalog = { default: "", options: [] }; + +export const LocalDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { displayName: "Local models", supportsMultipleInstances: true, access: "custom" }, + models: EMPTY, + install: { + command: { + darwin: "brew install ollama", + linux: "curl -fsSL https://ollama.com/install.sh | sh", + }, + docsUrl: "https://ollama.com/download", + signInCommand: "ollama serve", + }, + decodeConfig, + defaultConfig: () => decodeConfig({ host: "ollama", fleetHost: "mac" }), + + async create(input: DriverCreateInput): Promise { + const host = hostFor(input.config); + const environment = { ...process.env, ...input.environment }; + const headers = { + authorization: `Bearer ${hostApiKey(host, environment)}`, + "content-type": "application/json", + }; + const listeners = new Set(); + const active = new Map(); + let models: ModelCatalog = EMPTY; + let lastProbe: LocalProbe = { ok: false, reason: "not probed yet" }; + + const emit = (event: RuntimeEvent) => { + for (const listener of listeners) listener(event); + }; + const base = (threadId: string, turnId: string) => ({ + eventId: newEventId(), + provider: DRIVER_KIND, + threadId, + turnId, + createdAt: new Date().toISOString(), + }); + const probe = async (url: string): Promise => { + const response = await fetch(url, { headers, signal: AbortSignal.timeout(PROBE_MS) }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + }; + + const refreshModels = async () => { + try { + // Transport readiness only. Inventory and capability classification + // come exclusively from the guarded fleet projection; copying a raw + // /models response here would reintroduce unclassified or non-chat + // rows as selectable UI options. + await probe(`${host.baseUrl}/models`); + models = EMPTY; + lastProbe = { ok: true }; + } catch (error) { + models = EMPTY; + const detail = error instanceof Error ? error.message : String(error); + lastProbe = { + ok: false, + reason: /ECONNREFUSED|fetch failed|timeout|Timeout/i.test(detail) + ? `${host.label} is not running at ${host.baseUrl}` + : `${host.label}: ${detail}`, + }; + } + }; + await refreshModels(); + + const complete = async ( + messages: Array<{ role: string; content: string }>, + model: string, + signal: AbortSignal, + onDelta: (delta: string) => void, + ): Promise<{ text: string; usage: { input: number; output: number } | null }> => { + const response = await fetch(`${host.baseUrl}/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify({ + model, + messages, + stream: true, + stream_options: { include_usage: true }, + }), + signal: AbortSignal.any([signal, AbortSignal.timeout(TURN_MS)]), + }); + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error(`${host.label} HTTP ${response.status}${body ? `: ${body.slice(0, 200)}` : ""}`); + } + let text = ""; + let usage: { input: number; output: number } | null = null; + const reader = response.body?.getReader(); + if (!reader) throw new Error(`${host.label} returned no response body`); + const decoder = new TextDecoder(); + let buffer = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + newline = buffer.indexOf("\n"); + if (!line.startsWith("data:")) continue; + const data = line.slice(5).trim(); + if (!data || data === "[DONE]") continue; + let decoded: unknown; + try { + decoded = JSON.parse(data); + } catch { + continue; + } + const parsed = streamChunkSchema.safeParse(decoded); + if (!parsed.success) continue; + const chunk = parsed.data; + const delta = chunk.choices?.[0]?.delta?.content; + if (delta) { + text += delta; + onDelta(delta); + } + if (chunk.usage) usage = { + input: chunk.usage.prompt_tokens ?? 0, + output: chunk.usage.completion_tokens ?? 0, + }; + } + } + return { text, usage }; + }; + + const sendTurn = async (turn: SendTurnInput) => { + if (active.has(turn.threadId)) throw new Error("a turn is already running on this thread"); + const selected = turn.model || models.default; + const model = selected ? decodeFleetLocalSelector(selected, input.config.fleetHost) : null; + if (!model) { + throw new Error(selected + ? `model selector "${selected}" does not belong to this ${input.config.fleetHost ?? "local"} host` + : `no model to run — ${lastProbe.reason ?? "refresh the fleet catalog"}`); + } + const turnId = newId(); + const abort = new AbortController(); + active.set(turn.threadId, { abort, turnId }); + const messages = [ + ...(turn.system ? [{ role: "system", content: turn.system }] : []), + ...(turn.transcript ?? []).map((message) => ({ role: message.role, content: message.text })), + { role: "user", content: turn.text }, + ]; + appendNative(turn.threadId, { + dir: "out", + source: "local.chat.completions", + msg: { host: input.config.fleetHost ?? host.id, model, messages }, + }); + emit({ ...base(turn.threadId, turnId), type: "turn.started" }); + emit({ ...base(turn.threadId, turnId), type: "session.started", sessionId: null, model }); + void (async () => { + try { + const result = await complete( + messages, + model, + abort.signal, + (delta) => emit({ + ...base(turn.threadId, turnId), + type: "content.delta", + streamKind: "assistant_text", + delta, + }), + ); + appendNative(turn.threadId, { dir: "in", source: "local.chat.completions", msg: result }); + if (result.text.trim()) emit({ + ...base(turn.threadId, turnId), + type: "item.completed", + itemType: "assistant_text", + text: result.text, + }); + if (result.usage) emit({ ...base(turn.threadId, turnId), type: "thread.token-usage.updated", ...result.usage }); + active.delete(turn.threadId); + if (result.usage) { + emit({ + ...base(turn.threadId, turnId), + type: "turn.completed", + ok: true, + stopReason: null, + cost: null, + usage: result.usage, + }); + } else { + emit({ + ...base(turn.threadId, turnId), + type: "turn.completed", + ok: true, + stopReason: null, + cost: null, + }); + } + } catch (error) { + active.delete(turn.threadId); + const aborted = error instanceof Error && error.name === "AbortError"; + const message = error instanceof Error ? error.message : String(error); + if (!aborted) emit({ ...base(turn.threadId, turnId), type: "runtime.error", message }); + emit({ + ...base(turn.threadId, turnId), + type: "turn.completed", + ok: false, + stopReason: aborted ? "interrupted" : "error", + cost: null, + }); + } + })(); + return { turnId }; + }; + + const snapshot = async (): Promise => { + // ProviderRegistry owns refresh policy. Re-probing here would make a + // cached describe() perform network I/O anyway, and a live describe() + // would probe this host twice. + return lastProbe.ok + ? { state: "available", authenticated: true, version: null } + : { state: "unavailable", reason: lastProbe.reason }; + }; + + return { + instanceId: input.instanceId, + driverKind: DRIVER_KIND, + displayName: input.displayName ?? `${input.config.fleetHost === "windows" ? "Windows" : "Mac"} ${host.label}`, + enabled: input.enabled, + get models() { return models; }, + refreshModels, + snapshot, + adapter: { + provider: DRIVER_KIND, + capabilities: { + sessionModelSwitch: "in-session", + computerMcp: false, + agentsMcp: false, + composioMcp: false, + queueing: false, + }, + sendTurn, + interruptTurn: async (threadId) => active.get(threadId)?.abort.abort(), + respondToRequest: async (): Promise<"unavailable"> => "unavailable", + hasSession: (threadId) => active.has(threadId), + stopAll: async () => { + for (const entry of active.values()) entry.abort.abort(); + }, + onEvent: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + dispose: async () => { + for (const entry of active.values()) entry.abort.abort(); + active.clear(); + listeners.clear(); + }, + }; + }, +}; diff --git a/server/fleet-model-catalog.test.ts b/server/fleet-model-catalog.test.ts new file mode 100644 index 000000000..c2c6e51dc --- /dev/null +++ b/server/fleet-model-catalog.test.ts @@ -0,0 +1,512 @@ +import { createHash } from "node:crypto"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { ModelOption } from "./contracts.ts"; +import { + FleetModelCatalogRegistry, + parseFleetModelCatalog, + projectFleetModels, + type FleetModelCatalogSnapshot, + type ParsedFleetModelCatalog, +} from "./fleet-model-catalog.ts"; + +interface ProjectionStatus { + configured: boolean; + reachable: boolean; + verified: boolean; + admitted: boolean; + busy: boolean; + reason: string | null; + last_verification_receipt: string | null; +} + +interface ProjectionModel { + id: string; + display_name: string; + kind: "model" | "route_group"; + provider_id: string; + native_model_id: string | null; + capabilities: string[]; + host: "hosted" | "mac" | "windows"; + cost_class: "paid_subscription" | "paid_metered" | "free" | "local"; + manual_only: boolean; + is_default: boolean; + selectable: boolean; + status: ProjectionStatus; + translations: { + litellm: string | null; + hermes: string | null; + opencode: string | null; + telegram: string | null; + openmausbot: string | null; + }; + route_members: string[]; +} + +interface ProjectionFixture { + schema_version: "openmausbot-models/v1"; + catalog_version: number; + generated_at: string; + source: { + registry_schema_version: string; + registry_version: number; + registry_sha256: string; + }; + provider_candidates: Array<{ + provider_id: string; + display_name: string; + configured: boolean; + selectable: boolean; + reason: "unverified_provider_or_model"; + }>; + default_model_id: string; + models: ProjectionModel[]; +} + +function concreteModel(id: string, overrides: Partial = {}): ProjectionModel { + return { + id, + display_name: id, + kind: "model", + provider_id: "minimax", + native_model_id: id, + capabilities: ["chat"], + host: "hosted", + cost_class: "paid_subscription", + manual_only: true, + is_default: false, + selectable: false, + status: { + configured: true, + reachable: false, + verified: false, + admitted: false, + busy: false, + reason: "fresh admission receipt required", + last_verification_receipt: null, + }, + translations: { + litellm: id, + hermes: `litellm-local:${id}`, + opencode: `litellm-local/${id}`, + telegram: id, + openmausbot: id, + }, + route_members: [], + ...overrides, + }; +} + +function baseCatalog(): ProjectionFixture { + const group = concreteModel("MiniMax-M3", { + display_name: "MiniMax M3", + kind: "route_group", + native_model_id: "MiniMax-M3", + capabilities: ["chat", "tool_use"], + manual_only: false, + is_default: true, + selectable: true, + status: { + configured: true, + reachable: true, + verified: true, + admitted: true, + busy: false, + reason: null, + last_verification_receipt: "/receipt/group.json", + }, + route_members: ["minimax-m3-light"], + }); + const light = concreteModel("minimax-m3-light", { + display_name: "MiniMax M3 — Lightcloud007", + native_model_id: "MiniMax-M3", + }); + return { + schema_version: "openmausbot-models/v1", + catalog_version: 7, + generated_at: "2026-08-22T05:00:00Z", + source: { + registry_schema_version: "aos-model-registry/v1", + registry_version: 12, + registry_sha256: "a".repeat(64), + }, + provider_candidates: [{ + provider_id: "candidate-provider", + display_name: "Candidate Provider", + configured: true, + selectable: false, + reason: "unverified_provider_or_model", + }], + default_model_id: "MiniMax-M3", + models: [group, light], + }; +} + +function projection(mutate?: (catalog: ProjectionFixture) => void): string { + const catalog = baseCatalog(); + mutate?.(catalog); + return JSON.stringify(catalog); +} + +function readySnapshot(parsed: ParsedFleetModelCatalog): FleetModelCatalogSnapshot { + return { + schema: "openmausbot-models/v1", + source: { path: "/fixture", state: "ready", refreshedAt: "now" }, + models: parsed.models, + providerCandidates: parsed.providerCandidates, + }; +} + +function emptyOptions(): ModelOption[] { + return []; +} + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("parseFleetModelCatalog", () => { + it("accepts the locked v1 projection and maps Hermes/OpenCode ids", () => { + const catalog = parseFleetModelCatalog(projection()); + expect(catalog).toMatchObject({ + catalogVersion: 7, + registrySchemaVersion: "aos-model-registry/v1", + registryVersion: 12, + }); + expect(catalog.models[0]).toMatchObject({ + canonicalId: "MiniMax-M3", + kind: "route_group", + costClass: "paid_subscription", + default: true, + translations: [ + { driverKind: "hermesAgent", modelId: "litellm-local:MiniMax-M3" }, + { driverKind: "opencodeGo", modelId: "litellm-local/MiniMax-M3" }, + ], + routeMembers: ["minimax-m3-light"], + }); + expect(catalog.providerCandidates).toEqual([{ + providerId: "candidate-provider", + label: "Candidate Provider", + configured: true, + selectable: false, + reason: "unverified_provider_or_model", + }]); + }); + + it("rejects contradictory selectability, missing group members, and default drift", () => { + expect(() => parseFleetModelCatalog(projection((catalog) => { + catalog.models[1]!.selectable = true; + }))).toThrow("selectable contradicts"); + expect(() => parseFleetModelCatalog(projection((catalog) => { + catalog.models[0]!.route_members = ["absent"]; + }))).toThrow("route member"); + expect(() => parseFleetModelCatalog(projection((catalog) => { + catalog.models[0]!.is_default = false; + catalog.models[1]!.is_default = true; + }))).toThrow("single is_default row must agree"); + expect(() => parseFleetModelCatalog(projection((catalog) => { + catalog.provider_candidates[0]!.selectable = true; + }))).toThrow("must remain false"); + }); + + it("accepts a null provider-native id for a disabled inventory-only row", () => { + const catalog = parseFleetModelCatalog(projection((source) => { + source.models[1]!.native_model_id = null; + })); + expect(catalog.models[1]?.nativeModelId).toBeNull(); + }); + + it("rejects divergent schemas, extra credential fields, secret-looking values, and duplicate ids", () => { + const divergent = Object.assign(baseCatalog(), { schema: "openmausbot-models.v1" }); + expect(() => parseFleetModelCatalog(JSON.stringify(divergent))).toThrow("schema"); + + const withKey = Object.assign(baseCatalog(), { api_key: "should-never-be-here" }); + expect(() => parseFleetModelCatalog(JSON.stringify(withKey))).toThrow("api_key"); + const withReference = Object.assign(baseCatalog(), { credential_ref: "logical-name" }); + expect(() => parseFleetModelCatalog(JSON.stringify(withReference))).toThrow("credential_ref"); + + const withValue = baseCatalog(); + withValue.models[0]!.display_name = "Bearer definitely-a-secret-value"; + expect(() => parseFleetModelCatalog(JSON.stringify(withValue))).toThrow("credential value"); + + const duplicate = baseCatalog(); + duplicate.models.push({ ...duplicate.models[0]! }); + expect(() => parseFleetModelCatalog(JSON.stringify(duplicate))).toThrow("duplicate canonical model id"); + }); +}); + +describe("FleetModelCatalogRegistry", () => { + it("keeps the last inventory visible but fail-closes it after an invalid refresh", () => { + const dir = mkdtempSync(join(tmpdir(), "omb-fleet-catalog-")); + dirs.push(dir); + const path = join(dir, "catalog.json"); + writeFileSync(path, projection()); + const registry = new FleetModelCatalogRegistry(path); + expect(registry.snapshot().source.state).toBe("ready"); + writeFileSync(path, "not json"); + const failed = registry.refresh(); + expect(failed.source.state).toBe("invalid"); + expect(failed.models).toHaveLength(2); + expect(failed.models.every((model) => model.status.admitted === false)).toBe(true); + }); + + it("applies a registry-bound shared default without mutating active tasks", () => { + const dir = mkdtempSync(join(tmpdir(), "omb-fleet-default-")); + dirs.push(dir); + const path = join(dir, "openmausbot-models.v1.json"); + const defaultPath = join(dir, "default-model.v1.json"); + const raw = projection((catalog) => { + const light = catalog.models[1]!; + light.selectable = true; + light.status = { + configured: true, + reachable: true, + verified: true, + admitted: true, + busy: false, + reason: null, + last_verification_receipt: "/receipt/light.json", + }; + }); + writeFileSync(path, raw); + writeFileSync(defaultPath, JSON.stringify({ + schema_version: "aos-model-default/v1", + canonical_model_id: "minimax-m3-light", + catalog_sha256: createHash("sha256").update(raw).digest("hex"), + registry_sha256: "a".repeat(64), + applies_to_new_sessions_only: true, + active_sessions_rewritten: false, + })); + + const snapshot = new FleetModelCatalogRegistry(path, defaultPath).snapshot(); + + expect(snapshot.source.defaultState).toBe("shared"); + expect(snapshot.source.defaultModelId).toBe("minimax-m3-light"); + expect(snapshot.models.find((model) => model.canonicalId === "MiniMax-M3")?.default).toBe(false); + expect(snapshot.models.find((model) => model.canonicalId === "minimax-m3-light")?.default).toBe(true); + }); + + it("keeps the catalog default when the shared default is stale", () => { + const dir = mkdtempSync(join(tmpdir(), "omb-fleet-default-stale-")); + dirs.push(dir); + const path = join(dir, "openmausbot-models.v1.json"); + const defaultPath = join(dir, "default-model.v1.json"); + const raw = projection(); + writeFileSync(path, raw); + writeFileSync(defaultPath, JSON.stringify({ + schema_version: "aos-model-default/v1", + canonical_model_id: "MiniMax-M3", + catalog_sha256: createHash("sha256").update(raw).digest("hex"), + registry_sha256: "c".repeat(64), + applies_to_new_sessions_only: true, + active_sessions_rewritten: false, + })); + + const snapshot = new FleetModelCatalogRegistry(path, defaultPath).snapshot(); + + expect(snapshot.source.defaultState).toBe("invalid"); + expect(snapshot.source.defaultReason).toContain("hash is stale"); + expect(snapshot.source.defaultModelId).toBe("MiniMax-M3"); + }); +}); + +describe("projectFleetModels", () => { + it("preserves historical custom rows for installations with no AOS catalog", () => { + const [instance] = projectFleetModels( + [{ + instanceId: "claude", + driverKind: "claudeAgent", + models: { default: "", options: [{ id: "omlx::local", label: "Local", custom: true }] }, + }], + { + schema: "openmausbot-models/v1", + source: { path: "/missing", state: "missing", refreshedAt: "now" }, + models: [], + providerCandidates: [], + }, + ); + expect(instance.models.options).toContainEqual(expect.objectContaining({ id: "omlx::local" })); + }); + + it("uses driver-native ids and disables busy, non-chat, and unverified rows", () => { + const parsed = parseFleetModelCatalog(projection((catalog) => { + catalog.models.push( + concreteModel("windows-qwen", { + display_name: "Qwen on Windows", + provider_id: "ollama", + native_model_id: "qwen3:14b", + host: "windows", + cost_class: "local", + status: { + configured: true, + reachable: true, + verified: true, + admitted: true, + busy: true, + reason: "GPU is busy", + last_verification_receipt: "/receipt/windows.json", + }, + translations: { + litellm: "windows-qwen", + hermes: "litellm-local:windows-qwen", + opencode: "litellm-local/windows-qwen", + telegram: "windows-qwen", + openmausbot: "ollama-windows/qwen3:14b", + }, + }), + concreteModel("nomic-embed", { + display_name: "Nomic Embed", + provider_id: "ollama", + native_model_id: "nomic-embed-text", + capabilities: ["embedding"], + host: "mac", + cost_class: "local", + status: { + configured: true, + reachable: true, + verified: true, + admitted: true, + busy: false, + reason: null, + last_verification_receipt: "/receipt/mac.json", + }, + translations: { + litellm: "nomic-embed", + hermes: "litellm-local:nomic-embed", + opencode: "litellm-local/nomic-embed", + telegram: null, + openmausbot: "ollama-mac/nomic-embed-text", + }, + }), + ); + })); + const projected = projectFleetModels( + [ + { + instanceId: "hermes", + driverKind: "hermesAgent", + models: { + default: "raw-local-model", + options: [{ id: "raw-local-model", label: "Unclassified raw row", custom: true }], + }, + }, + { instanceId: "opencode", driverKind: "opencodeGo", models: { default: "go", options: emptyOptions() } }, + ], + readySnapshot(parsed), + ); + expect(projected[0]?.models.default).toBe("litellm-local:MiniMax-M3"); + expect(projected[0]?.models.options).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: "litellm-local:MiniMax-M3", + canonicalId: "MiniMax-M3", + isDefault: true, + selectable: true, + }), + expect.objectContaining({ id: "litellm-local:windows-qwen", selectable: false, reason: "GPU is busy" }), + expect.objectContaining({ id: "litellm-local:nomic-embed", selectable: false, reason: "Not chat-capable" }), + expect.objectContaining({ id: "litellm-local:minimax-m3-light", selectable: false }), + ])); + expect(projected[1]?.models.options).toContainEqual(expect.objectContaining({ + id: "litellm-local/MiniMax-M3", + canonicalId: "MiniMax-M3", + selectable: true, + })); + expect(projected[0]?.models.options.some((option) => option.id === "raw-local-model")).toBe(false); + }); + + it("keeps Mac and Windows direct-local translations on disjoint instances", () => { + const parsed = parseFleetModelCatalog(projection((source) => { + const local = (id: string, host: "mac" | "windows", selector: string, busy: boolean) => concreteModel(id, { + display_name: `${id} on ${host}`, + provider_id: "ollama", + native_model_id: "qwen3:14b", + host, + cost_class: "local", + selectable: !busy, + status: { + configured: true, + reachable: true, + verified: true, + admitted: true, + busy, + reason: busy ? "GPU is busy" : null, + last_verification_receipt: "/receipt/local.json", + }, + translations: { + litellm: null, + hermes: null, + opencode: null, + telegram: null, + openmausbot: selector, + }, + }); + source.models.push( + local("mac-qwen", "mac", "ollama-mac/qwen3:14b", false), + local("windows-qwen", "windows", "ollama-windows/qwen3:14b", true), + ); + })); + const projected = projectFleetModels( + [ + { instanceId: "localMac", driverKind: "local", models: { default: "", options: emptyOptions() } }, + { instanceId: "localWindows", driverKind: "local", models: { default: "", options: emptyOptions() } }, + ], + readySnapshot(parsed), + ); + expect(projected[0]?.models.options).toContainEqual(expect.objectContaining({ + canonicalId: "mac-qwen", + id: "ollama-mac/qwen3:14b", + selectable: true, + })); + expect(projected[0]?.models.options.some((option) => option.canonicalId === "windows-qwen")).toBe(false); + expect(projected[1]?.models.options).toContainEqual(expect.objectContaining({ + canonicalId: "windows-qwen", + id: "ollama-windows/qwen3:14b", + selectable: false, + reason: "GPU is busy", + })); + }); + + it("keeps unsupported models and provider candidates visible but disabled on one rail", () => { + const parsed = parseFleetModelCatalog(projection((source) => { + source.models.push(concreteModel("unsupported-candidate", { + display_name: "Unsupported candidate", + provider_id: "candidate-provider", + native_model_id: null, + cost_class: "free", + status: { + configured: true, + reachable: false, + verified: false, + admitted: false, + busy: false, + reason: "Provider model discovery did not verify this candidate", + last_verification_receipt: null, + }, + translations: { litellm: null, hermes: null, opencode: null, telegram: null, openmausbot: null }, + })); + })); + const [hermes, opencode] = projectFleetModels( + [ + { instanceId: "hermes", driverKind: "hermesAgent", models: { default: "", options: emptyOptions() } }, + { instanceId: "opencode", driverKind: "opencodeGo", models: { default: "", options: emptyOptions() } }, + ], + readySnapshot(parsed), + ); + expect(hermes.models.options).toContainEqual(expect.objectContaining({ + canonicalId: "unsupported-candidate", + selectable: false, + reason: "Provider model discovery did not verify this candidate", + })); + expect(opencode.models.options.some((option) => option.canonicalId === "unsupported-candidate")).toBe(false); + expect(hermes.models.options).toContainEqual(expect.objectContaining({ + canonicalId: "provider-candidate:candidate-provider", + provider: "candidate-provider", + selectable: false, + reason: "unverified_provider_or_model", + })); + }); +}); diff --git a/server/fleet-model-catalog.ts b/server/fleet-model-catalog.ts new file mode 100644 index 000000000..65a5c4d1a --- /dev/null +++ b/server/fleet-model-catalog.ts @@ -0,0 +1,546 @@ +// Guarded AOS fleet-model projection for OpenMausBot. +// +// This adapter reads one versioned, secret-free file. It never discovers +// providers, talks to model hosts, or reads credential stores. The producer +// owns live admission; OpenMausBot only renders its cached verdict and maps a +// stable canonical id to the native id understood by a concrete driver. +import { createHash } from "node:crypto"; +import { readFileSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { z } from "zod"; + +import type { ModelOption, ModelRuntimeStatus } from "./contracts.ts"; +import { parseJson, schemaIssue } from "./schema.ts"; + +export const DEFAULT_AOS_MODEL_CATALOG_PATH = + "/Users/gus/.local/share/aos-model-catalog/current/openmausbot-models.v1.json"; + +const MAX_CATALOG_BYTES = 2 * 1024 * 1024; +const ID = /^[a-z0-9][a-z0-9._:/+-]{0,191}$/i; +const RFC3339 = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/; +const FORBIDDEN_SECRET_VALUE = /^(?:Bearer\s+\S{12,}|sk-[A-Za-z0-9_-]{12,}|xai-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{20,}|AKIA[A-Z0-9]{16})$/; + +const stableIdSchema = z.string() + .regex(ID, "must be a stable model id") + .refine((value) => !FORBIDDEN_SECRET_VALUE.test(value), "must not contain a credential value"); +const secretFreeTextSchema = z.string().trim().min(1).max(4_096).refine( + (value) => !FORBIDDEN_SECRET_VALUE.test(value), + "must not contain a credential value", +); +const capabilitySchema = z.string().regex(/^[a-z][a-z0-9._-]{0,47}$/); +const capabilitiesSchema = z.array(capabilitySchema).refine( + (values) => new Set(values).size === values.length, + "must not contain duplicate capabilities", +); +const runtimeStatusSchema = z.object({ + configured: z.boolean(), + reachable: z.boolean(), + verified: z.boolean(), + admitted: z.boolean(), + busy: z.boolean(), + reason: secretFreeTextSchema.nullable(), + last_verification_receipt: secretFreeTextSchema.nullable(), +}).strict(); +const translationsSchema = z.object({ + litellm: stableIdSchema.nullable(), + hermes: stableIdSchema.nullable(), + opencode: stableIdSchema.nullable(), + telegram: stableIdSchema.nullable(), + openmausbot: stableIdSchema.nullable(), +}).strict(); +const modelRowSchema = z.object({ + id: stableIdSchema, + display_name: secretFreeTextSchema.max(160), + kind: z.enum(["model", "route_group"]), + provider_id: stableIdSchema, + native_model_id: secretFreeTextSchema.max(512).nullable(), + capabilities: capabilitiesSchema, + host: z.enum(["hosted", "mac", "windows"]), + cost_class: z.enum(["paid_subscription", "paid_metered", "free", "local"]), + manual_only: z.boolean(), + is_default: z.boolean(), + selectable: z.boolean(), + status: runtimeStatusSchema, + translations: translationsSchema, + route_members: z.array(stableIdSchema).refine( + (members) => new Set(members).size === members.length, + "must not contain duplicate route members", + ), +}).strict().superRefine((model, context) => { + if (model.selectable && ( + !model.status.configured || + !model.status.reachable || + !model.status.verified || + !model.status.admitted || + model.status.busy || + !model.capabilities.includes("chat") + )) { + context.addIssue({ + code: "custom", + path: ["selectable"], + message: "selectable contradicts runtime admission gates or chat capability", + }); + } + if (model.kind === "model" && model.route_members.length > 0) { + context.addIssue({ + code: "custom", + path: ["route_members"], + message: "a concrete model cannot have route members", + }); + } +}); +const providerCandidateSchema = z.object({ + provider_id: stableIdSchema, + display_name: secretFreeTextSchema.max(160), + configured: z.boolean(), + selectable: z.boolean().refine((selectable) => !selectable, { + message: "must remain false without an exact model identity", + }), + reason: z.literal("unverified_provider_or_model"), +}).strict(); +const catalogProjectionSchema = z.object({ + schema_version: z.literal("openmausbot-models/v1"), + catalog_version: z.number().int().positive(), + generated_at: z.string().refine( + (value) => RFC3339.test(value) && !Number.isNaN(Date.parse(value)), + "must be RFC3339", + ), + source: z.object({ + registry_schema_version: secretFreeTextSchema.max(160), + registry_version: z.number().int().positive(), + registry_sha256: z.string().regex(/^[a-f0-9]{64}$/i), + }).strict(), + default_model_id: stableIdSchema, + provider_candidates: z.array(providerCandidateSchema), + models: z.array(modelRowSchema), +}).strict().superRefine((catalog, context) => { + const ids = new Set(); + for (const [index, model] of catalog.models.entries()) { + if (ids.has(model.id)) { + context.addIssue({ + code: "custom", + path: ["models", index, "id"], + message: `duplicate canonical model id "${model.id}"`, + }); + } + ids.add(model.id); + } + const providerIds = new Set(); + for (const [index, candidate] of catalog.provider_candidates.entries()) { + if (providerIds.has(candidate.provider_id)) { + context.addIssue({ + code: "custom", + path: ["provider_candidates", index, "provider_id"], + message: `duplicate provider candidate "${candidate.provider_id}"`, + }); + } + providerIds.add(candidate.provider_id); + } + if (!ids.has(catalog.default_model_id)) { + context.addIssue({ + code: "custom", + path: ["default_model_id"], + message: "is absent from models", + }); + } + const defaults = catalog.models.filter((model) => model.is_default); + if (defaults.length !== 1 || defaults[0]?.id !== catalog.default_model_id) { + context.addIssue({ + code: "custom", + path: ["default_model_id"], + message: "and the single is_default row must agree", + }); + } + for (const [index, model] of catalog.models.entries()) { + for (const member of model.route_members) { + if (!ids.has(member)) { + context.addIssue({ + code: "custom", + path: ["models", index, "route_members"], + message: `route member "${member}" is absent from models`, + }); + } + } + } +}); +const sharedDefaultSchema = z.object({ + schema_version: z.literal("aos-model-default/v1"), + canonical_model_id: stableIdSchema, + catalog_sha256: z.string().regex(/^[a-f0-9]{64}$/i), + registry_sha256: z.string().regex(/^[a-f0-9]{64}$/i), + applies_to_new_sessions_only: z.literal(true), + active_sessions_rewritten: z.literal(false), +}).strict(); + +export type FleetCatalogState = "ready" | "missing" | "invalid"; + +export interface FleetModelTranslation { + driverKind: string; + modelId: string; + instanceId?: string; +} + +export interface FleetModelRecord { + canonicalId: string; + label: string; + kind: "model" | "route_group"; + nativeModelId: string | null; + provider: string; + host: "hosted" | "mac" | "windows"; + costClass: "paid_subscription" | "paid_metered" | "free" | "local"; + capabilities: string[]; + status: ModelRuntimeStatus; + reason?: string; + default: boolean; + manualOnly: boolean; + declaredSelectable: boolean; + verificationReceipt?: string; + translations: FleetModelTranslation[]; + routeMembers: string[]; +} + +export interface FleetProviderCandidate { + providerId: string; + label: string; + configured: boolean; + selectable: false; + reason: string; +} + +export interface FleetModelCatalogSnapshot { + schema: "openmausbot-models/v1"; + source: { + path: string; + state: FleetCatalogState; + refreshedAt: string; + generatedAt?: string; + catalogVersion?: number; + registrySchemaVersion?: string; + registryVersion?: number; + registrySha256?: string; + defaultModelId?: string; + defaultState?: "catalog" | "shared" | "invalid"; + defaultReason?: string; + reason?: string; + }; + models: FleetModelRecord[]; + providerCandidates: FleetProviderCandidate[]; +} + +export interface ParsedFleetModelCatalog { + generatedAt: string; + catalogVersion: number; + registrySchemaVersion: string; + registryVersion: number; + registrySha256: string; + models: FleetModelRecord[]; + providerCandidates: FleetProviderCandidate[]; +} + +interface InstanceDescription { + instanceId: string; + driverKind: string; + models: { default: string; options: ModelOption[] }; + snapshot?: { state: string; reason?: string }; +} + +function modelTranslations(model: z.output): FleetModelTranslation[] { + const translations: FleetModelTranslation[] = []; + if (model.translations.hermes) { + translations.push({ driverKind: "hermesAgent", modelId: model.translations.hermes }); + } + if (model.translations.opencode) { + translations.push({ driverKind: "opencodeGo", modelId: model.translations.opencode }); + } + if ((model.host === "mac" || model.host === "windows") && model.translations.openmausbot) { + translations.push({ + driverKind: "local", + modelId: model.translations.openmausbot, + instanceId: model.host === "mac" ? "localMac" : "localWindows", + }); + } + return translations; +} + +export function parseFleetModelCatalog(raw: string): ParsedFleetModelCatalog { + if (Buffer.byteLength(raw, "utf8") > MAX_CATALOG_BYTES) throw new Error("catalog exceeds 2 MiB"); + let json; + try { + json = parseJson(raw); + } catch { + throw new Error("catalog is not valid JSON"); + } + const parsed = catalogProjectionSchema.safeParse(json); + if (!parsed.success) throw new Error(schemaIssue(parsed.error, "catalog is invalid")); + const catalog = parsed.data; + const models = catalog.models.map((model): FleetModelRecord => { + const record: FleetModelRecord = { + canonicalId: model.id, + label: model.display_name, + kind: model.kind, + nativeModelId: model.native_model_id, + provider: model.provider_id, + host: model.host, + costClass: model.cost_class, + capabilities: [...model.capabilities], + status: { + configured: model.status.configured, + reachable: model.status.reachable, + verified: model.status.verified, + admitted: model.status.admitted, + busy: model.status.busy, + }, + default: model.is_default, + manualOnly: model.manual_only, + declaredSelectable: model.selectable, + translations: modelTranslations(model), + routeMembers: [...model.route_members], + }; + if (model.status.reason) record.reason = model.status.reason; + if (model.status.last_verification_receipt) { + record.verificationReceipt = model.status.last_verification_receipt; + } + return record; + }); + return { + generatedAt: catalog.generated_at, + catalogVersion: catalog.catalog_version, + registrySchemaVersion: catalog.source.registry_schema_version, + registryVersion: catalog.source.registry_version, + registrySha256: catalog.source.registry_sha256, + models, + providerCandidates: catalog.provider_candidates.map((candidate) => ({ + providerId: candidate.provider_id, + label: candidate.display_name, + configured: candidate.configured, + selectable: false, + reason: candidate.reason, + })), + }; +} + +function failedModels(previous: readonly FleetModelRecord[], reason: string): FleetModelRecord[] { + return previous.map((model) => ({ + ...model, + status: { ...model.status, admitted: false }, + reason, + })); +} + +export class FleetModelCatalogRegistry { + readonly path: string; + readonly defaultPath: string; + private cached: FleetModelCatalogSnapshot; + + constructor( + path = process.env.AOS_MODEL_CATALOG_PATH?.trim() || DEFAULT_AOS_MODEL_CATALOG_PATH, + defaultPath = process.env.AOS_MODEL_DEFAULT_PATH?.trim() || join(dirname(path), "default-model.v1.json"), + ) { + this.path = path; + this.defaultPath = defaultPath; + this.cached = { + schema: "openmausbot-models/v1", + source: { path, state: "missing", refreshedAt: new Date().toISOString(), reason: "catalog not loaded" }, + models: [], + providerCandidates: [], + }; + this.refresh(); + } + + snapshot(): FleetModelCatalogSnapshot { + return structuredClone(this.cached); + } + + refresh(): FleetModelCatalogSnapshot { + const refreshedAt = new Date().toISOString(); + try { + const size = statSync(this.path).size; + if (size > MAX_CATALOG_BYTES) throw new Error("catalog exceeds 2 MiB"); + const raw = readFileSync(this.path, "utf8"); + const parsed = parseFleetModelCatalog(raw); + const catalogSha256 = createHash("sha256").update(raw).digest("hex"); + let defaultState: "catalog" | "shared" | "invalid" = "catalog"; + let defaultReason: string | undefined; + try { + const shared = sharedDefaultSchema.parse( + parseJson(readFileSync(this.defaultPath, "utf8")), + ); + if (shared.registry_sha256 !== parsed.registrySha256) { + throw new Error("shared default registry hash is stale"); + } + if (shared.catalog_sha256 !== catalogSha256) { + throw new Error("shared default catalog hash is stale"); + } + const selected = parsed.models.find( + (model) => model.canonicalId === shared.canonical_model_id, + ); + if (!selected) throw new Error("shared default canonical model is unknown"); + const unavailable = unavailability(selected); + if (unavailable) throw new Error(`shared default is unavailable: ${unavailable}`); + for (const model of parsed.models) { + model.default = model.canonicalId === shared.canonical_model_id; + } + defaultState = "shared"; + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) { + defaultState = "invalid"; + defaultReason = error instanceof Error ? error.message : "shared default is invalid"; + } + } + const defaultModelId = parsed.models.find((model) => model.default)?.canonicalId; + const source: FleetModelCatalogSnapshot["source"] = { + path: this.path, + state: "ready", + refreshedAt, + generatedAt: parsed.generatedAt, + catalogVersion: parsed.catalogVersion, + registrySchemaVersion: parsed.registrySchemaVersion, + registryVersion: parsed.registryVersion, + registrySha256: parsed.registrySha256, + defaultModelId, + defaultState, + }; + if (defaultReason) source.defaultReason = defaultReason; + this.cached = { + schema: "openmausbot-models/v1", + source, + models: parsed.models, + providerCandidates: parsed.providerCandidates, + }; + } catch (error) { + const missing = error instanceof Error && "code" in error && error.code === "ENOENT"; + const reason = missing ? "catalog file is missing" : error instanceof Error ? error.message : "catalog is invalid"; + this.cached = { + schema: "openmausbot-models/v1", + source: { + path: this.path, + state: missing ? "missing" : "invalid", + refreshedAt, + reason, + }, + models: failedModels(this.cached.models, reason), + providerCandidates: this.cached.providerCandidates.map((candidate) => ({ ...candidate, reason })), + }; + } + return this.snapshot(); + } +} + +function unavailability(model: FleetModelRecord): string | undefined { + if (!model.capabilities.includes("chat")) return model.reason ?? "Not chat-capable"; + if (model.status.busy) return model.reason ?? "Host is busy"; + if (!model.status.configured) return model.reason ?? "Not configured"; + if (!model.status.reachable) return model.reason ?? "Host is unreachable"; + if (!model.status.verified) return model.reason ?? "Not verified"; + if (!model.status.admitted) return model.reason ?? "Not admitted"; + if (!model.declaredSelectable) return model.reason ?? "Not currently selectable"; + return undefined; +} + +function optionFor(model: FleetModelRecord, nativeId: string): ModelOption { + const reason = unavailability(model); + const option: ModelOption = { + id: nativeId, + label: model.label, + custom: true, + canonicalId: model.canonicalId, + provider: model.provider, + host: model.host, + costClass: model.costClass, + manualOnly: model.manualOnly, + isDefault: model.default, + capabilities: [...model.capabilities], + status: { ...model.status }, + selectable: reason === undefined, + }; + if (reason) option.reason = reason; + if (model.verificationReceipt) option.verificationReceipt = model.verificationReceipt; + return option; +} + +/** Merge driver-native projections into model-picker rows without changing + * the live driver catalog or probing any provider. */ +export function projectFleetModels( + instances: readonly T[], + catalog: FleetModelCatalogSnapshot, +): T[] { + const projectedCanonicalIds = new Set(); + const ownsCustomInventory = catalog.source.state === "ready" || + catalog.models.length > 0 || catalog.providerCandidates.length > 0; + const projected = instances.map((instance) => { + // Once a guarded catalog exists, it is the only owner of Custom rows. + // Raw local discovery cannot re-admit an unclassified embedding model or + // a machine that the producer marked busy/unreachable. A non-AOS install + // with no catalog keeps the product's historical custom-model behavior. + const options = instance.models.options + .filter((option) => !ownsCustomInventory || !option.custom) + .map((option) => ({ ...option })); + let defaultId = options.some((option) => option.id === instance.models.default) + ? instance.models.default + : options[0]?.id ?? ""; + for (const model of catalog.models) { + const translations = model.translations.filter((translation) => + translation.driverKind === instance.driverKind && + (!translation.instanceId || translation.instanceId === instance.instanceId) + ); + for (const translation of translations) { + projectedCanonicalIds.add(model.canonicalId); + const projectedOption = optionFor(model, translation.modelId); + const existing = options.findIndex((option) => option.id === projectedOption.id); + if (existing >= 0) options[existing] = { ...options[existing], ...projectedOption }; + else options.push(projectedOption); + if (model.default && projectedOption.selectable) defaultId = projectedOption.id; + } + } + return { + ...instance, + models: { default: defaultId, options }, + }; + }); + + // Unsupported candidates still belong in the inventory. Put each one on a + // single preferred fleet rail as a disabled row instead of silently + // dropping it or inventing a driver translation that could execute it. + const inventoryTarget = + projected.find((instance) => instance.driverKind === "hermesAgent") ?? + projected.find((instance) => instance.driverKind === "opencodeGo") ?? + projected[0]; + if (inventoryTarget) { + for (const model of catalog.models) { + if (projectedCanonicalIds.has(model.canonicalId)) continue; + const inventory = optionFor(model, model.canonicalId); + inventory.selectable = false; + inventory.reason = model.reason ?? "Unavailable on this OpenMausBot surface"; + if (!inventoryTarget.models.options.some((option) => option.canonicalId === model.canonicalId)) { + inventoryTarget.models.options.push(inventory); + } + } + for (const candidate of catalog.providerCandidates) { + const canonicalId = `provider-candidate:${candidate.providerId}`; + if (inventoryTarget.models.options.some((option) => option.canonicalId === canonicalId)) continue; + inventoryTarget.models.options.push({ + id: canonicalId, + label: candidate.label, + custom: true, + canonicalId, + provider: candidate.providerId, + host: "hosted", + costClass: "unknown", + manualOnly: true, + isDefault: false, + capabilities: [], + status: { + configured: candidate.configured, + reachable: false, + verified: false, + admitted: false, + busy: false, + }, + selectable: false, + reason: candidate.reason, + }); + } + } + return projected; +} diff --git a/server/harness/registry.ts b/server/harness/registry.ts index efa00953e..49b88cc93 100644 --- a/server/harness/registry.ts +++ b/server/harness/registry.ts @@ -117,8 +117,12 @@ export class ProviderRegistry { return [...this.byId.values()].flatMap((e) => (e.live ? [e.live] : [])); } - /** instance snapshots for the model picker: id, driver, models, health */ - async describe() { + /** Instance snapshots for the model picker: id, driver, models, health. + * Live model discovery is opt-out for existing callers, but the HTTP picker + * route passes refreshModels:false so opening cached UI never fans out to + * unrelated CLIs or local providers. */ + async describe(options: { refreshModels?: boolean } = {}) { + const refreshModels = options.refreshModels !== false; // Multiple instances may share a driver. Scan each default binary once // per response instead of repeating filesystem work for every row. const candidatesByName = new Map(); @@ -155,7 +159,7 @@ export class ProviderRegistry { const inst = entry.live; let snapshot: ProviderSnapshot; try { - await inst.refreshModels?.(); + if (refreshModels) await inst.refreshModels?.(); snapshot = await inst.snapshot(); } catch (e) { snapshot = { state: "unavailable", reason: e instanceof Error ? e.message : String(e) }; diff --git a/server/index.test.ts b/server/index.test.ts index 3819f5b63..31a775a3b 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -18,6 +18,7 @@ import { IMAGE_MAX_BYTES } from "./attachments.ts"; const SERVER_DIR = dirname(fileURLToPath(import.meta.url)); const ROOT = join(SERVER_DIR, ".."); const FAKE_CLAUDE_CLI = join(SERVER_DIR, "testing", "fake-claude-cli.ts"); +const FAKE_ACP_CLI = join(SERVER_DIR, "testing", "fake-acp-cli.ts"); const PORT = 18800 + Math.floor(Math.random() * 10_000); const BASE = `http://127.0.0.1:${PORT}`; const WEBHOOK_PORT = 39000 + Math.floor(Math.random() * 10_000); @@ -30,8 +31,52 @@ let boxStubPort = 0; let home: string; let staticDir: string; let fakeClaudeDump: string; +let fleetCatalogPath: string; let stderr = ""; +const fleetCatalogFixture = (busy = false) => ({ + schema_version: "openmausbot-models/v1", + catalog_version: 1, + generated_at: "2026-08-22T05:00:00Z", + source: { + registry_schema_version: "aos-model-registry/v1", + registry_version: 1, + registry_sha256: "a".repeat(64), + }, + default_model_id: "fixture-fleet-model", + provider_candidates: [], + models: [{ + id: "fixture-fleet-model", + display_name: "Fixture fleet model", + kind: "model", + provider_id: "fixture", + native_model_id: "fixture-fleet-model", + capabilities: ["chat"], + host: "hosted", + cost_class: "free", + manual_only: false, + is_default: true, + selectable: !busy, + status: { + configured: true, + reachable: true, + verified: true, + admitted: true, + busy, + reason: busy ? "Fixture host is busy" : null, + last_verification_receipt: "/fixture/receipt.json", + }, + translations: { + litellm: "fixture-fleet-model", + hermes: "litellm-local:fixture-fleet-model", + opencode: "litellm-local/fixture-fleet-model", + telegram: "fixture-fleet-model", + openmausbot: "fixture-fleet-model", + }, + route_members: [], + }], +}); + const api = async (method: string, path: string, body?: unknown): Promise<{ status: number; body: any }> => { const res = await fetch(`${BASE}${path}`, { method, @@ -68,17 +113,20 @@ beforeAll(async () => { home = mkdtempSync(join(tmpdir(), "omb-api-test-")); staticDir = join(home, "static"); fakeClaudeDump = join(home, "fake-claude-dump.json"); + fleetCatalogPath = join(home, "openmausbot-models.v1.json"); // a fleet of exactly one unknown driver: no CLI probes, no network mkdirSync(join(home, ".openmausbot"), { recursive: true }); mkdirSync(join(staticDir, "assets"), { recursive: true }); writeFileSync(join(staticDir, "index.html"), "Packaged OpenMausBot"); writeFileSync(join(staticDir, "assets", "smoke.css"), "body { color: white; }"); + writeFileSync(fleetCatalogPath, JSON.stringify(fleetCatalogFixture())); writeFileSync( join(home, ".openmausbot", "config.json"), JSON.stringify({ instances: { ghost: { driver: "not-a-real-driver", displayName: "Ghost" }, claude: { driver: "claudeAgent", displayName: "Fixture Claude", config: { cli: FAKE_CLAUDE_CLI } }, + hermes: { driver: "hermesAgent", displayName: "Fixture Hermes", config: { cli: FAKE_ACP_CLI } }, }, }), ); @@ -224,6 +272,7 @@ beforeAll(async () => { OMB_STATIC_DIR: staticDir, FAKE_CLAUDE_MODE: "hang", FAKE_CLAUDE_DUMP: fakeClaudeDump, + AOS_MODEL_CATALOG_PATH: fleetCatalogPath, }, stdio: ["ignore", "pipe", "pipe"], }); @@ -373,6 +422,85 @@ describe("harness HTTP API", () => { })); }); + it("projects the cached secret-free fleet catalog and fail-closes an invalid refresh", async () => { + const first = await api("GET", "/api/instances"); + const hermes = first.body.instances.find((instance: { instanceId: string }) => instance.instanceId === "hermes"); + expect(hermes.models.options).toContainEqual(expect.objectContaining({ + id: "litellm-local:fixture-fleet-model", + canonicalId: "fixture-fleet-model", + costClass: "free", + host: "hosted", + selectable: true, + })); + + writeFileSync(fleetCatalogPath, "not-json"); + const failed = await api("POST", "/api/model-catalog/refresh"); + expect(failed.status).toBe(200); + expect(failed.body.catalog.source.state).toBe("invalid"); + const failedHermes = failed.body.instances.find((instance: { instanceId: string }) => instance.instanceId === "hermes"); + expect(failedHermes.models.options).toContainEqual(expect.objectContaining({ + canonicalId: "fixture-fleet-model", + selectable: false, + })); + + writeFileSync(fleetCatalogPath, JSON.stringify(fleetCatalogFixture())); + expect((await api("POST", "/api/model-catalog/refresh")).body.catalog.source.state).toBe("ready"); + }); + + it("translates a stable fleet id and starts one fresh task after a model change", async () => { + writeFileSync(fleetCatalogPath, JSON.stringify(fleetCatalogFixture())); + await api("POST", "/api/model-catalog/refresh"); + const defaulted = (await api("POST", "/api/bots")).body.bot; + expect(defaulted.modelSelection).toEqual({ + instanceId: "hermes", + model: "litellm-local:fixture-fleet-model", + }); + const created = (await api("PATCH", `/api/bots/${defaulted.id}`, { + modelSelection: { instanceId: "claude", model: "claude-sonnet-5" }, + })).body.bot; + const previousThread = created.threadId; + const previousTaskCount = created.tasks.length; + + const switched = await api("POST", `/api/bots/${created.id}/model`, { + instanceId: "hermes", + model: "caller-value-is-not-trusted", + canonicalId: "fixture-fleet-model", + }); + expect(switched.status).toBe(201); + expect(switched.body.changed).toBe(true); + expect(switched.body.bot.modelSelection).toEqual({ + instanceId: "hermes", + model: "litellm-local:fixture-fleet-model", + }); + expect(switched.body.bot.threadId).not.toBe(previousThread); + expect(switched.body.bot.tasks).toHaveLength(previousTaskCount + 1); + expect(switched.body.bot.messages).toEqual([]); + + const same = await api("POST", `/api/bots/${created.id}/model`, { + instanceId: "hermes", + model: "still-not-authoritative", + canonicalId: "fixture-fleet-model", + }); + expect(same.status).toBe(200); + expect(same.body.changed).toBe(false); + expect(same.body.bot.tasks).toHaveLength(previousTaskCount + 1); + + try { + writeFileSync(fleetCatalogPath, JSON.stringify(fleetCatalogFixture(true))); + await api("POST", "/api/model-catalog/refresh"); + const disabled = await api("POST", `/api/bots/${created.id}/model`, { + instanceId: "hermes", + model: "litellm-local:fixture-fleet-model", + canonicalId: "fixture-fleet-model", + }); + expect(disabled.status).toBe(409); + expect(disabled.body.error).toBe("Fixture host is busy"); + } finally { + writeFileSync(fleetCatalogPath, JSON.stringify(fleetCatalogFixture())); + await api("POST", "/api/model-catalog/refresh"); + } + }); + it("searches transcripts and exports a conversation", async () => { const bot = (await api("POST", "/api/bots")).body.bot; // every new bot opens with a seeded greeting — a known searchable string diff --git a/server/index.ts b/server/index.ts index 15f278122..cb1269baa 100644 --- a/server/index.ts +++ b/server/index.ts @@ -60,7 +60,7 @@ import { ComputerControl } from "./computer-control.ts"; import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.ts"; import { describeSpawnFailure, execCli } from "./procs.ts"; import { buildNotification, type Notification } from "./notify.ts"; -import { isEffortLevel, type RequestOutcome, type RuntimeEvent } from "./contracts.ts"; +import { EFFORT_LEVELS, isEffortLevel, type ModelSelection, type RequestOutcome, type RuntimeEvent } from "./contracts.ts"; import { BUILT_IN_DRIVERS } from "./drivers/builtIn.ts"; import { getOrCreateChannel, mirrorActivity, mirrorExchange, mirrorReply, type CommsBus } from "./comms-visibility.ts"; @@ -107,9 +107,19 @@ import { WebhookManager } from "./webhooks.ts"; import { SPAWNED_PROXIES } from "./proxy-paths.ts"; import { loadBundledSkills, renderSkillInstructions, selectBundledSkills } from "./skill-library.ts"; import { shouldMountLocalComputer } from "./local-routing.ts"; +import { + FleetModelCatalogRegistry, + projectFleetModels, +} from "./fleet-model-catalog.ts"; const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799); const WEBHOOK_PORT = Number(process.env.OMB_WEBHOOK_PORT || PORT + 1); +const modelSwitchBodySchema = z.object({ + instanceId: z.string().min(1), + model: z.string().min(1), + canonicalId: z.string().min(1).optional(), + effort: z.enum(EFFORT_LEVELS).optional(), +}).strict(); const STATIC_DIR = process.env.OMB_STATIC_DIR || null; const MIME: Record = { ".html": "text/html", @@ -126,6 +136,15 @@ ensureDirs(); const cfg = loadConfig(); const registry = new ProviderRegistry(BUILT_IN_DRIVERS); await registry.load(instanceConfigs(cfg)); +const fleetModelCatalog = new FleetModelCatalogRegistry(); +let cachedInstanceDescriptions: Awaited> | null = null; + +async function instanceDescriptions(refresh = false) { + if (refresh || !cachedInstanceDescriptions) { + cachedInstanceDescriptions = await registry.describe({ refreshModels: refresh }); + } + return cachedInstanceDescriptions; +} const bundledSkills = loadBundledSkills(); const bus = new EventBus(); @@ -239,17 +258,28 @@ function askBotAndWait(targetBotId: string, message: string, depth: number, from }); } -// default selection for new bots: first available instance, claude preferred +// Default selection for new bots: the validated fleet-wide shared default +// wins when its owning instance is available. Existing tasks retain their +// saved selection; this function is only used for new bots/tasks and reset. async function defaultSelection() { - const described = await registry.describe(); + const described = projectFleetModels( + await instanceDescriptions(false), + fleetModelCatalog.snapshot(), + ); const available = described.filter((d) => d.snapshot.state === "available"); + const sharedDefault = available.find((instance) => + instance.models.options.some((option) => option.isDefault && option.selectable !== false) + ); // Deliberately NO fallback to described[0]. Handing a bot an engine whose // CLI isn't installed makes it look ready and then fail on send with a raw // spawn ENOENT — the single worst first-run experience, and the one every // user with no CLIs used to get. An empty selection is honest: the UI shows // the setup path instead of a bot that cannot answer. - const pick = available.find((d) => d.driverKind === "claudeAgent") ?? available[0]; - return { instanceId: pick?.instanceId ?? "", model: pick?.models.default ?? "" }; + const pick = sharedDefault ?? available.find((d) => d.driverKind === "claudeAgent") ?? available[0]; + const defaultModel = pick?.models.options.find( + (option) => option.isDefault && option.selectable !== false, + )?.id ?? pick?.models.default ?? ""; + return { instanceId: pick?.instanceId ?? "", model: defaultModel }; } let bootSelection = { instanceId: "", model: "" }; const store = new Store(() => bootSelection); @@ -2255,6 +2285,7 @@ async function reloadProviders() { bus.detachAll(); await registry.disposeAll(); await registry.load(instanceConfigs(cfg)); + cachedInstanceDescriptions = null; bus.attach(registry.instances()); // A killed turn's terminal events can die with the old fleet (dispose is // async under the hood), stranding the bot busy — and its screen poller — @@ -3712,6 +3743,71 @@ const server = createServer(async (req, res) => { tasks: store.tasks(bot.id).map(wireTask), }); + // A picker model change is one server-owned transition: resolve the + // stable catalog id to this driver's native id, enforce cached admission, + // persist the selection, then move onto a provider-session-isolated task. + // This avoids a PATCH/POST race where the new task could start with the + // previous engine or model. + m = path.match(/^\/api\/bots\/([\w-]+)\/model$/); + if (m && method === "POST") { + const bot = store.bot(m[1]); + if (!bot) return json(res, 404, { error: "no such bot" }); + if (bot.busy) { + return json(res, 409, { error: "this bot is working — let it finish before changing model" }); + } + const parsedBody = modelSwitchBodySchema.safeParse(await readBody(req)); + if (!parsedBody.success) { + return json(res, 400, { error: "instanceId, model, optional canonicalId, and optional effort must be valid" }); + } + const body = parsedBody.data; + const { instanceId, model: requestedModel, canonicalId } = body; + + if (!cachedInstanceDescriptions) { + return json(res, 409, { error: "engine inventory is not loaded — refresh engines first" }); + } + const instances = projectFleetModels(cachedInstanceDescriptions, fleetModelCatalog.snapshot()); + const target = instances.find((instance) => instance.instanceId === instanceId); + if (!target || target.snapshot.state !== "available" || !registry.get(instanceId)) { + return json(res, 409, { + error: target?.snapshot.reason ?? `provider instance "${instanceId}" is unavailable`, + }); + } + const option = target.models.options.find((candidate) => + canonicalId ? candidate.canonicalId === canonicalId : candidate.id === requestedModel + ); + if (!option) { + return json(res, 400, { error: "that model is not in the cached catalog for this engine" }); + } + if (option.selectable === false) { + return json(res, 409, { error: option.reason ?? "that model is not currently selectable" }); + } + if (body.effort !== undefined) { + const allowed: readonly string[] = target.capabilities.effortLevels ?? []; + if (!allowed.includes(body.effort)) { + return json(res, 400, { error: `effort "${body.effort}" is not offered by this bot's engine` }); + } + } + const selection: ModelSelection = { + instanceId, + model: option.id, + }; + if (body.effort !== undefined) selection.effort = body.effort; + if ( + bot.modelSelection.instanceId === selection.instanceId && + bot.modelSelection.model === selection.model && + bot.modelSelection.effort === selection.effort + ) { + return json(res, 200, { bot: botWithThread(bot), task: null, changed: false }); + } + const updated = store.patchBot(bot.id, { modelSelection: selection }); + if (!updated) return json(res, 404, { error: "no such bot" }); + const task = store.createTask(bot.id); + if (!task) return json(res, 500, { error: "couldn't create a fresh task for that model" }); + const fresh = botWithThread(store.bot(bot.id)!); + broadcast({ kind: "bot", bot: fresh }); + return json(res, 201, { bot: fresh, task: wireTask(task), changed: true }); + } + m = path.match(/^\/api\/bots\/([\w-]+)\/tasks$/); if (m && method === "POST") { const bot = store.bot(m[1]); @@ -3912,12 +4008,35 @@ const server = createServer(async (req, res) => { // ── provider instances (model picker) ── if (method === "GET" && path === "/api/instances") { - // Rescan PATH first: this endpoint is how the app answers "what can I - // run?", and the interesting case is a CLI installed since launch. - // Windows never pushes PATH changes into a live process, so without - // this the answer is frozen at boot and "check again" is a no-op. - resetPathCache(); - return json(res, 200, { instances: await registry.describe() }); + // Cached by default: opening the model picker or restoring app state + // must not fan out to every CLI/provider. Setup screens opt into a live + // provider refresh with ?refresh=1; the fleet-catalog picker has its own + // file-only POST below. + const refreshModels = url.searchParams.get("refresh") === "1"; + if (refreshModels) resetPathCache(); + const instances = projectFleetModels( + await instanceDescriptions(refreshModels), + fleetModelCatalog.snapshot(), + ); + return json(res, 200, { instances }); + } + + if (method === "GET" && path === "/api/model-catalog") { + return json(res, 200, { catalog: fleetModelCatalog.snapshot() }); + } + + if (method === "POST" && path === "/api/model-catalog/refresh") { + // Reload only the guarded projection. Admission/discovery is owned by + // its producer; this action does not probe a provider or model host. + const catalog = fleetModelCatalog.refresh(); + if (!cachedInstanceDescriptions) { + return json(res, 409, { + error: "engine inventory is not loaded — load instances before refreshing the model catalog", + catalog, + }); + } + const instances = projectFleetModels(cachedInstanceDescriptions, catalog); + return json(res, 200, { catalog, instances }); } // ── CLI binary discovery for the Engines "detected" dropdown ── @@ -3980,7 +4099,9 @@ const server = createServer(async (req, res) => { // from the memoized PATH, so resetting after would answer this request // with the pre-reset cache resetPathCache(); - return json(res, 200, { instances: await registry.describe() }); + return json(res, 200, { + instances: projectFleetModels(await instanceDescriptions(true), fleetModelCatalog.snapshot()), + }); } finally { providerConfigBusy = false; } diff --git a/server/tasks.test.ts b/server/tasks.test.ts index 6c0142679..997a5f18d 100644 --- a/server/tasks.test.ts +++ b/server/tasks.test.ts @@ -51,6 +51,25 @@ describe("tasks", () => { expect(store.messagesFor(firstThread).length).toBeGreaterThan(0); }); + it("keeps a changed model on the fresh task without carrying its provider session", async () => { + const { store } = await freshStore(); + const bot = store.createBot(); + const firstThread = bot.threadId; + store.setResumeCursor(bot.id, "claude", "old-provider-session"); + store.patchBot(bot.id, { + modelSelection: { instanceId: "hermes", model: "litellm-local:minimax-m3-light" }, + }); + + const task = store.createTask(bot.id)!; + expect(task.threadId).not.toBe(firstThread); + expect(store.bot(bot.id)?.modelSelection).toEqual({ + instanceId: "hermes", + model: "litellm-local:minimax-m3-light", + }); + expect(store.activeTask(bot.id)?.resumeCursors).toEqual({}); + expect(store.taskByThread(bot.id, firstThread)?.resumeCursors.claude).toBe("old-provider-session"); + }); + it("can create a detached routine task without changing the visible conversation", async () => { const { store } = await freshStore(); const bot = store.createBot(); diff --git a/src/components/ModelPicker.tsx b/src/components/ModelPicker.tsx index f98047d7a..278d5d1a7 100644 --- a/src/components/ModelPicker.tsx +++ b/src/components/ModelPicker.tsx @@ -2,7 +2,7 @@ // show a short suggested list with search and an explicit all-models view; // engines that need setup show one focused action instead of a disabled wall. import { useEffect, useRef, useState } from "react"; -import { Check, ChevronDown, ChevronLeft, ChevronRight, Search } from "lucide-react"; +import { Check, ChevronDown, ChevronLeft, ChevronRight, RefreshCw, Search } from "lucide-react"; import { useStore, type Bot, type InstanceInfo, type ModelSelection } from "@/state/store"; import { filterCustomModels, partitionCustomModels, suggestedModels } from "@/lib/custom-models"; import { isCustomOnly, splitEngineRail } from "@/lib/engine-rail"; @@ -11,6 +11,7 @@ import { EngineSetup, needsCli, needsSignIn } from "./EngineSetup"; import { EngineGroupLabel } from "./EngineGroupLabel"; import { cn } from "@/lib/cn"; import { COMPACT_SQUARE } from "@/lib/compact-chip"; +import { modelMetadata, modelReadinessLabel, modelSelectable } from "@/lib/model-catalog"; type ModelOption = InstanceInfo["models"]["options"][number]; const COMPACT_MODEL_COUNT = 5; @@ -29,32 +30,48 @@ function ModelRow({ option, current, defaultId, + blockedReason, onPick, }: { option: ModelOption; current: boolean; defaultId: string; + blockedReason?: string; onPick: () => void; }) { + const selectable = modelSelectable(option) && !blockedReason; + const unavailableReason = blockedReason ?? option.reason; + const metadata = modelMetadata(option); return ( ); } @@ -92,12 +109,13 @@ function ModelSearch({ } export function ModelPicker({ bot, className }: { bot: Bot; className?: string }) { - const { state, dispatch, refreshInstances } = useStore(); + const { state, dispatch, refreshModelCatalog } = useStore(); const [open, setOpen] = useState(false); const [railId, setRailId] = useState(null); const [pane, setPane] = useState<"main" | "custom">("main"); const [query, setQuery] = useState(""); const [showAll, setShowAll] = useState(false); + const [refreshing, setRefreshing] = useState(false); const rootRef = useRef(null); const selection = bot.modelSelection; @@ -105,10 +123,6 @@ export function ModelPicker({ bot, className }: { bot: Bot; className?: string } const railInstance = state.instances.find((instance) => instance.instanceId === (railId ?? selection.instanceId)) ?? state.instances[0]; - useEffect(() => { - if (open) void refreshInstances(); - }, [open, refreshInstances]); - useEffect(() => { if (!open) return; const closeOnOutsideClick = (event: MouseEvent) => { @@ -151,7 +165,13 @@ export function ModelPicker({ bot, className }: { bot: Bot; className?: string } }; const pick = (instance: InstanceInfo, model: string) => { + const option = instance.models.options.find((candidate) => candidate.id === model); + if (bot.busy || (option && !modelSelectable(option))) return; const sameInstance = instance.instanceId === selection.instanceId; + if (sameInstance && selection.model === model) { + setOpen(false); + return; + } const nextSelection: ModelSelection = { instanceId: instance.instanceId, model, @@ -161,12 +181,15 @@ export function ModelPicker({ bot, className }: { bot: Bot; className?: string } type: "setModel", botId: bot.id, selection: nextSelection, + canonicalId: option?.canonicalId, + freshTask: true, }); setOpen(false); }; const official = railInstance?.models.options.filter((option) => !option.custom) ?? []; const custom = railInstance?.models.options.filter((option) => option.custom) ?? []; + const availableCustom = custom.filter(modelSelectable).length; const currentModel = selection.instanceId === railInstance?.instanceId ? selection.model : undefined; const filteredOfficial = filterCustomModels(official, query); const compactOfficial = railInstance @@ -177,11 +200,14 @@ export function ModelPicker({ bot, className }: { bot: Bot; className?: string } const { pinned, rest } = partitionCustomModels(filteredCustom); const blocked = railInstance ? pane === "custom" - ? needsCli(railInstance) + ? custom.length === 0 && needsCli(railInstance) : needsCli(railInstance) || needsSignIn(railInstance) : false; - const canOpenCustom = Boolean(railInstance && !needsCli(railInstance)); + const canOpenCustom = Boolean(railInstance && (custom.length > 0 || !needsCli(railInstance))); const canReturnToOfficial = official.length > 0 && !isCustomOnly(railInstance); + const engineUnavailableReason = railInstance?.snapshot.state === "available" + ? undefined + : railInstance?.snapshot.reason ?? "This engine is unavailable."; const renderRow = (option: ModelOption) => ( railInstance && pick(railInstance, option.id)} + blockedReason={bot.busy ? "Let this task finish before changing model." : engineUnavailableReason} + onPick={() => railInstance && modelSelectable(option) && pick(railInstance, option.id)} /> ); @@ -280,18 +307,33 @@ export function ModelPicker({ bot, className }: { bot: Bot; className?: string }
{railInstance.displayName}
- - {pane === "custom" && !blocked ? "Local models" : engineStatus(railInstance)} + + + + {pane === "custom" && !engineUnavailableReason ? "Fleet catalog" : engineStatus(railInstance)} +
{pane === "custom" - ? "Run this agent with a model already on your machine." + ? "Choose an admitted hosted or local fleet model." : "Choose a model for this bot."}
@@ -379,9 +421,9 @@ export function ModelPicker({ bot, className }: { bot: Bot; className?: string } {rest.map(renderRow)} {custom.length === 0 && (
-
No local models found
+
No fleet models found
- Start oMLX, Ollama, Unsloth, LM Studio, or EXO, then reopen this picker. + Update the canonical catalog, then use Refresh above.
)} @@ -400,7 +442,7 @@ export function ModelPicker({ bot, className }: { bot: Bot; className?: string }