auto-model-router 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +207 -15
  3. package/docs/data-governance.md +210 -0
  4. package/package.json +1 -1
  5. package/src/cli/config-wizard.ts +11 -1
  6. package/src/cli/connect.ts +131 -2
  7. package/src/cli/harnesses.ts +277 -0
  8. package/src/config/defaults.ts +7 -1
  9. package/src/config/hot-reload.ts +3 -2
  10. package/src/config/redaction.ts +265 -0
  11. package/src/config/schema.ts +25 -1
  12. package/src/config/types.ts +52 -5
  13. package/src/cost/ledger.ts +39 -5
  14. package/src/cost/report.ts +19 -0
  15. package/src/cost/retention.ts +58 -0
  16. package/src/cost/types.ts +26 -2
  17. package/src/index.ts +1 -0
  18. package/src/lib.ts +8 -2
  19. package/src/server/http.ts +44 -14
  20. package/src/server/redact.ts +104 -0
  21. package/src/server/turn.ts +21 -0
  22. package/src/util/sqlite.ts +23 -1
  23. package/test/config-wizard.test.ts +1 -1
  24. package/test/connect-harnesses.test.ts +299 -0
  25. package/test/failover.test.ts +1 -0
  26. package/test/fixtures/connect/cline-providers.json +16 -0
  27. package/test/fixtures/connect/continue-config.yaml +14 -0
  28. package/test/fixtures/connect/opencode.json +15 -0
  29. package/test/fixtures/harness/cline-cli-connected.json +817 -0
  30. package/test/harness-requests.test.ts +13 -2
  31. package/test/migrations.test.ts +6 -3
  32. package/test/redaction.test.ts +300 -0
  33. package/test/report-hub.test.ts +2 -0
  34. package/test/report.test.ts +2 -0
  35. package/test/retention.test.ts +242 -0
  36. package/test/tokens.test.ts +3 -3
  37. package/test/trust-attribution.test.ts +2 -2
  38. package/test/turn.test.ts +107 -0
  39. package/tools/capture-proxy.ts +1 -1
@@ -5,6 +5,8 @@ import { createProviders } from "./providers.ts";
5
5
  import { createBridgeFromConfig } from "../context/index.ts";
6
6
  import { createFeedbackStore, type Verdict } from "../cost/feedback.ts";
7
7
  import { createLedger } from "../cost/ledger.ts";
8
+ import { createRetentionRunner } from "../cost/retention.ts";
9
+ import { redactionRulesFor } from "../config/redaction.ts";
8
10
  import { createSessionOverrides } from "./overrides.ts";
9
11
  import { catalogView } from "./catalog-view.ts";
10
12
  import { buildUpstreamModels } from "../catalog/static-catalog.ts";
@@ -265,6 +267,18 @@ export function startServer(cfg: RouterConfig): StartedServer {
265
267
  },
266
268
  );
267
269
 
270
+ // Compile the redaction rules before the listener exists: a rule that does
271
+ // not load is a hole in the guard, and an operator who configured redaction
272
+ // must not get a router that started and forwarded anyway. Programmatic
273
+ // overrides (an embedder's) never pass through the config schema, so this is
274
+ // the only place their rules are checked.
275
+ const redactionRules = redactionRulesFor(cfg.redaction);
276
+ if (redactionRules.length > 0) {
277
+ // Names only. The patterns describe the secrets and the matches are the
278
+ // secrets; neither belongs in a log line.
279
+ log.info("redaction enabled", { rules: redactionRules.map((r) => r.name).join(","), scanTools: cfg.redaction.scanTools });
280
+ }
281
+
268
282
  if (context.enabled) {
269
283
  log.info("agentdox context bridge enabled", {
270
284
  url: cfg.context.baseUrl,
@@ -296,8 +310,24 @@ export function startServer(cfg: RouterConfig): StartedServer {
296
310
  log.warn("initial catalog fetch failed", { error: err instanceof Error ? err.message : String(err) });
297
311
  });
298
312
 
299
- // One housekeeping timer for both tables. `unref`'d so it never holds the
300
- // process open.
313
+ // Ledger retention. The runner owns the once-an-hour floor, so the minute
314
+ // timer below, the boot run and `POST /v1/router/prune` cannot between them
315
+ // run a whole-ledger delete more often than that. It reads the window live,
316
+ // so a hot reload that lowers it applies on the next tick.
317
+ const retention = createRetentionRunner({ ledger, retentionDays: () => cfg.ledger.retentionDays });
318
+ const retain = (): void => {
319
+ try {
320
+ const result = retention.maybeRun();
321
+ if (result !== null && result.deleted > 0) {
322
+ log.info("pruned ledger rows past retention", { deleted: result.deleted, retentionDays: cfg.ledger.retentionDays });
323
+ }
324
+ } catch (err) {
325
+ log.warn("ledger retention prune failed", { error: err instanceof Error ? err.message : String(err) });
326
+ }
327
+ };
328
+
329
+ // One housekeeping timer for all three tables. `unref`'d so it never holds
330
+ // the process open.
301
331
  const pruneTimer = setInterval(() => {
302
332
  try {
303
333
  const dropped = conversations.prune(cfg.ledger.conversationTtlMs);
@@ -315,21 +345,12 @@ export function startServer(cfg: RouterConfig): StartedServer {
315
345
  } catch (err) {
316
346
  log.warn("context block prune failed", { error: err instanceof Error ? err.message : String(err) });
317
347
  }
348
+ retain();
318
349
  }, 60_000);
319
350
  pruneTimer.unref();
320
351
 
321
- // Ledger retention: hourly, and once at boot so a lowered setting takes
322
- // effect without waiting. Reads the live config, so it hot-reloads.
323
- const retain = (): void => {
324
- try {
325
- const dropped = ledger.prune?.(cfg.ledger.retentionDays) ?? 0;
326
- if (dropped > 0) log.info("pruned ledger rows past retention", { dropped, retentionDays: cfg.ledger.retentionDays });
327
- } catch (err) {
328
- log.warn("ledger retention prune failed", { error: err instanceof Error ? err.message : String(err) });
329
- }
330
- };
331
- const retentionTimer = setInterval(retain, 3_600_000);
332
- retentionTimer.unref();
352
+ // Once shortly after boot, so a lowered window takes effect without waiting
353
+ // out an hour; the runner's own floor governs everything after that.
333
354
  setTimeout(retain, 5_000).unref();
334
355
 
335
356
  // Periodically refetch the (key-scoped) catalog in the background so
@@ -665,6 +686,15 @@ export function startServer(cfg: RouterConfig): StartedServer {
665
686
  }),
666
687
  );
667
688
  }
