diff --git a/server/drivers/acp/acp.test.ts b/server/drivers/acp/acp.test.ts index ff3dabfb8..ca3526597 100644 --- a/server/drivers/acp/acp.test.ts +++ b/server/drivers/acp/acp.test.ts @@ -562,6 +562,39 @@ describe("ACP turns (fake CLI)", () => { expect(done).toMatchObject({ ok: true }); }); + it("applyTurnEnv sees the picker model after resolveTurnModel", async () => { + const dump = join(scratch, "turn-env.json"); + process.env.FAKE_ACP_DUMP = dump; + const TurnEnvDriver = createAcpDriver({ + ...SELECT_MODEL_SUPPORT, + driverKind: "turnEnvTest", + selectModel: undefined, + resolveTurnModel: (model) => (model ? `resolved/${model}` : model), + applyTurnEnv: (env, { model, requestedModel }) => { + env.TEST_TURN_MODEL = `${model ?? ""}|${requestedModel ?? ""}`; + }, + }); + instance = await TurnEnvDriver.create({ + instanceId: "turn-env-test", + displayName: undefined, + environment: {}, + enabled: true, + config: { cli: FAKE_CLI, fullAuto: false }, + }); + recorder = recordEvents(instance.adapter); + + await instance.adapter.sendTurn({ + threadId: "t-turn-env", + text: "go", + model: "ollama::ornith:35b-bf16", + }); + await recorder.until((e) => e.type === "turn.completed"); + + expect(JSON.parse(readFileSync(dump, "utf8")).env.TEST_TURN_MODEL).toBe( + "resolved/ollama::ornith:35b-bf16|ollama::ornith:35b-bf16", + ); + }); + it("transformEnv sees the instance config", async () => { const dump = join(scratch, "policy.json"); process.env.FAKE_ACP_DUMP = dump; diff --git a/server/drivers/acp/core.ts b/server/drivers/acp/core.ts index 52f5b0d1a..b175ec880 100644 --- a/server/drivers/acp/core.ts +++ b/server/drivers/acp/core.ts @@ -92,6 +92,12 @@ export interface AcpSupport { /** Mutate the child env in place: strip a key, inject a policy. Receives the * instance config so a support can vary with fullAuto. */ transformEnv?(env: Record, config: AcpConfig): void; + /** Mutate the child env after the turn model is known. Catalog refresh and + * snapshot share `transformEnv` and must not see a per-turn overlay. */ + applyTurnEnv?( + env: Record, + ctx: { model?: string; requestedModel?: string }, + ): void; /** Pick the ACP authenticate methodId from initialize's advertised * authMethods; return null to skip the authenticate step. */ pickAuthMethod(authMethods: Array<{ id?: string }>): string | null; @@ -278,6 +284,7 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver const cwd = turn.cwd ?? config.workspace ?? homedir(); const env = childEnv(); const resolvedModel = support.resolveTurnModel?.(turn.model, env); + support.applyTurnEnv?.(env, { model: resolvedModel, requestedModel: turn.model }); const cliTurn = resolvedModel !== undefined && resolvedModel !== turn.model ? { ...turn, model: resolvedModel } diff --git a/server/drivers/acp/droid.ts b/server/drivers/acp/droid.ts index 79a0e9e81..e3f0e468e 100644 --- a/server/drivers/acp/droid.ts +++ b/server/drivers/acp/droid.ts @@ -118,6 +118,24 @@ export function ensureDroidInjectModel( return id; } +/** ACP `session/new` throws "Authentication required" unless a Factory + * login or FACTORY_API_KEY is present — even for a BYOK custom model. + * Droid 0.198 only checks that the env var is set, then uses the + * custom row's own key for the local host. Do not invent a key for + * subscription models, and do not overwrite a real Factory key. */ +export function applyDroidLocalAuthEnv( + env: Record, + modelId: string | undefined, +): void { + if (!decodeInjectId(modelId)) return; + if (env.FACTORY_API_KEY?.trim()) return; + // session/new already succeeds on a Factory login file. A placeholder + // FACTORY_API_KEY can take precedence over that login, so leave env + // alone when one of the auth files is present. + if (authFilePaths(env).some(existsSync)) return; + env.FACTORY_API_KEY = "openmausbot-local"; +} + function readSettings(env: Record): FactorySettings { return JSON.parse(readFileSync(join(factoryHome(env), ".factory", "settings.json"), "utf8")) as FactorySettings; } @@ -233,6 +251,9 @@ const support: AcpSupport = { isAuthenticated: (env) => authFilePaths(env).some(existsSync) || Boolean(env.FACTORY_API_KEY), resolveModels, resolveTurnModel: (model, env) => (model ? ensureDroidInjectModel(model, env) : model), + applyTurnEnv: (env, { requestedModel }) => { + applyDroidLocalAuthEnv(env, requestedModel); + }, async configureSession({ request, sessionId, config, turn }) { const modeId = config.fullAuto ? MODE_FULL_AUTO : MODE_DEFAULT; diff --git a/server/drivers/acp/kimi.ts b/server/drivers/acp/kimi.ts index 28dd43450..a3d2a3759 100644 --- a/server/drivers/acp/kimi.ts +++ b/server/drivers/acp/kimi.ts @@ -39,17 +39,299 @@ function credentialsPath(env: Record) { return join(kimiDataRoot(env), "credentials", "kimi-code.json"); } +/** Quote a TOML string value. */ function quoteToml(value: string): string { return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } +/** Quote a TOML key when it is not a bare identifier. */ function quoteTomlKey(key: string): string { if (/^[A-Za-z0-9_-]+$/.test(key)) return key; return quoteToml(key); } +/** Strip a `#` comment that is not inside a quoted string. */ +function stripTomlLineComment(line: string): string { + let quote: '"' | "'" | null = null; + for (let i = 0; i < line.length; i++) { + const c = line[i]!; + if (quote) { + if (quote === '"' && c === "\\") { + i += 1; + continue; + } + if (c === quote) quote = null; + continue; + } + if (c === "#") return line.slice(0, i); + if (c === '"' || c === "'") quote = c; + } + return line; +} + +/** Decode a TOML basic-string escape at `text[i]` (`i` points at the `\\`). */ +function takeTomlBasicEscape(text: string, i: number): { value: string; next: number } { + const code = text[i + 1]; + if (code === "u") { + const hex = text.slice(i + 2, i + 6); + if (/^[0-9a-fA-F]{4}$/.test(hex)) { + return { value: String.fromCharCode(parseInt(hex, 16)), next: i + 6 }; + } + } + if (code === "U") { + const hex = text.slice(i + 2, i + 10); + if (/^[0-9a-fA-F]{8}$/.test(hex)) { + const point = parseInt(hex, 16); + return { value: point <= 0x10ffff ? String.fromCodePoint(point) : "", next: i + 10 }; + } + } + const named: Record = { + b: "\b", + t: "\t", + n: "\n", + f: "\f", + r: "\r", + '"': '"', + "\\": "\\", + }; + return { value: named[code ?? ""] ?? code ?? "", next: i + 2 }; +} + +/** Canonical `a.b.c` form of a `[table]` heading, quotes and comments removed. */ +function canonicalizeTomlHeading(heading: string): string | null { + const trimmed = stripTomlLineComment(heading).trim(); + const match = trimmed.match(/^\[([^[\]]+)\]$/); + if (!match) return null; + const parts: string[] = []; + const inner = match[1]!; + let i = 0; + const skipSep = () => { + while (i < inner.length && (inner[i] === "." || inner[i] === " " || inner[i] === "\t")) i += 1; + }; + skipSep(); + while (i < inner.length) { + const q = inner[i]; + if (q === '"' || q === "'") { + i += 1; + let value = ""; + while (i < inner.length && inner[i] !== q) { + if (q === '"' && inner[i] === "\\") { + const taken = takeTomlBasicEscape(inner, i); + value += taken.value; + i = taken.next; + continue; + } + value += inner[i]; + i += 1; + } + if (inner[i] === q) i += 1; + parts.push(value); + skipSep(); + continue; + } + let value = ""; + while (i < inner.length && inner[i] !== ".") { + value += inner[i]; + i += 1; + } + const part = value.trim(); + if (part) parts.push(part); + skipSep(); + } + return parts.length ? parts.join(".") : null; +} + +/** Unwrap `"key"` / `'key'` so a quoted assignment matches the bare name. */ +function unquoteTomlKey(raw: string): string { + const key = raw.trim(); + if (key.length >= 2 && ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'")))) { + return key.slice(1, -1); + } + return key; +} + +/** Bare key on the left of `key = value`. */ +function tomlRowKey(row: string): string { + const eq = row.indexOf("="); + return unquoteTomlKey(eq < 0 ? row : row.slice(0, eq)); +} + +/** Walk `text` and yield `[table]` spans. `[[array]]` headings bound a table + * but are not themselves patchable. `#` comments in `out` mode are skipped + * so an apostrophe in a comment cannot open a phantom string. */ +function tomlTables(text: string): Array<{ name: string; headingStart: number; bodyStart: number; end: number }> { + type Mode = "out" | "basic" | "literal" | "mlbasic" | "mllit"; + const headings: Array<{ name: string | null; patchable: boolean; lineStart: number; lineEnd: number }> = []; + let mode: Mode = "out"; + let i = 0; + const atLineStart = (idx: number) => idx === 0 || text[idx - 1] === "\n"; + while (i < text.length) { + if (mode === "mlbasic") { + if (text.startsWith('"""', i)) { + mode = "out"; + i += 3; + continue; + } + i += 1; + continue; + } + if (mode === "mllit") { + if (text.startsWith("'''", i)) { + mode = "out"; + i += 3; + continue; + } + i += 1; + continue; + } + if (mode === "basic") { + if (text[i] === "\\") { + i += 2; + continue; + } + if (text[i] === '"') mode = "out"; + i += 1; + continue; + } + if (mode === "literal") { + if (text[i] === "'") mode = "out"; + i += 1; + continue; + } + if (text.startsWith('"""', i)) { + mode = "mlbasic"; + i += 3; + continue; + } + if (text.startsWith("'''", i)) { + mode = "mllit"; + i += 3; + continue; + } + if (text[i] === '"') { + mode = "basic"; + i += 1; + continue; + } + if (text[i] === "'") { + mode = "literal"; + i += 1; + continue; + } + if (text[i] === "#") { + const nl = text.indexOf("\n", i); + i = nl < 0 ? text.length : nl + 1; + continue; + } + if (atLineStart(i)) { + let j = i; + while (j < text.length && (text[j] === " " || text[j] === "\t")) j += 1; + if (text[j] === "[") { + const nl = text.indexOf("\n", j); + const lineEnd = nl < 0 ? text.length : nl; + const raw = text.slice(j, lineEnd).replace(/\r$/, ""); + const stripped = stripTomlLineComment(raw).trim(); + const array = stripped.startsWith("[["); + const name = array + ? canonicalizeTomlHeading(`[${stripped.replace(/^\s*\[\[/, "").replace(/\]\]\s*$/, "")}]`) + : canonicalizeTomlHeading(raw); + headings.push({ name, patchable: !array && name !== null, lineStart: i, lineEnd }); + i = lineEnd + (nl < 0 ? 0 : 1); + continue; + } + } + i += 1; + } + return headings + .map((heading, index) => ({ + heading, + end: index + 1 < headings.length ? headings[index + 1]!.lineStart : text.length, + })) + .filter((entry) => entry.heading.patchable && entry.heading.name) + .map((entry) => ({ + name: entry.heading.name!, + headingStart: entry.heading.lineStart, + bodyStart: entry.heading.lineEnd + (text[entry.heading.lineEnd] === "\n" ? 1 : 0), + end: entry.end, + })); +} + +/** Keys assigned at line start in a table body, including `"quoted"` keys. */ +function tomlKeys(block: string): Set { + const keys = new Set(); + type Mode = "out" | "basic" | "literal" | "mlbasic" | "mllit"; + let mode: Mode = "out"; + let lineStart = 0; + let lineStartMode: Mode = "out"; + const take = (end: number) => { + if (lineStartMode !== "out") return; + const line = stripTomlLineComment(block.slice(lineStart, end)); + const eq = line.indexOf("="); + if (eq > 0) keys.add(unquoteTomlKey(line.slice(0, eq))); + }; + for (let i = 0; i < block.length; i++) { + if (mode === "mlbasic") { + if (block.startsWith('"""', i)) { + mode = "out"; + i += 2; + } + } else if (mode === "mllit") { + if (block.startsWith("'''", i)) { + mode = "out"; + i += 2; + } + } else if (mode === "basic") { + if (block[i] === "\\") i += 1; + else if (block[i] === '"') mode = "out"; + } else if (mode === "literal") { + if (block[i] === "'") mode = "out"; + } else if (block[i] === "#") { + const nl = block.indexOf("\n", i); + i = nl < 0 ? block.length : nl; + if (nl < 0) break; + } else if (block.startsWith('"""', i)) { + mode = "mlbasic"; + i += 2; + } else if (block.startsWith("'''", i)) { + mode = "mllit"; + i += 2; + } else if (block[i] === '"') { + mode = "basic"; + } else if (block[i] === "'") { + mode = "literal"; + } + if (i < block.length && block[i] === "\n") { + take(i); + lineStart = i + 1; + if (mode === "basic" || mode === "literal") mode = "out"; + lineStartMode = mode; + } + } + take(block.length); + return keys; +} + +/** Whether `text` already has this table, ignoring quotes and trailing comments. */ function hasTomlTable(text: string, heading: string): boolean { - return text.split(/\r?\n/).some((line) => line.trim() === heading); + const name = canonicalizeTomlHeading(heading); + return name !== null && tomlTables(text).some((table) => table.name === name); +} + +/** Insert missing keys into an existing table. Does not overwrite set values. */ +function patchTomlTable(text: string, heading: string, rows: string[]): string { + const name = canonicalizeTomlHeading(heading); + if (!name) return text; + const table = tomlTables(text).find((entry) => entry.name === name); + if (!table) return text; + const keys = tomlKeys(text.slice(table.bodyStart, table.end)); + const missing = rows.filter((row) => !keys.has(tomlRowKey(row))); + if (!missing.length) return text; + let insertAt = table.end; + while (insertAt > table.bodyStart && (text[insertAt - 1] === "\n" || text[insertAt - 1] === "\r")) insertAt -= 1; + const before = text.slice(0, insertAt); + const after = text.slice(insertAt); + const pad = before.endsWith("\n") || before.length === 0 ? "" : "\n"; + return `${before}${pad}${missing.join("\n")}${after.startsWith("\n") ? "" : "\n"}${after}`; } /** Write [providers.host] + [models."host/alias"] so `kimi -m` hits the local host. */ @@ -72,6 +354,7 @@ export function ensureKimiInjectAlias( } catch { text = ""; } + const original = text; const providerHeading = `[providers.${inject.host}]`; const modelHeading = `[models.${quoteTomlKey(alias)}]`; @@ -87,18 +370,69 @@ export function ensureKimiInjectAlias( ].join("\n"), ); } - if (!hasTomlTable(text, modelHeading)) { + // Kimi 0.36+ refuses openai_legacy as a wire protocol; ACP then + // skips default-model binding and falls through to OAuth. Patch + // aliases written before those keys existed; do not overwrite a + // user's protocol or context size. + if (hasTomlTable(text, modelHeading)) { + text = patchTomlTable(text, modelHeading, [`protocol = "openai"`, `max_context_size = 262144`]); + } else { blocks.push( - [modelHeading, `provider = ${quoteToml(inject.host)}`, `model = ${quoteToml(inject.model)}`, ""].join("\n"), + [ + modelHeading, + `provider = ${quoteToml(inject.host)}`, + `model = ${quoteToml(inject.model)}`, + `protocol = "openai"`, + `max_context_size = 262144`, + "", + ].join("\n"), ); } if (blocks.length) { const prefix = text && !text.endsWith("\n") ? `${text}\n\n` : text ? `${text}\n` : ""; writeFileSync(path, `${prefix}${blocks.join("\n")}`); + } else if (text !== original) { + writeFileSync(path, text); } return alias; } +/** Env keys Kimi 0.36+ reads to synthesize an in-memory default model. + * ACP `session/new` runs auth readiness against `default_model`, not `-m`. + * Without a default, a missing/expired `kimi login` becomes + * "Authentication required" even when the picker is a local host. */ +const KIMI_MODEL_ENV = [ + "KIMI_MODEL_NAME", + "KIMI_MODEL_API_KEY", + "KIMI_MODEL_BASE_URL", + "KIMI_MODEL_PROVIDER_TYPE", + "KIMI_MODEL_DISPLAY_NAME", +] as const; + +/** Overlay a local inject as Kimi's in-memory default. Does not write + * config.toml — Kimi strips these reserved entries on persist. */ +export function applyKimiLocalModelEnv( + env: Record, + modelId: string | undefined, +): void { + const inject = decodeInjectId(modelId); + if (!inject) return; + const host = localHost(inject.host); + if (!host) return; + env.KIMI_MODEL_NAME = inject.model; + env.KIMI_MODEL_API_KEY = hostApiKey(host, env); + env.KIMI_MODEL_BASE_URL = host.baseUrl; + // Env overlay accepts openai | anthropic | kimi — not the toml + // openai_legacy type we write for the on-disk provider row. + env.KIMI_MODEL_PROVIDER_TYPE = "openai"; + env.KIMI_MODEL_DISPLAY_NAME = `${inject.model} (${host.label})`; +} + +/** Drop leftover shell `KIMI_MODEL_*` so they cannot steal a cloud turn. */ +function stripKimiModelEnv(env: Record): void { + for (const key of KIMI_MODEL_ENV) delete env[key]; +} + function readKimiModelCatalog(env: Record): ModelCatalog { const dataRoot = kimiDataRoot(env); let text = ""; @@ -180,6 +514,12 @@ const support: AcpSupport = { transformEnv: (env) => { delete env.MOONSHOT_API_KEY; delete env.KIMI_API_KEY; + // A leftover shell overlay would steal every Kimi turn, including + // subscription models. applyTurnEnv puts the inject back per turn. + stripKimiModelEnv(env); + }, + applyTurnEnv: (env, { requestedModel }) => { + applyKimiLocalModelEnv(env, requestedModel); }, // The only advertised authMethod is {id:"login", type:"terminal"} — a diff --git a/server/drivers/local-inject-matrix.test.ts b/server/drivers/local-inject-matrix.test.ts index 3ec29272e..078d2fb82 100644 --- a/server/drivers/local-inject-matrix.test.ts +++ b/server/drivers/local-inject-matrix.test.ts @@ -125,6 +125,36 @@ describe("host credentials", () => { writeFileSync(join(home, ".unsloth", "studio", "auth", "agent_api_key.json"), JSON.stringify({ api_key: "from-file" })); expect(hostApiKey(localHost("unsloth")!, { HOME: home })).toBe("from-file"); }); + + it("reads a minted Unsloth Studio token from the servers map", () => { + const home = scratchHome("omb-unsloth-minted-"); + mkdirSync(join(home, ".unsloth", "studio", "auth"), { recursive: true }); + writeFileSync( + join(home, ".unsloth", "studio", "auth", "agent_api_key.json"), + JSON.stringify({ + servers: { + "http://127.0.0.1:8888": { saved: [], minted: ["sk-unsloth-minted"] }, + }, + }), + ); + expect(hostApiKey(localHost("unsloth")!, { HOME: home })).toBe("sk-unsloth-minted"); + expect(hostApiKey(localHost("unsloth_api")!, { HOME: home })).toBe("sk-unsloth-minted"); + }); + + it("prefers a localhost minted token over a stale top-level api_key", () => { + const home = scratchHome("omb-unsloth-mixed-"); + mkdirSync(join(home, ".unsloth", "studio", "auth"), { recursive: true }); + writeFileSync( + join(home, ".unsloth", "studio", "auth", "agent_api_key.json"), + JSON.stringify({ + api_key: "stale-legacy", + servers: { + "http://127.0.0.1:8888": { saved: [], minted: ["sk-unsloth-fresh"] }, + }, + }), + ); + expect(hostApiKey(localHost("unsloth")!, { HOME: home })).toBe("sk-unsloth-fresh"); + }); }); describe("OpenAI / Anthropic env dialects", () => { diff --git a/server/drivers/local-inject.test.ts b/server/drivers/local-inject.test.ts index 34f456d2a..5aff8ab89 100644 --- a/server/drivers/local-inject.test.ts +++ b/server/drivers/local-inject.test.ts @@ -4,9 +4,9 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; -import { DroidAgentDriver, ensureDroidInjectModel } from "./acp/droid.ts"; +import { applyDroidLocalAuthEnv, DroidAgentDriver, ensureDroidInjectModel } from "./acp/droid.ts"; import { ensureGrokInjectSlug, GrokAgentDriver } from "./acp/grok.ts"; -import { ensureKimiInjectAlias, KimiAgentDriver } from "./acp/kimi.ts"; +import { applyKimiLocalModelEnv, ensureKimiInjectAlias, KimiAgentDriver } from "./acp/kimi.ts"; import { ensureOpenCodeInjectModel } from "./acp/opencode-go.ts"; import { AntigravityDriver } from "./antigravity.ts"; @@ -303,6 +303,257 @@ describe("ensureKimiInjectAlias", () => { expect(text.match(/\[providers\.omlx\]/g)?.length).toBe(1); expect(text).toContain(`base_url = "http://127.0.0.1:8080/v1"`); expect(text).toContain(`model = "GLM-5.2-fp8"`); + expect(text).toContain(`protocol = "openai"`); + expect(text).toContain(`max_context_size = 262144`); + }); + + it("amends an existing alias with protocol and context size and leaves user keys", () => { + const home = mkdtempSync(join(tmpdir(), "omb-kimi-patch-")); + scratchDirs.push(home); + const root = join(home, ".kimi-code"); + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, "config.toml"), + [ + "[[hooks]]", + 'event = "Stop"', + "", + "[providers.omlx]", + 'type = "openai_legacy"', + 'base_url = "http://127.0.0.1:8080/v1"', + 'api_key = "omlx"', + "", + '[models."omlx/GLM-5.2-fp8"]', + 'provider = "omlx"', + 'model = "GLM-5.2-fp8"', + 'display_name = "keep me"', + "", + ].join("\n"), + ); + expect(ensureKimiInjectAlias("omlx::GLM-5.2-fp8", { HOME: home })).toBe("omlx/GLM-5.2-fp8"); + expect(ensureKimiInjectAlias("omlx::GLM-5.2-fp8", { HOME: home })).toBe("omlx/GLM-5.2-fp8"); + const text = readFileSync(join(root, "config.toml"), "utf8"); + expect(text).toContain("[[hooks]]"); + expect(text).toContain('display_name = "keep me"'); + expect(text).toContain('provider = "omlx"'); + expect(text).toContain('model = "GLM-5.2-fp8"'); + expect(text.match(/protocol = "openai"/g)?.length).toBe(1); + expect(text.match(/max_context_size = 262144/g)?.length).toBe(1); + }); + + it("does not overwrite a user's protocol or context size", () => { + const home = mkdtempSync(join(tmpdir(), "omb-kimi-keep-")); + scratchDirs.push(home); + const root = join(home, ".kimi-code"); + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, "config.toml"), + [ + '[models."omlx/GLM-5.2-fp8"]', + 'provider = "omlx"', + 'model = "GLM-5.2-fp8"', + 'protocol = "openai_responses"', + "max_context_size = 8192", + "", + ].join("\n"), + ); + ensureKimiInjectAlias("omlx::GLM-5.2-fp8", { HOME: home }); + const text = readFileSync(join(root, "config.toml"), "utf8"); + expect(text).toContain('protocol = "openai_responses"'); + expect(text).toContain("max_context_size = 8192"); + expect(text).not.toContain('protocol = "openai"'); + expect(text).not.toContain("max_context_size = 262144"); + }); + + it("treats a quoted protocol key as already set and does not duplicate it", () => { + const home = mkdtempSync(join(tmpdir(), "omb-kimi-quoted-")); + scratchDirs.push(home); + const root = join(home, ".kimi-code"); + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, "config.toml"), + [ + '[models."omlx/GLM-5.2-fp8"]', + 'provider = "omlx"', + 'model = "GLM-5.2-fp8"', + '"protocol" = "openai"', + "", + ].join("\n"), + ); + ensureKimiInjectAlias("omlx::GLM-5.2-fp8", { HOME: home }); + const text = readFileSync(join(root, "config.toml"), "utf8"); + expect(text.match(/protocol/g)?.length).toBe(1); + expect(text).toContain("max_context_size = 262144"); + }); + + it("finds a heading with a trailing comment and does not append a second table", () => { + const home = mkdtempSync(join(tmpdir(), "omb-kimi-heading-")); + scratchDirs.push(home); + const root = join(home, ".kimi-code"); + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, "config.toml"), + ['[models."omlx/GLM-5.2-fp8"] # keep', 'provider = "omlx"', 'model = "GLM-5.2-fp8"', ""].join("\n"), + ); + ensureKimiInjectAlias("omlx::GLM-5.2-fp8", { HOME: home }); + const text = readFileSync(join(root, "config.toml"), "utf8"); + expect(text.match(/\[models\./g)?.length).toBe(1); + expect(text).toContain("# keep"); + expect(text).toContain('protocol = "openai"'); + }); + + it("does not hide a model table behind an apostrophe in a preceding comment", () => { + const home = mkdtempSync(join(tmpdir(), "omb-kimi-apos-")); + scratchDirs.push(home); + const root = join(home, ".kimi-code"); + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, "config.toml"), + [ + "# user's setting", + '[models."omlx/GLM-5.2-fp8"]', + 'provider = "omlx"', + 'model = "GLM-5.2-fp8"', + "", + ].join("\n"), + ); + ensureKimiInjectAlias("omlx::GLM-5.2-fp8", { HOME: home }); + const text = readFileSync(join(root, "config.toml"), "utf8"); + expect(text.match(/\[models\./g)?.length).toBe(1); + expect(text).toContain("# user's setting"); + expect(text).toContain('protocol = "openai"'); + expect(text).toContain("max_context_size = 262144"); + }); + + it("stops a model table before a following array-of-tables heading", () => { + const home = mkdtempSync(join(tmpdir(), "omb-kimi-aot-")); + scratchDirs.push(home); + const root = join(home, ".kimi-code"); + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, "config.toml"), + [ + '[models."omlx/GLM-5.2-fp8"]', + 'provider = "omlx"', + 'model = "GLM-5.2-fp8"', + "", + "[[hooks]]", + 'event = "Stop"', + "", + ].join("\n"), + ); + ensureKimiInjectAlias("omlx::GLM-5.2-fp8", { HOME: home }); + const text = readFileSync(join(root, "config.toml"), "utf8"); + expect(text.indexOf('protocol = "openai"')).toBeLessThan(text.indexOf("[[hooks]]")); + expect(text.indexOf("max_context_size = 262144")).toBeLessThan(text.indexOf("[[hooks]]")); + expect(text).toMatch(/\[\[hooks\]\]\s*event = "Stop"/); + }); + + it("treats a unicode-escaped model key as the same table as the literal alias", () => { + const home = mkdtempSync(join(tmpdir(), "omb-kimi-unicode-")); + scratchDirs.push(home); + const root = join(home, ".kimi-code"); + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, "config.toml"), + ['[models."omlx/GLM-\\u0035.2-fp8"]', 'provider = "omlx"', 'model = "GLM-5.2-fp8"', ""].join("\n"), + ); + ensureKimiInjectAlias("omlx::GLM-5.2-fp8", { HOME: home }); + const text = readFileSync(join(root, "config.toml"), "utf8"); + expect(text.match(/\[models\./g)?.length).toBe(1); + expect(text).toContain("GLM-\\u0035.2-fp8"); + expect(text).toContain('protocol = "openai"'); + expect(text).toContain("max_context_size = 262144"); + }); + + it("treats whitespace around dotted heading keys as the same table", () => { + const home = mkdtempSync(join(tmpdir(), "omb-kimi-dots-")); + scratchDirs.push(home); + const root = join(home, ".kimi-code"); + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, "config.toml"), + ['[models . "omlx/GLM-5.2-fp8"]', 'provider = "omlx"', 'model = "GLM-5.2-fp8"', ""].join("\n"), + ); + ensureKimiInjectAlias("omlx::GLM-5.2-fp8", { HOME: home }); + const text = readFileSync(join(root, "config.toml"), "utf8"); + expect(text.match(/\[models/g)?.length).toBe(1); + expect(text).toContain('protocol = "openai"'); + expect(text).toContain("max_context_size = 262144"); + }); + + it("does not treat a triple-quote inside a single-line string as multiline", () => { + const home = mkdtempSync(join(tmpdir(), "omb-kimi-squote-")); + scratchDirs.push(home); + const root = join(home, ".kimi-code"); + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, "config.toml"), + [ + '[models."omlx/GLM-5.2-fp8"]', + 'provider = "omlx"', + 'model = "GLM-5.2-fp8"', + `note = '"""'`, + 'protocol = "openai"', + "", + ].join("\n"), + ); + ensureKimiInjectAlias("omlx::GLM-5.2-fp8", { HOME: home }); + const text = readFileSync(join(root, "config.toml"), "utf8"); + expect(text.match(/protocol = "openai"/g)?.length).toBe(1); + expect(text).toContain("max_context_size = 262144"); + }); + + it("does not treat a triple-quote inside a comment as multiline", () => { + const home = mkdtempSync(join(tmpdir(), "omb-kimi-hash-")); + scratchDirs.push(home); + const root = join(home, ".kimi-code"); + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, "config.toml"), + [ + '[models."omlx/GLM-5.2-fp8"]', + 'provider = "omlx"', + 'model = "GLM-5.2-fp8"', + 'note = "x" # """', + 'protocol = "openai"', + "", + ].join("\n"), + ); + ensureKimiInjectAlias("omlx::GLM-5.2-fp8", { HOME: home }); + const text = readFileSync(join(root, "config.toml"), "utf8"); + expect(text.match(/protocol = "openai"/g)?.length).toBe(1); + expect(text).toContain("max_context_size = 262144"); + }); + + it("does not treat a bracket line inside a multiline string as a table", () => { + const home = mkdtempSync(join(tmpdir(), "omb-kimi-ml-")); + scratchDirs.push(home); + const root = join(home, ".kimi-code"); + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, "config.toml"), + [ + '[models."omlx/GLM-5.2-fp8"]', + 'provider = "omlx"', + 'model = "GLM-5.2-fp8"', + 'notes = """', + "[providers.evil]", + 'protocol = "skip"', + '"""', + "", + ].join("\n"), + ); + ensureKimiInjectAlias("omlx::GLM-5.2-fp8", { HOME: home }); + const text = readFileSync(join(root, "config.toml"), "utf8"); + expect(text).toContain('protocol = "skip"'); + expect(text).toContain('protocol = "openai"'); + const notesOpen = text.indexOf('"""', text.indexOf("notes")); + const notesClose = text.indexOf('"""', notesOpen + 3); + const protocolAt = text.indexOf('protocol = "openai"'); + expect(protocolAt).toBeGreaterThan(notesClose); + expect(text).toContain("[providers.omlx]"); + expect(text).toContain("[providers.evil]"); }); it("treats USERPROFILE as the same home for credentials and config", async () => { @@ -329,6 +580,76 @@ describe("ensureKimiInjectAlias", () => { }); }); +describe("applyKimiLocalModelEnv", () => { + it("overlays an OpenAI-compatible default for a local inject pick", () => { + const env: Record = {}; + applyKimiLocalModelEnv(env, "ollama::ornith:35b-bf16"); + expect(env).toMatchObject({ + KIMI_MODEL_NAME: "ornith:35b-bf16", + KIMI_MODEL_API_KEY: "ollama", + KIMI_MODEL_BASE_URL: "http://127.0.0.1:11434/v1", + KIMI_MODEL_PROVIDER_TYPE: "openai", + }); + }); + + it("leaves subscription slugs and already-resolved aliases alone", () => { + const env: Record = { KIMI_MODEL_NAME: "keep-me" }; + applyKimiLocalModelEnv(env, "kimi-code/k3"); + applyKimiLocalModelEnv(env, "ollama/ornith:35b-bf16"); + applyKimiLocalModelEnv(env, undefined); + expect(env.KIMI_MODEL_NAME).toBe("keep-me"); + expect(env.KIMI_MODEL_API_KEY).toBeUndefined(); + }); + + it("reads the Unsloth token from the turn env", () => { + const env: Record = { UNSLOTH_STUDIO_AUTH_TOKEN: "unsloth-secret" }; + applyKimiLocalModelEnv(env, "unsloth::qwen3-coder"); + expect(env.KIMI_MODEL_API_KEY).toBe("unsloth-secret"); + expect(env.KIMI_MODEL_BASE_URL).toBe("http://127.0.0.1:8888/v1"); + }); + + it("puts the overlay on the Kimi child only for a local inject pick", async () => { + const home = mkdtempSync(join(tmpdir(), "omb-kimi-overlay-")); + scratchDirs.push(home); + mkdirSync(join(home, ".kimi-code"), { recursive: true }); + const dump = join(home, "dump.json"); + const instance = await KimiAgentDriver.create({ + instanceId: "kimi-overlay", + displayName: "Kimi", + environment: { HOME: home, FAKE_ACP_DUMP: dump, KIMI_MODEL_NAME: "from-shell" }, + enabled: true, + config: { cli: FAKE_ACP, fullAuto: false }, + }); + const recorder = recordEvents(instance.adapter); + try { + await instance.adapter.sendTurn({ + threadId: "t-inject", + text: "hi", + model: "ollama::ornith:35b-bf16", + }); + await recorder.until((e) => e.type === "turn.completed"); + const injectDump = JSON.parse(readFileSync(dump, "utf8")) as { env: Record }; + expect(injectDump.env).toMatchObject({ + KIMI_MODEL_NAME: "ornith:35b-bf16", + KIMI_MODEL_API_KEY: "ollama", + KIMI_MODEL_BASE_URL: "http://127.0.0.1:11434/v1", + KIMI_MODEL_PROVIDER_TYPE: "openai", + }); + + await instance.adapter.sendTurn({ + threadId: "t-cloud", + text: "hi", + model: "kimi-code/k3", + }); + await recorder.until((e) => e.type === "turn.completed" && e.threadId === "t-cloud"); + const cloudDump = JSON.parse(readFileSync(dump, "utf8")) as { env: Record }; + expect(cloudDump.env.KIMI_MODEL_NAME).toBeUndefined(); + } finally { + await instance.dispose(); + } + }); +}); + describe("ensureDroidInjectModel", () => { it("upserts a generic-chat-completion BYOK row and reuses it", () => { const home = mkdtempSync(join(tmpdir(), "omb-droid-inject-")); @@ -373,6 +694,72 @@ describe("ensureDroidInjectModel", () => { }); }); +describe("applyDroidLocalAuthEnv", () => { + it("fills a placeholder Factory key only for a local inject pick", () => { + const env: Record = {}; + applyDroidLocalAuthEnv(env, "ollama::ornith:35b-bf16"); + expect(env.FACTORY_API_KEY).toBe("openmausbot-local"); + applyDroidLocalAuthEnv(env, "ollama::ornith:35b-bf16"); + expect(env.FACTORY_API_KEY).toBe("openmausbot-local"); + }); + + it("leaves a real Factory key and cloud slugs alone", () => { + const kept: Record = { FACTORY_API_KEY: "fk-real" }; + applyDroidLocalAuthEnv(kept, "ollama::ornith:35b-bf16"); + expect(kept.FACTORY_API_KEY).toBe("fk-real"); + const cloud: Record = {}; + applyDroidLocalAuthEnv(cloud, "claude-opus-5"); + applyDroidLocalAuthEnv(cloud, undefined); + expect(cloud.FACTORY_API_KEY).toBeUndefined(); + }); + + it("does not invent a Factory key when a Droid auth file already exists", () => { + const home = mkdtempSync(join(tmpdir(), "omb-droid-authfile-")); + scratchDirs.push(home); + mkdirSync(join(home, ".factory"), { recursive: true }); + writeFileSync(join(home, ".factory", "auth.v2.file"), "signed-in"); + const env: Record = { FACTORY_HOME_OVERRIDE: home }; + applyDroidLocalAuthEnv(env, "ollama::ornith:35b-bf16"); + expect(env).toEqual({ FACTORY_HOME_OVERRIDE: home }); + }); + + it("puts the placeholder on the Droid child only for a local inject pick", async () => { + const home = mkdtempSync(join(tmpdir(), "omb-droid-overlay-")); + scratchDirs.push(home); + mkdirSync(join(home, ".factory"), { recursive: true }); + const dump = join(home, "dump.json"); + const instance = await DroidAgentDriver.create({ + instanceId: "droid-overlay", + displayName: "Droid", + environment: { HOME: home, FACTORY_HOME_OVERRIDE: home, FAKE_ACP_DUMP: dump, FACTORY_API_KEY: "" }, + enabled: true, + config: { cli: FAKE_ACP, fullAuto: false }, + }); + const recorder = recordEvents(instance.adapter); + try { + await instance.adapter.sendTurn({ + threadId: "t-inject", + text: "hi", + model: "ollama::ornith:35b-bf16", + }); + await recorder.until((e) => e.type === "turn.completed"); + expect(JSON.parse(readFileSync(dump, "utf8")).env.FACTORY_API_KEY).toBe("openmausbot-local"); + + await instance.adapter.sendTurn({ + threadId: "t-cloud", + text: "hi", + model: "claude-opus-5", + }); + await recorder.until((e) => e.type === "turn.completed" && e.threadId === "t-cloud"); + expect(JSON.parse(readFileSync(dump, "utf8")).env.FACTORY_API_KEY).not.toBe( + "openmausbot-local", + ); + } finally { + await instance.dispose(); + } + }); +}); + describe("ensureOpenCodeInjectModel", () => { it("merges a host provider into opencode.json without dropping existing models", () => { const home = mkdtempSync(join(tmpdir(), "omb-opencode-inject-")); diff --git a/server/drivers/local-inject.ts b/server/drivers/local-inject.ts index 6287afa1c..a979b63aa 100644 --- a/server/drivers/local-inject.ts +++ b/server/drivers/local-inject.ts @@ -104,13 +104,44 @@ export function codexLocalProviderArgs( ]; } +function firstUnslothToken(row: unknown): string | null { + if (!row || typeof row !== "object") return null; + const rec = row as { minted?: unknown; saved?: unknown; api_key?: unknown }; + for (const bucket of [rec.minted, rec.saved]) { + if (typeof bucket === "string" && bucket) return bucket; + if (Array.isArray(bucket)) { + const token = bucket.find((value) => typeof value === "string" && value); + if (typeof token === "string") return token; + } + } + if (typeof rec.api_key === "string" && rec.api_key) return rec.api_key; + return null; +} + 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; + servers?: unknown; }; - return typeof raw.api_key === "string" && raw.api_key ? raw.api_key : null; + // Older Studio wrote `{ api_key }`. Current Studio writes + // `{ servers: { "http://127.0.0.1:8888": { minted: ["sk-unsloth-…"] } } }`. + // Prefer the localhost minted token so a stale mixed-format file cannot + // win; keep the top-level key as fallback. + if (raw.servers && typeof raw.servers === "object") { + const servers = raw.servers as Record; + for (const url of ["http://127.0.0.1:8888", "http://localhost:8888"]) { + const token = firstUnslothToken(servers[url]); + if (token) return token; + } + for (const row of Object.values(servers)) { + const token = firstUnslothToken(row); + if (token) return token; + } + } + if (typeof raw.api_key === "string" && raw.api_key) return raw.api_key; + return null; } catch { return null; } diff --git a/server/testing/fake-acp-cli.ts b/server/testing/fake-acp-cli.ts index d2bd1e958..cb0d9ae56 100755 --- a/server/testing/fake-acp-cli.ts +++ b/server/testing/fake-acp-cli.ts @@ -71,9 +71,16 @@ const dumpEnv = Object.fromEntries( "XAI_API_KEY", "BOX_TOKEN", "OMB_TTS_KEY", + "FACTORY_API_KEY", "UNSLOTH_STUDIO_AUTH_TOKEN", "CURSOR_API_KEY", "CURSOR_AUTH_TOKEN", + "KIMI_MODEL_NAME", + "KIMI_MODEL_API_KEY", + "KIMI_MODEL_BASE_URL", + "KIMI_MODEL_PROVIDER_TYPE", + "KIMI_MODEL_DISPLAY_NAME", + "TEST_TURN_MODEL", ].flatMap((key) => (process.env[key] === undefined ? [] : [[key, process.env[key]]] as const)), ); const dumpState: Record = { argv, env: dumpEnv }; diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index 50e967b8c..97eb847ec 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -22,7 +22,7 @@ import { Webhook, X, } from "lucide-react"; -import { costCaption, formatTokens, formatUsd, usageChip } from "@/lib/usage"; +import { costCaption, formatTokens, formatUsd, hasFiniteCost, usageChip } from "@/lib/usage"; import { useStore, useStreaming, @@ -1121,7 +1121,7 @@ function UsageChip({ bot }: { bot: Bot }) { const detail = [ `${usage.turns} turn${usage.turns === 1 ? "" : "s"}`, `${formatTokens(usage.input)} in · ${formatTokens(usage.output)} out`, - usage.costUsd !== null ? `${formatUsd(usage.costUsd)} ${costCaption(billing)}` : null, + hasFiniteCost(usage.costUsd) ? `${formatUsd(usage.costUsd)} ${costCaption(billing)}` : null, ] .filter(Boolean) .join("\n"); diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 008271887..f314e7352 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -13,7 +13,7 @@ import { ModelPicker } from "./ModelPicker"; import { useDesktopCapabilities } from "./DesktopCapabilities"; import { cn } from "@/lib/cn"; import { requestNotificationPermission } from "@/lib/notify"; -import { botUsage, costCaption, formatTokens, formatUsd } from "@/lib/usage"; +import { botUsage, costCaption, formatTokens, formatUsd, hasFiniteCost } from "@/lib/usage"; import { shortPath } from "@/lib/short-path"; import { instanceSupportsLocalComputer, localComputerDisabledReason } from "@/lib/local-computer"; @@ -63,11 +63,11 @@ function BotUsageCard({ bot }: { bot: Bot }) {
Cost
-
{usage.costUsd === null ? "—" : formatUsd(usage.costUsd)}
+
{hasFiniteCost(usage.costUsd) ? formatUsd(usage.costUsd) : "—"}
- {usage.costUsd === null ? "This engine doesn't report a price; tokens are counted." : `Cost ${costCaption(instance?.snapshot.billing)}.`} + {hasFiniteCost(usage.costUsd) ? `Cost ${costCaption(instance?.snapshot.billing)}.` : "This engine doesn't report a price; tokens are counted."}
); diff --git a/src/components/UsageSection.tsx b/src/components/UsageSection.tsx index f5015998a..4540daeb0 100644 --- a/src/components/UsageSection.tsx +++ b/src/components/UsageSection.tsx @@ -5,7 +5,7 @@ import { useStore } from "@/state/store"; import { MausAvatar } from "./Avatar"; import { Card } from "./SettingsPrimitives"; -import { botUsage, costCaption, formatTokens, formatUsd, sumUsage } from "@/lib/usage"; +import { botUsage, costCaption, formatTokens, formatUsd, hasFiniteCost, sumUsage } from "@/lib/usage"; export function UsageSection() { const { state } = useStore(); @@ -44,16 +44,16 @@ export function UsageSection() { {formatTokens(usage.input + usage.output)} - {usage.costUsd === null ? : formatUsd(usage.costUsd)} + {hasFiniteCost(usage.costUsd) ? formatUsd(usage.costUsd) : } ))}
All bots {total.turns} {formatTokens(total.input + total.output)} - {total.costUsd === null ? "—" : formatUsd(total.costUsd)} + {hasFiniteCost(total.costUsd) ? formatUsd(total.costUsd) : "—"}
- {total.costUsd !== null && ( + {hasFiniteCost(total.costUsd) && (
Cost is {billings.size === 1 ? costCaption([...billings][0]) : "as each engine reports it — on a subscription it's an equivalent, not a charge"}.
diff --git a/src/lib/usage.test.ts b/src/lib/usage.test.ts index aa9f0c820..a0abac415 100644 --- a/src/lib/usage.test.ts +++ b/src/lib/usage.test.ts @@ -16,6 +16,29 @@ describe("usage formatting", () => { expect(formatUsd(0.31)).toBe("$0.31"); }); + it("does not throw on missing usage fields from older bots.json", () => { + expect(formatUsd(undefined as unknown as number)).toBe(""); + expect(formatTokens(undefined as unknown as number)).toBe("0"); + expect( + usageChip({ input: 100, output: 20, turns: 1 } as { input: number; output: number; costUsd: null; turns: number }), + ).toBe("120 tok"); + }); + + it("treats NaN and Infinity cost as missing", () => { + expect(formatUsd(Number.NaN)).toBe(""); + expect(formatUsd(Number.POSITIVE_INFINITY)).toBe(""); + expect(formatTokens(Number.NaN)).toBe("0"); + expect(formatTokens(Number.POSITIVE_INFINITY)).toBe("0"); + expect(usageChip({ input: 100, output: 20, costUsd: Number.NaN, turns: 1 })).toBe("120 tok"); + expect(usageChip({ input: 100, output: 20, costUsd: Number.POSITIVE_INFINITY, turns: 1 })).toBe("120 tok"); + expect( + sumUsage([ + { input: 1, output: 1, costUsd: Number.NaN, turns: 1 }, + { input: 2, output: 2, costUsd: 0.01, turns: 1 }, + ]), + ).toEqual({ input: 3, output: 3, costUsd: 0.01, turns: 2 }); + }); + it("builds the chip: tokens always, cost only when known, nothing when unused", () => { expect(usageChip({ input: 0, output: 0, costUsd: null, turns: 0 })).toBe(""); expect(usageChip({ input: 10_000, output: 2_400, costUsd: null, turns: 3 })).toBe("12.4k tok"); diff --git a/src/lib/usage.ts b/src/lib/usage.ts index 0cdc8d9c2..179bfe659 100644 --- a/src/lib/usage.ts +++ b/src/lib/usage.ts @@ -4,6 +4,11 @@ import type { Bot, TaskUsage } from "@/state/store"; export const EMPTY_USAGE: TaskUsage = { input: 0, output: 0, costUsd: null, turns: 0 }; +/** True when a stored cost is a real number (not null, NaN, or Infinity). */ +export function hasFiniteCost(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + /** Sum a set of usages; cost stays null until any of them has one. */ export function sumUsage(items: Array): TaskUsage { const out: TaskUsage = { ...EMPTY_USAGE }; @@ -12,7 +17,7 @@ export function sumUsage(items: Array): TaskUsage { out.input += u.input; out.output += u.output; out.turns += u.turns; - if (typeof u.costUsd === "number") out.costUsd = (out.costUsd ?? 0) + u.costUsd; + if (hasFiniteCost(u.costUsd)) out.costUsd = (out.costUsd ?? 0) + u.costUsd; } return out; } @@ -23,6 +28,7 @@ export function botUsage(bot: Pick): TaskUsage { /** 950 → "950", 12_400 → "12.4k", 2_300_000 → "2.3M" */ export function formatTokens(n: number): string { + if (!hasFiniteCost(n)) return "0"; if (n < 1000) return String(n); if (n < 1_000_000) return `${trim(n / 1000)}k`; return `${trim(n / 1_000_000)}M`; @@ -31,6 +37,7 @@ const trim = (x: number) => (x >= 100 ? Math.round(x).toString() : x.toFixed(1). /** Dollars, with enough precision that a cheap turn isn't "$0.00". */ export function formatUsd(usd: number): string { + if (!hasFiniteCost(usd)) return ""; if (usd === 0) return "$0"; if (usd < 0.01) return `$${usd.toFixed(3)}`; return `$${usd.toFixed(2)}`; @@ -41,7 +48,7 @@ export function formatUsd(usd: number): string { export function usageChip(u: TaskUsage): string { if (u.turns === 0 && u.input + u.output === 0) return ""; const parts = [`${formatTokens(u.input + u.output)} tok`]; - if (typeof u.costUsd === "number") parts.push(formatUsd(u.costUsd)); + if (hasFiniteCost(u.costUsd)) parts.push(formatUsd(u.costUsd)); return parts.join(" · "); }