From 5b3077fb085597f1a07e47578ff3cbde32d0b0f4 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 18 Aug 2026 10:47:01 +0200 Subject: [PATCH 01/10] Let Kimi run a local model without a Kimi login. Kimi ACP session/new checks default_model, not -m. A missing or expired login then becomes "Authentication required" even when the picker is a local host. Overlay Kimi's official KIMI_MODEL_* env on inject turns so the child has an in-memory default, and write protocol plus max_context_size on the on-disk alias so 0.36+ will bind it. --- server/drivers/acp/acp.test.ts | 30 ++++++++++++ server/drivers/acp/core.ts | 7 +++ server/drivers/acp/kimi.ts | 52 +++++++++++++++++++- server/drivers/local-inject.test.ts | 74 ++++++++++++++++++++++++++++- server/testing/fake-acp-cli.ts | 6 +++ 5 files changed, 167 insertions(+), 2 deletions(-) diff --git a/server/drivers/acp/acp.test.ts b/server/drivers/acp/acp.test.ts index ff3dabfb8..c5330177c 100644 --- a/server/drivers/acp/acp.test.ts +++ b/server/drivers/acp/acp.test.ts @@ -562,6 +562,36 @@ 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, + applyTurnEnv: (env, { requestedModel }) => { + env.TEST_TURN_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("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/kimi.ts b/server/drivers/acp/kimi.ts index 28dd43450..3f3f505d6 100644 --- a/server/drivers/acp/kimi.ts +++ b/server/drivers/acp/kimi.ts @@ -89,7 +89,16 @@ export function ensureKimiInjectAlias( } if (!hasTomlTable(text, modelHeading)) { blocks.push( - [modelHeading, `provider = ${quoteToml(inject.host)}`, `model = ${quoteToml(inject.model)}`, ""].join("\n"), + [ + modelHeading, + `provider = ${quoteToml(inject.host)}`, + `model = ${quoteToml(inject.model)}`, + // Kimi 0.36+ refuses openai_legacy as a wire protocol; ACP then + // skips default-model binding and falls through to OAuth. + `protocol = "openai"`, + `max_context_size = 262144`, + "", + ].join("\n"), ); } if (blocks.length) { @@ -99,6 +108,41 @@ export function ensureKimiInjectAlias( 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})`; +} + +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 +224,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.test.ts b/server/drivers/local-inject.test.ts index 34f456d2a..bfe473f6d 100644 --- a/server/drivers/local-inject.test.ts +++ b/server/drivers/local-inject.test.ts @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { 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,8 @@ 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("treats USERPROFILE as the same home for credentials and config", async () => { @@ -329,6 +331,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-")); diff --git a/server/testing/fake-acp-cli.ts b/server/testing/fake-acp-cli.ts index d2bd1e958..acd38a05d 100755 --- a/server/testing/fake-acp-cli.ts +++ b/server/testing/fake-acp-cli.ts @@ -74,6 +74,12 @@ const dumpEnv = Object.fromEntries( "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 }; From fc111efbb0083306ea5cd5d535600fd74a114d9d Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 18 Aug 2026 10:59:09 +0200 Subject: [PATCH 02/10] Patch existing Kimi aliases with protocol and context size. Aliases written before this PR were left as-is, so Kimi 0.36+ skipped default-model binding. Fill in protocol and max_context_size when they are missing, and leave any values the user already set. The applyTurnEnv test now checks both the resolved model and the picker id. --- server/drivers/acp/acp.test.ts | 9 +++-- server/drivers/acp/kimi.ts | 37 ++++++++++++++++-- server/drivers/local-inject.test.ts | 58 +++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 6 deletions(-) diff --git a/server/drivers/acp/acp.test.ts b/server/drivers/acp/acp.test.ts index c5330177c..ca3526597 100644 --- a/server/drivers/acp/acp.test.ts +++ b/server/drivers/acp/acp.test.ts @@ -569,8 +569,9 @@ describe("ACP turns (fake CLI)", () => { ...SELECT_MODEL_SUPPORT, driverKind: "turnEnvTest", selectModel: undefined, - applyTurnEnv: (env, { requestedModel }) => { - env.TEST_TURN_MODEL = requestedModel ?? ""; + resolveTurnModel: (model) => (model ? `resolved/${model}` : model), + applyTurnEnv: (env, { model, requestedModel }) => { + env.TEST_TURN_MODEL = `${model ?? ""}|${requestedModel ?? ""}`; }, }); instance = await TurnEnvDriver.create({ @@ -589,7 +590,9 @@ describe("ACP turns (fake CLI)", () => { }); await recorder.until((e) => e.type === "turn.completed"); - expect(JSON.parse(readFileSync(dump, "utf8")).env.TEST_TURN_MODEL).toBe("ollama::ornith:35b-bf16"); + 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 () => { diff --git a/server/drivers/acp/kimi.ts b/server/drivers/acp/kimi.ts index 3f3f505d6..9282da289 100644 --- a/server/drivers/acp/kimi.ts +++ b/server/drivers/acp/kimi.ts @@ -52,6 +52,30 @@ function hasTomlTable(text: string, heading: string): boolean { return text.split(/\r?\n/).some((line) => line.trim() === heading); } +function tomlTableHasKey(block: string, key: string): boolean { + return block.split(/\r?\n/).some((line) => { + const stripped = line.trim(); + if (!stripped || stripped.startsWith("#")) return false; + const eq = stripped.indexOf("="); + return eq > 0 && stripped.slice(0, eq).trim() === key; + }); +} + +/** Insert missing keys into an existing table. Does not overwrite set values. */ +function patchTomlTable(text: string, heading: string, rows: string[]): string { + const lines = text.split(/\r?\n/); + const start = lines.findIndex((line) => line.trim() === heading); + if (start < 0) return text; + let end = start + 1; + while (end < lines.length && !/^\s*\[/.test(lines[end]!)) end++; + const block = lines.slice(start, end).join("\n"); + const missing = rows.filter((row) => !tomlTableHasKey(block, row.split("=")[0]!.trim())); + if (!missing.length) return text; + let insertAt = end; + while (insertAt > start + 1 && lines[insertAt - 1] === "") insertAt--; + return [...lines.slice(0, insertAt), ...missing, ...lines.slice(insertAt)].join("\n"); +} + /** Write [providers.host] + [models."host/alias"] so `kimi -m` hits the local host. */ export function ensureKimiInjectAlias( modelId: string, @@ -72,6 +96,7 @@ export function ensureKimiInjectAlias( } catch { text = ""; } + const original = text; const providerHeading = `[providers.${inject.host}]`; const modelHeading = `[models.${quoteTomlKey(alias)}]`; @@ -87,14 +112,18 @@ 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)}`, - // Kimi 0.36+ refuses openai_legacy as a wire protocol; ACP then - // skips default-model binding and falls through to OAuth. `protocol = "openai"`, `max_context_size = 262144`, "", @@ -104,6 +133,8 @@ export function ensureKimiInjectAlias( 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; } diff --git a/server/drivers/local-inject.test.ts b/server/drivers/local-inject.test.ts index bfe473f6d..84f524ef3 100644 --- a/server/drivers/local-inject.test.ts +++ b/server/drivers/local-inject.test.ts @@ -307,6 +307,64 @@ describe("ensureKimiInjectAlias", () => { 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 USERPROFILE as the same home for credentials and config", async () => { const home = mkdtempSync(join(tmpdir(), "omb-kimi-userprofile-")); scratchDirs.push(home); From e20c1ff96e947b53ab5a14ad83de1b5a26a7bded Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 18 Aug 2026 11:11:09 +0200 Subject: [PATCH 03/10] Parse Kimi config.toml with a string-aware scanner. Line-based heading and key checks missed quoted keys, headings with comments, and bracket lines inside multiline strings. Walk the file outside of strings so existing aliases are patched once, and document the helpers the coverage check was counting. --- server/drivers/acp/kimi.ts | 230 ++++++++++++++++++++++++++-- server/drivers/local-inject.test.ts | 67 ++++++++ 2 files changed, 280 insertions(+), 17 deletions(-) diff --git a/server/drivers/acp/kimi.ts b/server/drivers/acp/kimi.ts index 9282da289..be0971bb8 100644 --- a/server/drivers/acp/kimi.ts +++ b/server/drivers/acp/kimi.ts @@ -39,41 +39,236 @@ 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; +} + +/** 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; + while (i < inner.length) { + if (inner[i] === ".") { + i += 1; + continue; + } + const q = inner[i]; + if (q === '"' || q === "'") { + i += 1; + let value = ""; + while (i < inner.length && inner[i] !== q) { + if (q === '"' && inner[i] === "\\") { + value += inner[i + 1] ?? ""; + i += 2; + continue; + } + value += inner[i]; + i += 1; + } + if (inner[i] === q) i += 1; + parts.push(value); + continue; + } + let value = ""; + while (i < inner.length && inner[i] !== ".") { + value += inner[i]; + i += 1; + } + parts.push(value); + } + return parts.join("."); +} + +/** 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 tables, skipping `[` inside strings (including multiline). */ +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; 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 (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 name = canonicalizeTomlHeading(text.slice(j, lineEnd).replace(/\r$/, "")); + if (name) headings.push({ name, lineStart: i, lineEnd }); + i = lineEnd + (nl < 0 ? 0 : 1); + continue; + } + } + i += 1; + } + return headings.map((heading, index) => ({ + name: heading.name, + headingStart: heading.lineStart, + bodyStart: heading.lineEnd + (text[heading.lineEnd] === "\n" ? 1 : 0), + end: index + 1 < headings.length ? headings[index + 1]!.lineStart : text.length, + })); +} + +/** Keys assigned at line start in a table body, including `"quoted"` keys. */ +function tomlKeys(block: string): Set { + const keys = new Set(); + let lineStart = 0; + let mode: "out" | "mlbasic" | "mllit" = "out"; + const take = (end: number) => { + if (mode !== "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 (block.startsWith('"""', i)) { + mode = "mlbasic"; + i += 2; + } else if (block.startsWith("'''", i)) { + mode = "mllit"; + i += 2; + } else if (block[i] === "\n") { + take(i); + lineStart = i + 1; + } + } + 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); } +/** Whether a table body already assigns `key` (`protocol` or `"protocol"`). */ function tomlTableHasKey(block: string, key: string): boolean { - return block.split(/\r?\n/).some((line) => { - const stripped = line.trim(); - if (!stripped || stripped.startsWith("#")) return false; - const eq = stripped.indexOf("="); - return eq > 0 && stripped.slice(0, eq).trim() === key; - }); + return tomlKeys(block).has(key); } /** Insert missing keys into an existing table. Does not overwrite set values. */ function patchTomlTable(text: string, heading: string, rows: string[]): string { - const lines = text.split(/\r?\n/); - const start = lines.findIndex((line) => line.trim() === heading); - if (start < 0) return text; - let end = start + 1; - while (end < lines.length && !/^\s*\[/.test(lines[end]!)) end++; - const block = lines.slice(start, end).join("\n"); - const missing = rows.filter((row) => !tomlTableHasKey(block, row.split("=")[0]!.trim())); + 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 = end; - while (insertAt > start + 1 && lines[insertAt - 1] === "") insertAt--; - return [...lines.slice(0, insertAt), ...missing, ...lines.slice(insertAt)].join("\n"); + 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. */ @@ -170,6 +365,7 @@ export function applyKimiLocalModelEnv( 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]; } diff --git a/server/drivers/local-inject.test.ts b/server/drivers/local-inject.test.ts index 84f524ef3..4e98477ef 100644 --- a/server/drivers/local-inject.test.ts +++ b/server/drivers/local-inject.test.ts @@ -365,6 +365,73 @@ describe("ensureKimiInjectAlias", () => { 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 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 () => { const home = mkdtempSync(join(tmpdir(), "omb-kimi-userprofile-")); scratchDirs.push(home); From ee79549aa516be4f87318b6501f53a3de199384d Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 18 Aug 2026 11:16:16 +0200 Subject: [PATCH 04/10] Let Droid run a local model without a Factory login. Droid ACP session/new requires a Factory login or FACTORY_API_KEY even when the picker is a BYOK custom host. The CLI only checks that the variable is set, then uses the custom row's own key. On a local inject turn, fill a placeholder if the user has no Factory key. Cloud models are unchanged. --- server/drivers/acp/droid.ts | 17 +++++++++ server/drivers/local-inject.test.ts | 58 ++++++++++++++++++++++++++++- server/testing/fake-acp-cli.ts | 1 + 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/server/drivers/acp/droid.ts b/server/drivers/acp/droid.ts index 79a0e9e81..cc0ad5b7e 100644 --- a/server/drivers/acp/droid.ts +++ b/server/drivers/acp/droid.ts @@ -118,6 +118,20 @@ 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; + 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 +247,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/local-inject.test.ts b/server/drivers/local-inject.test.ts index 4e98477ef..e7039b6e1 100644 --- a/server/drivers/local-inject.test.ts +++ b/server/drivers/local-inject.test.ts @@ -4,7 +4,7 @@ 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 { applyKimiLocalModelEnv, ensureKimiInjectAlias, KimiAgentDriver } from "./acp/kimi.ts"; import { ensureOpenCodeInjectModel } from "./acp/opencode-go.ts"; @@ -570,6 +570,62 @@ 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("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/testing/fake-acp-cli.ts b/server/testing/fake-acp-cli.ts index acd38a05d..cb0d9ae56 100755 --- a/server/testing/fake-acp-cli.ts +++ b/server/testing/fake-acp-cli.ts @@ -71,6 +71,7 @@ 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", From fadce239a26015906e629bb44281b8f406132f52 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 18 Aug 2026 11:18:11 +0200 Subject: [PATCH 05/10] Drop the unused tomlTableHasKey helper so typecheck passes. --- server/drivers/acp/kimi.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/server/drivers/acp/kimi.ts b/server/drivers/acp/kimi.ts index be0971bb8..a57d817b6 100644 --- a/server/drivers/acp/kimi.ts +++ b/server/drivers/acp/kimi.ts @@ -249,11 +249,6 @@ function hasTomlTable(text: string, heading: string): boolean { return name !== null && tomlTables(text).some((table) => table.name === name); } -/** Whether a table body already assigns `key` (`protocol` or `"protocol"`). */ -function tomlTableHasKey(block: string, key: string): boolean { - return tomlKeys(block).has(key); -} - /** 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); From 8497c3105e2a9488c9adeee677735141e70c0f11 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 18 Aug 2026 12:52:55 +0200 Subject: [PATCH 06/10] Don't crash the UI when older tasks have no costUsd. A usage chip on first paint called toFixed on undefined for bots.json rows written before cost tracking. The packaged window rendered black. --- src/components/ChatView.tsx | 2 +- src/components/SettingsPanel.tsx | 2 +- src/components/UsageSection.tsx | 4 ++-- src/lib/usage.test.ts | 8 ++++++++ src/lib/usage.ts | 2 ++ 5 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index 50e967b8c..9de2b35e6 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -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, + typeof usage.costUsd === "number" ? `${formatUsd(usage.costUsd)} ${costCaption(billing)}` : null, ] .filter(Boolean) .join("\n"); diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 008271887..38091518a 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -63,7 +63,7 @@ function BotUsageCard({ bot }: { bot: Bot }) {
Cost
-
{usage.costUsd === null ? "—" : formatUsd(usage.costUsd)}
+
{typeof usage.costUsd === "number" ? formatUsd(usage.costUsd) : "—"}
diff --git a/src/components/UsageSection.tsx b/src/components/UsageSection.tsx index f5015998a..89a327c3e 100644 --- a/src/components/UsageSection.tsx +++ b/src/components/UsageSection.tsx @@ -44,14 +44,14 @@ export function UsageSection() { {formatTokens(usage.input + usage.output)} - {usage.costUsd === null ? : formatUsd(usage.costUsd)} + {typeof usage.costUsd === "number" ? formatUsd(usage.costUsd) : }
))}
All bots {total.turns} {formatTokens(total.input + total.output)} - {total.costUsd === null ? "—" : formatUsd(total.costUsd)} + {typeof total.costUsd === "number" ? formatUsd(total.costUsd) : "—"}
{total.costUsd !== null && (
diff --git a/src/lib/usage.test.ts b/src/lib/usage.test.ts index aa9f0c820..c9a5229a1 100644 --- a/src/lib/usage.test.ts +++ b/src/lib/usage.test.ts @@ -16,6 +16,14 @@ 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("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..2430669cd 100644 --- a/src/lib/usage.ts +++ b/src/lib/usage.ts @@ -23,6 +23,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 (typeof n !== "number" || !Number.isFinite(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 +32,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 (typeof usd !== "number" || !Number.isFinite(usd)) return ""; if (usd === 0) return "$0"; if (usd < 0.01) return `$${usd.toFixed(3)}`; return `$${usd.toFixed(2)}`; From 974a3482089cb0f7ac9efd7347e8a4efc7caf5dd Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 18 Aug 2026 15:43:15 +0200 Subject: [PATCH 07/10] Read Unsloth Studio's minted API token from the servers map. Current Studio stores keys as servers[url].minted instead of a top-level api_key. Without that, /v1/models returns 401 and Custom never lists Unsloth models. --- server/drivers/local-inject-matrix.test.ts | 15 +++++++++++ server/drivers/local-inject.ts | 30 +++++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/server/drivers/local-inject-matrix.test.ts b/server/drivers/local-inject-matrix.test.ts index 3ec29272e..327cb1082 100644 --- a/server/drivers/local-inject-matrix.test.ts +++ b/server/drivers/local-inject-matrix.test.ts @@ -125,6 +125,21 @@ 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"); + }); }); describe("OpenAI / Anthropic env dialects", () => { diff --git a/server/drivers/local-inject.ts b/server/drivers/local-inject.ts index 6287afa1c..8a99cde67 100644 --- a/server/drivers/local-inject.ts +++ b/server/drivers/local-inject.ts @@ -104,13 +104,41 @@ 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 }; + if (typeof rec.api_key === "string" && rec.api_key) return rec.api_key; + 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; + } + } + 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-…"] } } }`. + if (typeof raw.api_key === "string" && raw.api_key) return raw.api_key; + if (!raw.servers || typeof raw.servers !== "object") return null; + 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; + } + return null; } catch { return null; } From 4d194ec506aa115b8dd170c69c3a3a04d5e74eef Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 19 Aug 2026 22:55:19 +0200 Subject: [PATCH 08/10] Honor remaining review notes for Droid, Unsloth, usage, and Kimi TOML. Skip the Droid FACTORY_API_KEY placeholder when a Factory auth file already exists. Prefer localhost minted Unsloth tokens over a stale top-level api_key. Treat NaN/Infinity costs as missing in the chip and settings. Trim whitespace around dotted TOML headings and ignore """ inside comments or single-line strings. --- server/drivers/acp/droid.ts | 4 ++ server/drivers/acp/kimi.ts | 39 +++++++++--- server/drivers/local-inject-matrix.test.ts | 15 +++++ server/drivers/local-inject.test.ts | 70 ++++++++++++++++++++++ server/drivers/local-inject.ts | 25 ++++---- src/components/ChatView.tsx | 4 +- src/components/SettingsPanel.tsx | 6 +- src/components/UsageSection.tsx | 8 +-- src/lib/usage.test.ts | 15 +++++ src/lib/usage.ts | 13 ++-- 10 files changed, 166 insertions(+), 33 deletions(-) diff --git a/server/drivers/acp/droid.ts b/server/drivers/acp/droid.ts index cc0ad5b7e..e3f0e468e 100644 --- a/server/drivers/acp/droid.ts +++ b/server/drivers/acp/droid.ts @@ -129,6 +129,10 @@ export function applyDroidLocalAuthEnv( ): 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"; } diff --git a/server/drivers/acp/kimi.ts b/server/drivers/acp/kimi.ts index a57d817b6..9c49621c0 100644 --- a/server/drivers/acp/kimi.ts +++ b/server/drivers/acp/kimi.ts @@ -77,11 +77,11 @@ function canonicalizeTomlHeading(heading: string): string | 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) { - if (inner[i] === ".") { - i += 1; - continue; - } const q = inner[i]; if (q === '"' || q === "'") { i += 1; @@ -97,6 +97,7 @@ function canonicalizeTomlHeading(heading: string): string | null { } if (inner[i] === q) i += 1; parts.push(value); + skipSep(); continue; } let value = ""; @@ -104,9 +105,11 @@ function canonicalizeTomlHeading(heading: string): string | null { value += inner[i]; i += 1; } - parts.push(value); + const part = value.trim(); + if (part) parts.push(part); + skipSep(); } - return parts.join("."); + return parts.length ? parts.join(".") : null; } /** Unwrap `"key"` / `'key'` so a quoted assignment matches the bare name. */ @@ -209,10 +212,12 @@ function tomlTables(text: string): Array<{ name: string; headingStart: number; b /** 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 mode: "out" | "mlbasic" | "mllit" = "out"; + let lineStartMode: Mode = "out"; const take = (end: number) => { - if (mode !== "out") return; + 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))); @@ -228,15 +233,31 @@ function tomlKeys(block: string): Set { 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] === "\n") { + } 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); diff --git a/server/drivers/local-inject-matrix.test.ts b/server/drivers/local-inject-matrix.test.ts index 327cb1082..078d2fb82 100644 --- a/server/drivers/local-inject-matrix.test.ts +++ b/server/drivers/local-inject-matrix.test.ts @@ -140,6 +140,21 @@ describe("host credentials", () => { 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 e7039b6e1..88fb856f8 100644 --- a/server/drivers/local-inject.test.ts +++ b/server/drivers/local-inject.test.ts @@ -402,6 +402,66 @@ describe("ensureKimiInjectAlias", () => { expect(text).toContain('protocol = "openai"'); }); + 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); @@ -589,6 +649,16 @@ describe("applyDroidLocalAuthEnv", () => { 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); diff --git a/server/drivers/local-inject.ts b/server/drivers/local-inject.ts index 8a99cde67..a979b63aa 100644 --- a/server/drivers/local-inject.ts +++ b/server/drivers/local-inject.ts @@ -107,7 +107,6 @@ 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 }; - if (typeof rec.api_key === "string" && rec.api_key) return rec.api_key; for (const bucket of [rec.minted, rec.saved]) { if (typeof bucket === "string" && bucket) return bucket; if (Array.isArray(bucket)) { @@ -115,6 +114,7 @@ function firstUnslothToken(row: unknown): string | null { if (typeof token === "string") return token; } } + if (typeof rec.api_key === "string" && rec.api_key) return rec.api_key; return null; } @@ -127,17 +127,20 @@ function readUnslothKey(env: Record): string | null }; // Older Studio wrote `{ api_key }`. Current Studio writes // `{ servers: { "http://127.0.0.1:8888": { minted: ["sk-unsloth-…"] } } }`. - if (typeof raw.api_key === "string" && raw.api_key) return raw.api_key; - if (!raw.servers || typeof raw.servers !== "object") return null; - 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; + // 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/src/components/ChatView.tsx b/src/components/ChatView.tsx index 9de2b35e6..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`, - typeof usage.costUsd === "number" ? `${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 38091518a..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
-
{typeof usage.costUsd === "number" ? 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 89a327c3e..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)} - {typeof usage.costUsd === "number" ? formatUsd(usage.costUsd) : } + {hasFiniteCost(usage.costUsd) ? formatUsd(usage.costUsd) : } ))}
All bots {total.turns} {formatTokens(total.input + total.output)} - {typeof total.costUsd === "number" ? 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 c9a5229a1..a0abac415 100644 --- a/src/lib/usage.test.ts +++ b/src/lib/usage.test.ts @@ -24,6 +24,21 @@ describe("usage formatting", () => { ).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 2430669cd..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,7 +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 (typeof n !== "number" || !Number.isFinite(n)) return "0"; + 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`; @@ -32,7 +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 (typeof usd !== "number" || !Number.isFinite(usd)) return ""; + if (!hasFiniteCost(usd)) return ""; if (usd === 0) return "$0"; if (usd < 0.01) return `$${usd.toFixed(3)}`; return `$${usd.toFixed(2)}`; @@ -43,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(" · "); } From 37aa31bbfe1324a641996def0d1f8ce7a768c5ac Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 20 Aug 2026 20:18:14 +0200 Subject: [PATCH 09/10] Harden the Kimi TOML scanner around comments and array tables. Skip # comments in tomlTables so an apostrophe in a comment cannot open a phantom string and hide the real model heading. Treat [[array]] headings as table boundaries without patching them, so protocol keys land in the model table instead of the following hooks array. --- server/drivers/acp/kimi.ts | 38 +++++++++++++++++------ server/drivers/local-inject.test.ts | 47 +++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/server/drivers/acp/kimi.ts b/server/drivers/acp/kimi.ts index 9c49621c0..c55334c9d 100644 --- a/server/drivers/acp/kimi.ts +++ b/server/drivers/acp/kimi.ts @@ -127,10 +127,12 @@ function tomlRowKey(row: string): string { return unquoteTomlKey(eq < 0 ? row : row.slice(0, eq)); } -/** Walk `text` and yield tables, skipping `[` inside strings (including multiline). */ +/** 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; lineStart: number; lineEnd: number }> = []; + 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"; @@ -187,26 +189,42 @@ function tomlTables(text: string): Array<{ name: string; headingStart: number; b 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 name = canonicalizeTomlHeading(text.slice(j, lineEnd).replace(/\r$/, "")); - if (name) headings.push({ name, lineStart: i, lineEnd }); + 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) => ({ - name: heading.name, - headingStart: heading.lineStart, - bodyStart: heading.lineEnd + (text[heading.lineEnd] === "\n" ? 1 : 0), - end: index + 1 < headings.length ? headings[index + 1]!.lineStart : text.length, - })); + 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. */ diff --git a/server/drivers/local-inject.test.ts b/server/drivers/local-inject.test.ts index 88fb856f8..c712f7c45 100644 --- a/server/drivers/local-inject.test.ts +++ b/server/drivers/local-inject.test.ts @@ -402,6 +402,53 @@ describe("ensureKimiInjectAlias", () => { 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 whitespace around dotted heading keys as the same table", () => { const home = mkdtempSync(join(tmpdir(), "omb-kimi-dots-")); scratchDirs.push(home); From 1a2cd0cbab3320c9c26dfccf3e41faddb0263528 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 20 Aug 2026 20:46:10 +0200 Subject: [PATCH 10/10] Decode TOML basic-string unicode escapes in Kimi headings. \u0035 in a quoted table key is 5, not the letters u0035, so an existing [models."omlx/GLM-\u0035.2-fp8"] matches the inject alias and is patched instead of duplicating the table. --- server/drivers/acp/kimi.ts | 33 +++++++++++++++++++++++++++-- server/drivers/local-inject.test.ts | 17 +++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/server/drivers/acp/kimi.ts b/server/drivers/acp/kimi.ts index c55334c9d..a3d2a3759 100644 --- a/server/drivers/acp/kimi.ts +++ b/server/drivers/acp/kimi.ts @@ -69,6 +69,34 @@ function stripTomlLineComment(line: string): string { 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(); @@ -88,8 +116,9 @@ function canonicalizeTomlHeading(heading: string): string | null { let value = ""; while (i < inner.length && inner[i] !== q) { if (q === '"' && inner[i] === "\\") { - value += inner[i + 1] ?? ""; - i += 2; + const taken = takeTomlBasicEscape(inner, i); + value += taken.value; + i = taken.next; continue; } value += inner[i]; diff --git a/server/drivers/local-inject.test.ts b/server/drivers/local-inject.test.ts index c712f7c45..5aff8ab89 100644 --- a/server/drivers/local-inject.test.ts +++ b/server/drivers/local-inject.test.ts @@ -449,6 +449,23 @@ describe("ensureKimiInjectAlias", () => { 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);