689
+ if (req.method === "POST" && url.pathname === "/v1/router/prune") {
690
+ // A front door of the team edition holds a READ-ONLY handle on the
691
+ // ledger file by design, so this route is the only way it can act
692
+ // on its own retention policy — and the once-an-hour floor is the
693
+ // runner's, not the caller's, so calling it in a loop is harmless.
694
+ const result = retention.runNow();
695
+ if (result.deleted > 0) log.info("pruned ledger rows past retention", { deleted: result.deleted, retentionDays: cfg.ledger.retentionDays });
696
+ return json({ ...result, retentionDays: cfg.ledger.retentionDays });
697
+ }
668
698
  if (req.method === "POST" && url.pathname === "/v1/router/feedback") {
669
699
  // A user verdict on the newest routed turn of an omp session.
670
700
  const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Redaction: strings an operator forbids from leaving the process.
3
+ *
4
+ * An operator with a compliance obligation needs two things a router can
5
+ * actually give: certainty that certain shapes of text never reach a provider,
6
+ * and evidence that the guard ran. This module is the first half; the ledger's
7
+ * `redactions` count is the second. Neither ever records WHAT was matched — a
8
+ * redaction log that quotes the secret is just a second copy of the secret.
9
+ *
10
+ * `runTurn` applies this to the rendered upstream body, which is the
11
+ * chat-completions shape every front end normalises to and every provider
12
+ * client renders from, so the guard sits between the router and ALL of them:
13
+ * a new upstream cannot bypass it by construction.
14
+ */
15
+
16
+ import type { CompiledRedactionRule } from "../config/redaction.ts";
17
+
18
+ /** Applies every rule to one string. Returns the text and how many matches were replaced. */
19
+ export function redactText(text: string, rules: readonly CompiledRedactionRule[]): { text: string; count: number } {
20
+ let out = text;
21
+ let count = 0;
22
+ for (const rule of rules) {
23
+ // `replace` with a global regex resets and advances `lastIndex` itself,
24
+ // including over a zero-width position — which compilation refuses anyway.
25
+ out = out.replace(rule.regex, () => {
26
+ count++;
27
+ return rule.replacement;
28
+ });
29
+ }
30
+ return { text: out, count };
31
+ }
32
+
33
+ type Rec = Record<string, unknown>;
34
+ const isRec = (v: unknown): v is Rec => v !== null && typeof v === "object" && !Array.isArray(v);
35
+
36
+ /** Redacts a message's `content`, whether it is a string or an array of parts. Returns matches replaced. */
37
+ function redactContent(message: Rec, rules: readonly CompiledRedactionRule[]): number {
38
+ const content = message.content;
39
+ if (typeof content === "string") {
40
+ const r = redactText(content, rules);
41
+ if (r.count > 0) message.content = r.text;
42
+ return r.count;
43
+ }
44
+ if (!Array.isArray(content)) return 0;
45
+ let count = 0;
46
+ for (const part of content) {
47
+ // Text parts only: an `image_url` part carries a data URI, which no
48
+ // redaction rule can meaningfully read and every rule would be slow over.
49
+ if (!isRec(part) || part.type !== "text" || typeof part.text !== "string") continue;
50
+ const r = redactText(part.text, rules);
51
+ if (r.count > 0) part.text = r.text;
52
+ count += r.count;
53
+ }
54
+ return count;
55
+ }
56
+
57
+ /**
58
+ * Redacts the rendered upstream body in place and returns how many matches
59
+ * were replaced.
60
+ *
61
+ * The body is the chat-completions shape: every front end (chat completions,
62
+ * Responses, Anthropic Messages) translates into it before parsing, and every
63
+ * upstream client renders its own protocol FROM it, so this one pass covers
64
+ * every wire in and every provider out.
65
+ *
66
+ * What is scanned:
67
+ * - the text content of every message that is not a tool result — the system
68
+ * prompt (including the injected agentdox block), the user's words, the
69
+ * assistant's replay;
70
+ * - with `scanTools`, tool-call arguments and tool-result content as well.
71
+ * Off by default because tool results are where the bytes are: a turn's
72
+ * prompt is mostly file content, so scanning them is most of the cost — and
73
+ * also, for an operator who cares about a secret in a file the agent read,
74
+ * most of the point.
75
+ *
76
+ * Tool NAMES, ids and the tool schemas are left alone: they are the harness's
77
+ * own vocabulary, not conversation content, and rewriting one breaks the
78
+ * call/result pairing the model needs.
79
+ */
80
+ export function redactUpstreamBody(
81
+ body: Rec,
82
+ rules: readonly CompiledRedactionRule[],
83
+ opts: { scanTools: boolean },
84
+ ): number {
85
+ if (rules.length === 0) return 0;
86
+ const messages = body.messages;
87
+ if (!Array.isArray(messages)) return 0;
88
+ let count = 0;
89
+ for (const message of messages) {
90
+ if (!isRec(message)) continue;
91
+ const isToolResult = message.role === "tool";
92
+ if (!isToolResult || opts.scanTools) count += redactContent(message, rules);
93
+ if (!opts.scanTools || !Array.isArray(message.tool_calls)) continue;
94
+ for (const call of message.tool_calls) {
95
+ if (!isRec(call) || !isRec(call.function)) continue;
96
+ const args = call.function.arguments;
97
+ if (typeof args !== "string") continue;
98
+ const r = redactText(args, rules);
99
+ if (r.count > 0) call.function.arguments = r.text;
100
+ count += r.count;
101
+ }
102
+ }
103
+ return count;
104
+ }
@@ -29,6 +29,8 @@ import {
29
29
  import { UpstreamError, type Dispatch, type UpstreamClient } from "../upstream/types.ts";
30
30
  import type { SessionOverrides } from "./overrides.ts";
31
31
  import { digestCompactionEdits, type CompactionDigester } from "./compaction-digest.ts";
32
+ import { redactUpstreamBody } from "./redact.ts";
33
+ import { redactionRulesFor } from "../config/redaction.ts";
32
34
  import { createLogger } from "../util/log.ts";
33
35
  import type { NormRequest, ResponseSink, TurnSummary, UpstreamChunk } from "../wire/types.ts";
34
36
 
@@ -165,6 +167,10 @@ export async function runTurn(
165
167
  let pendingDecision: Decision | null = null;
166
168
  // Digests made this turn, by edit; a retry re-plans and must not pay twice.
167
169
  const digestMemo = new Map<string, string>();
170
+ // Compiled once per turn (and memoised across turns on the rules' own text),
171
+ // never per attempt: a retry renders the same request again and would
172
+ // otherwise recompile every pattern.
173
+ const redactionRules = redactionRulesFor(config.redaction);
168
174
 
169
175
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
170
176
  // Client disconnected before anything was dispatched: spend nothing.
@@ -261,6 +267,18 @@ export async function runTurn(
261
267
  ...(decision.compactionPlan.length > 0 ? { compactionPlan: decision.compactionPlan } : {}),
262
268
  });
263
269
 
270
+ // Redaction, immediately after the body is rendered and before ANYTHING
271
+ // can dispatch it. This is the only choke point that covers every path:
272
+ // all three front ends normalise to this shape, and every upstream client
273
+ // renders its own protocol from it, so a provider added later is covered
274
+ // without being told. The count is recorded on the ledger row below; the
275
+ // matched text is never logged, at any level.
276
+ let redactions = 0;
277
+ if (redactionRules.length > 0) {
278
+ redactions = redactUpstreamBody(body, redactionRules, { scanTools: config.redaction.scanTools });
279
+ if (redactions > 0) log.debug("redacted outgoing request", { matches: redactions, rules: redactionRules.length });
280
+ }
281
+
264
282
  // Calibrate the token estimate against the bytes that actually go out —
265
283
  // after compaction shrank the prompt and the context block was appended
266
284
  // — not the raw request the estimate was taken from.
@@ -333,6 +351,9 @@ export async function runTurn(
333
351
  upstreamGenerationId: generationId,
334
352
  error: fields.error,
335
353
  promptTokensSaved: decision.promptTokensSaved,
354
+ // Evidence that the guard ran, as a count and nothing more. Absent
355
+ // while redaction is off, so an untouched row stays NULL.
356
+ ...(redactionRules.length === 0 ? {} : { redactions }),
336
357
  // The ledger prices OpenRouter slugs from its own cached payload; any
337
358
  // other provider's model exists only in the live catalog.
338
359
  ...(priceModel === undefined ? {} : { priceModel }),
@@ -18,7 +18,7 @@ import { mkdirSync } from "node:fs";
18
18
  import { dirname } from "node:path";
19
19
 
20
20
  /** Bump when a migration is added; guarded below so reopening never regresses it. */
21
- const USER_VERSION = 18;
21
+ const USER_VERSION = 19;
22
22
 
23
23
  const MIGRATIONS = `
24
24
  CREATE TABLE IF NOT EXISTS catalog_cache (
@@ -296,6 +296,16 @@ const MIGRATE_V18 = `
296
296
  ALTER TABLE ledger ADD COLUMN scope TEXT;
297
297
  `;
298
298
 
299
+ // v19: ledger records how many strings redaction removed from the turn's
300
+ // outgoing request (see `redaction` in the config). A COUNT and nothing else:
301
+ // the whole point of redaction is that the matched text does not exist outside
302
+ // the client, so the evidence that it ran must not reintroduce it. NULL on
303
+ // every row written before this and on every turn with redaction off; 0 means
304
+ // the rules ran and matched nothing.
305
+ const MIGRATE_V19 = `
306
+ ALTER TABLE ledger ADD COLUMN redactions INTEGER;
307
+ `;
308
+
299
309
  // v9: benchmark_cache holds the external benchmark feeds (Artificial Analysis,
300
310
  // BenchLM) that backfill quality scores OpenRouter leaves unpublished. It is a
301
311
  // whole new table, created idempotently by the MIGRATIONS block above, so there
@@ -320,6 +330,17 @@ export function openDb(path: string): Database {
320
330
  // ":memory:" has no parent directory to create.
321
331
  if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
322
332
  const db = new Database(path);
333
+ // Incremental auto-vacuum so retention can hand freed pages back to the
334
+ // filesystem (`PRAGMA incremental_vacuum` after a prune). SQLite only honours
335
+ // a change of vacuum mode on an empty database or across a full VACUUM, so
336
+ // this takes effect for NEW ledgers; an existing one keeps its mode and
337
+ // simply reuses freed pages instead of releasing them. Must precede the
338
+ // journal-mode change, and is best-effort: never fail an open over it.
339
+ try {
340
+ db.exec("PRAGMA auto_vacuum = INCREMENTAL");
341
+ } catch {
342
+ /* an existing database in another mode; freed pages are reused instead */
343
+ }
323
344
  // WAL + NORMAL: single-writer local service; favours read latency on the turn hot path.
324
345
  db.exec("PRAGMA journal_mode = WAL");
325
346
  db.exec("PRAGMA synchronous = NORMAL");
@@ -338,6 +359,7 @@ export function openDb(path: string): Database {
338
359
  if (!ledgerCols.some((c) => c.name === "hold_arm")) db.exec(MIGRATE_V8);
339
360
  if (!ledgerCols.some((c) => c.name === "prompt_tokens_saved")) db.exec(MIGRATE_V12);
340
361
  if (!ledgerCols.some((c) => c.name === "scope")) db.exec(MIGRATE_V18);
362
+ if (!ledgerCols.some((c) => c.name === "redactions")) db.exec(MIGRATE_V19);
341
363
  const convCols = db.query("PRAGMA table_info(conversations)").all() as { name: string }[];
342
364
  if (!convCols.some((c) => c.name === "context_version")) db.exec(MIGRATE_V11);
343
365
  if (!convCols.some((c) => c.name === "compaction_plan")) db.exec(MIGRATE_V13);
@@ -142,7 +142,7 @@ describe("validateField", () => {
142
142
 
143
143
  describe("WIZARD_SECTIONS coverage", () => {
144
144
  /** Leaves that are edited as whole records/arrays rather than fields. */
145
- const RECORD_PATHS = new Set(["ollama.prices", "ollama.twins", "digest.toolAliases", "profiles", "upstreams"]);
145
+ const RECORD_PATHS = new Set(["ollama.prices", "ollama.twins", "digest.toolAliases", "profiles", "upstreams", "redaction.rules"]);
146
146
 
147
147
  function leaves(obj: unknown, prefix = ""): string[] {
148
148
  if (typeof obj !== "object" || obj === null || Array.isArray(obj)) return [prefix];
@@ -0,0 +1,299 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { connectRemote, type ConnectOptions, type ConnectReport } from "../src/cli/connect.ts";
7
+ import { mergeClineProviders, mergeContinueConfig, mergeOpenCodeConfig } from "../src/cli/harnesses.ts";
8
+
9
+ /**
10
+ * The harnesses `connect` reaches beyond omp, Hermes, Codex, Aider and Claude
11
+ * Code. Each automated one starts from a fixture in the shape its harness
12
+ * actually writes (`test/fixtures/connect/`), so the assertions are about
13
+ * merging into a REAL file rather than into an empty one: the user's other keys,
14
+ * providers and comments must survive, a second run must change nothing,
15
+ * `--harness <id>` must select exactly one, and a machine without the harness
16
+ * must be told it was skipped rather than have a file invented for it.
17
+ *
18
+ * The two manual harnesses are held to the other half of that promise — they
19
+ * print and write nothing at all.
20
+ */
21
+
22
+ const FIXTURES = join(import.meta.dir, "fixtures", "connect");
23
+
24
+ /** A temp HOME with nothing in it: every harness is absent until a test plants one. */
25
+ function bare(extra: Partial<ConnectOptions> = {}): { home: string; o: ConnectOptions } {
26
+ const home = mkdtempSync(join(tmpdir(), "amr-harness-"));
27
+ const o: ConnectOptions = {
28
+ url: "https://team.example",
29
+ key: "amrt_key",
30
+ userId: "u_ada",
31
+ name: "Ada",
32
+ profile: false,
33
+ dryRun: false,
34
+ only: [],
35
+ // Point every harness this suite does not exercise at a path that does not exist.
36
+ env: { HOME: home, PI_CODING_AGENT_DIR: join(home, "no-omp"), HERMES_HOME: join(home, "no-hermes"), AUTO_MODEL_ROUTER_HOME: join(home, ".auto-model-router"), XDG_CONFIG_HOME: join(home, ".config") },
37
+ home,
38
+ packageDir: process.cwd(),
39
+ platform: "linux",
40
+ pathHas: () => false,
41
+ ...extra,
42
+ };
43
+ return { home, o };
44
+ }
45
+
46
+ /** Plants a captured config file for a harness under the temp HOME. */
47
+ function plant(home: string, fixture: string, ...rel: string[]): string {
48
+ const target = join(home, ...rel);
49
+ mkdirSync(join(target, ".."), { recursive: true });
50
+ cpSync(join(FIXTURES, fixture), target);
51
+ return target;
52
+ }
53
+
54
+ const configuredFor = (r: ConnectReport, prefix: string): string | undefined => r.configured.find((c) => c.startsWith(prefix));
55
+ const backupsIn = (dir: string): string[] => readdirSync(dir).filter((f) => f.endsWith(".bak"));
56
+
57
+ describe("OpenCode", () => {
58
+ test("the provider block is merged into opencode.json, the plugin is copied, and a second run changes nothing", () => {
59
+ const { home, o } = bare();
60
+ const dir = join(home, ".config", "opencode");
61
+ const cfg = plant(home, "opencode.json", ".config", "opencode", "opencode.json");
62
+ try {
63
+ const r = connectRemote(o);
64
+ expect(configuredFor(r, "OpenCode (")).toContain("opencode.json");
65
+ // The exact file: our provider added, `model` pointed at it, and every
66
+ // other key — the schema, the MCP server, the user's own Ollama provider — kept.
67
+ expect(JSON.parse(readFileSync(cfg, "utf8"))).toEqual({
68
+ $schema: "https://opencode.ai/config.json",
69
+ model: "auto-model-router/auto",
70
+ mcp: { unityMCP: { type: "remote", url: "http://127.0.0.1:8080/mcp", enabled: true } },
71
+ provider: {
72
+ ollama: {
73
+ npm: "@ai-sdk/openai-compatible",
74
+ name: "Ollama",
75
+ options: { baseURL: "http://localhost:11434/v1" },
76
+ models: { "qwen2.5:7b": { name: "Qwen2.5 7B (local)" } },
77
+ },
78
+ "auto-model-router": {
79
+ npm: "@ai-sdk/openai-compatible",
80
+ name: "auto-model-router",
81
+ options: { baseURL: "https://team.example/v1", apiKey: "amrt_key", headers: { "X-Omp-Harness": "opencode" } },
82
+ models: { auto: { name: "auto" }, "auto-cheap": { name: "auto-cheap" }, "auto-max": { name: "auto-max" } },
83
+ },
84
+ },
85
+ });
86
+ // The plugin is what gives OpenCode session identity, the toast and the digest.
87
+ expect(readFileSync(join(dir, "plugin", "auto-model-router.ts"), "utf8")).toContain("chat.headers");
88
+ expect(backupsIn(dir)).toHaveLength(1); // the previous file was kept
89
+ const after = readFileSync(cfg, "utf8");
90
+ connectRemote(o);
91
+ expect(readFileSync(cfg, "utf8")).toBe(after);
92
+ expect(backupsIn(dir)).toHaveLength(1); // and a no-op run backs nothing up
93
+ } finally {
94
+ rmSync(home, { recursive: true, force: true });
95
+ }
96
+ });
97
+
98
+ test("--harness opencode selects it alone, and an absent OpenCode is reported as skipped", () => {
99
+ const { home, o } = bare({ only: ["opencode"] });
100
+ plant(home, "opencode.json", ".config", "opencode", "opencode.json");
101
+ try {
102
+ expect(connectRemote(o).configured.map((c) => c.split(" (")[0])).toEqual(["OpenCode"]);
103
+ } finally {
104
+ rmSync(home, { recursive: true, force: true });
105
+ }
106
+ const { home: h2, o: o2 } = bare();
107
+ try {
108
+ expect(connectRemote(o2).skipped).toContain("OpenCode (not on PATH and no ~/.config/opencode)");
109
+ expect(existsSync(join(h2, ".config", "opencode"))).toBe(false);
110
+ } finally {
111
+ rmSync(h2, { recursive: true, force: true });
112
+ }
113
+ });
114
+
115
+ test("a pinned scope rides in the provider's headers; a file we cannot parse is left alone", () => {
116
+ expect(JSON.parse(mergeOpenCodeConfig("", "https://t", "k", "omp-router")!).provider["auto-model-router"].options.headers).toEqual({ "X-Omp-Harness": "opencode", "X-Agentdox-Scope": "omp-router" });
117
+ expect(mergeOpenCodeConfig("[]", "https://t", "k")).toBeNull();
118
+ expect(mergeOpenCodeConfig("{ not json", "https://t", "k")).toBeNull();
119
+ expect(mergeOpenCodeConfig(mergeOpenCodeConfig("", "https://t", "k")!, "https://t", "k")).toBeNull();
120
+ });
121
+ });
122
+
123
+ describe("Cline", () => {
124
+ const REL = [".cline", "data", "settings"];
125
+
126
+ test("the openai-compatible provider is merged into providers.json beside the user's own, and a second run changes nothing", () => {
127
+ const { home, o } = bare();
128
+ const dir = join(home, ...REL);
129
+ const cfg = plant(home, "cline-providers.json", ...REL, "providers.json");
130
+ try {
131
+ const r = connectRemote(o);
132
+ expect(configuredFor(r, "Cline (")).toContain("providers.json");
133
+ const written = JSON.parse(readFileSync(cfg, "utf8")) as Record<string, unknown>;
134
+ // The harness id can only travel in `settings.headers`: cline's own auth
135
+ // command has no flag for it, which is why connect writes the file itself.
136
+ expect((written.providers as Record<string, { settings: unknown }>)["openai-compatible"]!.settings).toEqual({
137
+ provider: "openai-compatible",
138
+ apiKey: "amrt_key",
139
+ model: "auto",
140
+ baseUrl: "https://team.example/v1",
141
+ headers: { "X-Omp-Harness": "cline" },
142
+ });
143
+ expect(written.lastUsedProvider).toBe("openai-compatible");
144
+ expect(written.version).toBe(1);
145
+ // The user's other provider is untouched, key and stamp included.
146
+ expect((written.providers as Record<string, unknown>).anthropic).toEqual({
147
+ settings: { provider: "anthropic", apiKey: "sk-ant-existing", model: "claude-sonnet-4-5" },
148
+ updatedAt: "2026-09-10T05:27:55.818Z",
149
+ tokenSource: "manual",
150
+ });
151
+ expect(backupsIn(dir)).toHaveLength(1);
152
+ const after = readFileSync(cfg, "utf8");
153
+ connectRemote(o);
154
+ expect(readFileSync(cfg, "utf8")).toBe(after);
155
+ expect(backupsIn(dir)).toHaveLength(1);
156
+ } finally {
157
+ rmSync(home, { recursive: true, force: true });
158
+ }
159
+ });
160
+
161
+ test("--harness cline selects it alone, and an absent Cline is reported as skipped", () => {
162
+ const { home, o } = bare({ only: ["cline"] });
163
+ plant(home, "cline-providers.json", ...REL, "providers.json");
164
+ try {
165
+ expect(connectRemote(o).configured.map((c) => c.split(" (")[0])).toEqual(["Cline"]);
166
+ } finally {
167
+ rmSync(home, { recursive: true, force: true });
168
+ }
169
+ const { home: h2, o: o2 } = bare();
170
+ try {
171
+ expect(connectRemote(o2).skipped).toContain("Cline (not on PATH and no ~/.cline)");
172
+ expect(existsSync(join(h2, ".cline"))).toBe(false);
173
+ } finally {
174
+ rmSync(h2, { recursive: true, force: true });
175
+ }
176
+ });
177
+
178
+ test("the stamp only moves when the settings do, a pinned scope rides along, and an unreadable file is left alone", () => {
179
+ const first = mergeClineProviders("", "https://t", "k", "2026-01-01T00:00:00.000Z", "omp-router")!;
180
+ expect(JSON.parse(first).providers["openai-compatible"].settings.headers).toEqual({ "X-Omp-Harness": "cline", "X-Agentdox-Scope": "omp-router" });
181
+ expect(mergeClineProviders(first, "https://t", "k", "2027-01-01T00:00:00.000Z", "omp-router")).toBeNull();
182
+ // A new key is a real change, so the stamp does move.
183
+ const rekeyed = mergeClineProviders(first, "https://t", "k2", "2027-01-01T00:00:00.000Z", "omp-router")!;
184
+ expect(JSON.parse(rekeyed).providers["openai-compatible"].updatedAt).toBe("2027-01-01T00:00:00.000Z");
185
+ expect(mergeClineProviders("nope", "https://t", "k", "2026-01-01T00:00:00.000Z")).toBeNull();
186
+ });
187
+ });
188
+
189
+ describe("Continue", () => {
190
+ test("the three profiles become model entries in config.yaml, keeping the file's comments, and a second run changes nothing", () => {
191
+ const { home, o } = bare();
192
+ const dir = join(home, ".continue");
193
+ const cfg = plant(home, "continue-config.yaml", ".continue", "config.yaml");
194
+ try {
195
+ const r = connectRemote(o);
196
+ expect(configuredFor(r, "Continue (")).toContain("config.yaml");
197
+ const text = readFileSync(cfg, "utf8");
198
+ // The document is edited, not re-serialised: comments, the user's own model
199
+ // and the context blocks are all still there, in their original order.
200
+ expect(text).toContain("# My Continue assistant — hand-edited, comments and all.");
201
+ expect(text).toContain("name: Local Assistant");
202
+ expect(text).toContain(" - name: Ollama Qwen");
203
+ expect(text.indexOf("context:")).toBeGreaterThan(text.indexOf("auto-model-router-max"));
204
+ for (const [name, model] of [
205
+ ["auto-model-router", "auto"],
206
+ ["auto-model-router-cheap", "auto-cheap"],
207
+ ["auto-model-router-max", "auto-max"],
208
+ ]) {
209
+ expect(text).toContain(` - name: ${name}\n provider: openai\n model: ${model}\n apiBase: https://team.example/v1\n apiKey: amrt_key\n`);
210
+ }
211
+ expect(text).toContain(" X-Omp-Harness: continue");
212
+ expect(backupsIn(dir)).toHaveLength(1);
213
+ connectRemote(o);
214
+ expect(readFileSync(cfg, "utf8")).toBe(text);
215
+ expect(backupsIn(dir)).toHaveLength(1);
216
+ } finally {
217
+ rmSync(home, { recursive: true, force: true });
218
+ }
219
+ });
220
+
221
+ test("--harness continue selects it alone, and an absent Continue is reported as skipped", () => {
222
+ const { home, o } = bare({ only: ["continue"] });
223
+ plant(home, "continue-config.yaml", ".continue", "config.yaml");
224
+ try {
225
+ expect(connectRemote(o).configured.map((c) => c.split(" (")[0])).toEqual(["Continue"]);
226
+ } finally {
227
+ rmSync(home, { recursive: true, force: true });
228
+ }
229
+ const { home: h2, o: o2 } = bare();
230
+ try {
231
+ expect(connectRemote(o2).skipped).toContain("Continue (no ~/.continue)");
232
+ expect(existsSync(join(h2, ".continue"))).toBe(false);
233
+ } finally {
234
+ rmSync(h2, { recursive: true, force: true });
235
+ }
236
+ });
237
+
238
+ test("a fresh file gets the schema's required fields first, a re-key replaces entries in place, and broken YAML is left alone", () => {
239
+ const fresh = mergeContinueConfig("", "https://t", "k")!;
240
+ expect(fresh.startsWith("name: auto-model-router\nversion: 0.0.1\nschema: v1\nmodels:\n - name:")).toBe(true);
241
+ expect(mergeContinueConfig(fresh, "https://t", "k")).toBeNull();
242
+ const rekeyed = mergeContinueConfig(fresh, "https://t", "k2")!;
243
+ expect(rekeyed.match(/- name: auto-model-router\n/g)).toHaveLength(1); // replaced, not appended
244
+ expect(rekeyed).toContain("apiKey: k2");
245
+ expect(rekeyed).not.toContain("apiKey: k\n");
246
+ expect(mergeContinueConfig("models:\n - [unclosed\n", "https://t", "k")).toBeNull();
247
+ expect(mergeContinueConfig("- a\n- b\n", "https://t", "k")).toBeNull(); // a sequence is not an assistant file
248
+ });
249
+ });
250
+
251
+ describe("Cursor and Windsurf keep their provider settings where no file can reach them", () => {
252
+ test("connect prints what to set and writes nothing under either home", () => {
253
+ const { home, o } = bare({ only: ["cursor", "windsurf"] });
254
+ mkdirSync(join(home, ".cursor"), { recursive: true });
255
+ mkdirSync(join(home, ".codeium", "windsurf"), { recursive: true });
256
+ try {
257
+ const r = connectRemote(o);
258
+ expect(r.configured).toEqual([]);
259
+ expect(r.manual.map((m) => m.harness)).toEqual(["Cursor", "Windsurf"]);
260
+ const cursor = r.manual[0]!.lines.join("\n");
261
+ expect(cursor).toContain("base URL: https://team.example/v1");
262
+ expect(cursor).toContain("API key: amrt_key");
263
+ // Windsurf has no base-URL field of its own, so the useful answer is the extension.
264
+ expect(r.manual[1]!.lines.join("\n")).toContain("Cline extension");
265
+ // Nothing was written into either harness's directory.
266
+ expect(readdirSync(join(home, ".cursor"))).toEqual([]);
267
+ expect(readdirSync(join(home, ".codeium", "windsurf"))).toEqual([]);
268
+ } finally {
269
+ rmSync(home, { recursive: true, force: true });
270
+ }
271
+ });
272
+
273
+ test("a loopback router is called out for Cursor, which proxies chat through its own servers", () => {
274
+ const { home, o } = bare({ only: ["cursor"], url: "http://127.0.0.1:8788" });
275
+ mkdirSync(join(home, ".cursor"), { recursive: true });
276
+ try {
277
+ expect(connectRemote(o).manual[0]!.lines.join("\n")).toContain("reachable from the internet");
278
+ } finally {
279
+ rmSync(home, { recursive: true, force: true });
280
+ }
281
+ // A public remote gets no such warning, and an uninstalled Cursor gets no recipe.
282
+ const { home: h2, o: o2 } = bare({ only: ["cursor"] });
283
+ mkdirSync(join(h2, ".cursor"), { recursive: true });
284
+ try {
285
+ expect(connectRemote(o2).manual[0]!.lines.join("\n")).not.toContain("reachable from the internet");
286
+ } finally {
287
+ rmSync(h2, { recursive: true, force: true });
288
+ }
289
+ const { home: h3, o: o3 } = bare();
290
+ try {
291
+ const r = connectRemote(o3);
292
+ expect(r.manual).toEqual([]);
293
+ expect(r.skipped).toContain("Cursor (no ~/.cursor)");
294
+ expect(r.skipped).toContain("Windsurf (no ~/.codeium)");
295
+ } finally {
296
+ rmSync(h3, { recursive: true, force: true });
297
+ }
298
+ });
299
+ });
@@ -83,6 +83,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
83
83
  digest: { enabled: false, minBytes: 12_000, maxBytes: 400_000, tools: ["read"], fromTier: "moderate", tier: "simple", model: "", maxOutputTokens: 700, maxCostUsd: 0.02, timeoutMs: 25_000, toolAliases: {} },
84
84
  profiles: [],
85
85
  ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 , retentionDays: 0,},
86
+ redaction: { enabled: false, rules: [], scanTools: false },
86
87
  adaptiveTierFloors: true,
87
88
  adaptivePriceCeilings: false,
88
89
  logLevel: "silent",
@@ -0,0 +1,16 @@
1
+ {
2
+ "version": 1,
3
+ "lastUsedProvider": "anthropic",
4
+ "modes": {},
5
+ "providers": {
6
+ "anthropic": {
7
+ "settings": {
8
+ "provider": "anthropic",
9
+ "apiKey": "sk-ant-existing",
10
+ "model": "claude-sonnet-4-5"
11
+ },
12
+ "updatedAt": "2026-09-10T05:27:55.818Z",
13
+ "tokenSource": "manual"
14
+ }
15
+ }
16
+ }
@@ -0,0 +1,14 @@
1
+ # My Continue assistant — hand-edited, comments and all.
2
+ name: Local Assistant
3
+ version: 0.0.1
4
+ schema: v1
5
+ models:
6
+ - name: Ollama Qwen
7
+ provider: ollama
8
+ model: qwen2.5-coder:7b
9
+ roles:
10
+ - chat
11
+ - edit
12
+ context:
13
+ - provider: code
14
+ - provider: diff