auto-model-router 0.19.0 → 0.20.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.
@@ -7,14 +7,14 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
10
- "version": "0.19.0",
10
+ "version": "0.20.0",
11
11
  "pluginRoot": "."
12
12
  },
13
13
  "plugins": [
14
14
  {
15
15
  "name": "auto-model-router",
16
16
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
17
- "version": "0.19.0",
17
+ "version": "0.20.0",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -472,8 +472,17 @@ cline -P openai -m auto "your task"
472
472
  Verified live with cline 3.0 (captured request:
473
473
  `test/fixtures/harness/cline-cli.json`). The CLI sends native tool calls
474
474
  (`read_files`, `search_codebase`, `run_commands`, `fetch_web_content`, …),
475
- all in `digest.toolAliases`, and no custom headers, so its rows carry no
476
- harness id. No session id or hooks.
475
+ all in `digest.toolAliases`. No session id or hooks.
476
+
477
+ `auto-model-router connect` writes this itself, and writes one thing the
478
+ `auth` command cannot: Cline's provider store
479
+ (`~/.cline/data/settings/providers.json`) has a `settings.headers` map with no
480
+ CLI flag behind it, so a file written by hand is what finally gives Cline a
481
+ harness id. Verified live on cline 3.0.61 by pointing it at a recording
482
+ server — `test/fixtures/harness/cline-cli-connected.json` is that capture, with
483
+ `X-Omp-Harness: cline` on it. That one file also serves the **VS Code
484
+ extension**, which reads the same store since its settings migration, so the
485
+ extension needs no separate recipe (and neither does any editor that hosts it).
477
486
 
478
487
  ### Kilo Code CLI
479
488
 
@@ -534,10 +543,11 @@ id or hooks: the digest applies only through summarising compaction.
534
543
 
535
544
  ### Cline (VS Code)
536
545
 
537
- Choose the *OpenAI Compatible* provider in the extension's settings, set the
538
- base URL to `http://127.0.0.1:8788/v1`, any API key, and the model id `auto`
539
- (or `auto-cheap` / `auto-max`); add `X-Omp-Harness` under custom headers if
540
- offered. Not verified live here (the CLI above was); its tool names are in
546
+ Nothing extra: the extension reads the same provider store as the CLI
547
+ (`~/.cline/data/settings/providers.json`), so `connect` has already configured
548
+ it pick the *OpenAI Compatible* provider and the `auto` model. By hand, set
549
+ the base URL to `http://127.0.0.1:8788/v1`, any API key, and the model id
550
+ `auto` (or `auto-cheap` / `auto-max`). Its tool names are in
541
551
  `digest.toolAliases`, and the digest applies only through summarising
542
552
  compaction.
543
553
 
@@ -564,9 +574,16 @@ SSE frame, which is why the router's final summary frame is shaped as a
564
574
  chunk with no choices. Its tool names (`read`, `grep`, `glob`, `bash`,
565
575
  `webfetch`) match the router's canonical list.
566
576
 
577
+ `auto-model-router connect` writes both halves of this: the provider block
578
+ (other providers, MCP servers and `$schema` untouched) and the plugin below.
579
+ The config directory is `~/.config/opencode` on **every** platform, Windows
580
+ included — OpenCode uses the XDG layout there rather than `%APPDATA%`.
581
+
567
582
  **Native features (OpenCode plugin API).** Copy
568
583
  `opencode-plugin/auto-model-router.ts` to `~/.config/opencode/plugin/` (or a
569
- project's `.opencode/plugin/`); OpenCode loads it on start. It adds:
584
+ project's `.opencode/plugin/`); OpenCode loads it on start (`plugins/`, the
585
+ name current docs use, is loaded too — both were confirmed on opencode 1.18).
586
+ It adds:
570
587
 
571
588
  - **Session identity** — `X-Omp-Session`, `X-Omp-Harness` (`opencode`, or
572
589
  `OMP_HARNESS_ID`) and `X-Omp-Subagent` for sessions with a parent, through
@@ -581,6 +598,57 @@ No `/router` command (OpenCode commands are markdown files, not plugin
581
598
  hooks): use `auto-model-router report` on the terminal, or the router's
582
599
  HTTP endpoints.
583
600
 
601
+ ### Continue
602
+
603
+ ```yaml
604
+ # ~/.continue/config.yaml
605
+ name: auto-model-router
606
+ version: 0.0.1
607
+ schema: v1
608
+ models:
609
+ - name: auto-model-router
610
+ provider: openai
611
+ model: auto
612
+ apiBase: http://127.0.0.1:8788/v1
613
+ apiKey: local
614
+ roles: [chat, edit, apply, summarize]
615
+ capabilities: [tool_use, image_input]
616
+ requestOptions:
617
+ headers:
618
+ X-Omp-Harness: continue
619
+ ```
620
+
621
+ `connect` writes all three profiles as entries like this one, editing the YAML
622
+ *document* rather than re-serialising it, so a hand-written config keeps its
623
+ comments, key order and other models; entries are matched by `name`, so a later
624
+ connect replaces them in place. Pick `auto-model-router` in the model dropdown.
625
+ This is Continue's documented assistant schema rather than a shape captured
626
+ here — Continue was not run against the router — and `config.yaml` takes
627
+ precedence over the older `config.json` if you still have one.
628
+
629
+ ### Cursor
630
+
631
+ Cursor has no provider file to write: the OpenAI override is an application
632
+ setting in the editor's own state. `connect` prints the values instead —
633
+ Cursor Settings → Models → OpenAI API Key, enable the base-URL override, then
634
+ the base URL, the key and `auto` as a custom model.
635
+
636
+ Two limits worth knowing before you try. Cursor proxies chat through **its own
637
+ servers** with your key attached, so the base URL has to be reachable from the
638
+ internet — a `127.0.0.1` router or a LAN team edition will never be called. And
639
+ the override carries no custom header, so those turns arrive with no
640
+ `X-Omp-Harness` id and share the unnamed budget.
641
+
642
+ ### Windsurf
643
+
644
+ Windsurf has no custom base-URL field at all: its bring-your-own-key page takes
645
+ first-party provider keys, and the files under `~/.codeium/<channel>` are MCP
646
+ servers, rules and skills. So the route in is an extension. Windsurf is a VS
647
+ Code fork, and **Cline** reads the provider store `connect` already wrote
648
+ (`~/.cline/data/settings/providers.json`) — installing the extension is the
649
+ whole configuration. Continue works the same way against `~/.continue/config.yaml`.
650
+ `connect` says exactly this when it finds a Windsurf install.
651
+
584
652
  ### The OpenRouter key
585
653
 
586
654
  **omp does not need to be authenticated to OpenRouter.** On a routed turn omp
@@ -1313,9 +1381,21 @@ embed extension registers the remote router as omp's provider with that key inst
1313
1381
  binding a local one, and the toast, `/router` hub and digest extensions talk to it. Nothing
1314
1382
  is classified or selected locally; the remote router is the router. The same command adds
1315
1383
  the extensions to omp's config, installs the Hermes plugins and points them at the remote,
1316
- adds the Codex provider and the Aider settings, and prints (or with `--profile` persists)
1317
- the environment lines for Claude Code. `--harness omp,hermes` restricts it; `--dry-run`
1318
- shows the changes. Delete `remote.json` to go back to a local router. (`join` is an alias.)
1384
+ adds the Codex provider and the Aider settings, writes Claude Code's settings file, merges
1385
+ the provider into OpenCode's `opencode.json` (and copies its plugin), Cline's
1386
+ `providers.json` and Continue's `config.yaml`, and prints what to set by hand for Cursor
1387
+ and Windsurf. `--harness omp,hermes` restricts it (`omp`, `hermes`, `codex`, `aider`,
1388
+ `claude`, `opencode`, `cline`, `continue`, `cursor`, `windsurf`); `--dry-run` shows the
1389
+ changes. Delete `remote.json` to go back to a local router. (`join` is an alias.)
1390
+
1391
+ **What gets written and what gets printed.** A harness is configured only where its file
1392
+ format is documented and, wherever the harness could be run here, seen to be read: omp,
1393
+ Hermes, Codex, Aider, Claude Code, OpenCode, Cline and Continue. Cursor and Windsurf keep
1394
+ their provider settings in application state — an editor's own database, a vendor's
1395
+ account page — so `connect` reports them as `manual` and prints the base URL, key and
1396
+ model to paste. That is deliberate: a config key invented for them would write a file that
1397
+ silently does nothing while the run reported success. Every write is idempotent, backs the
1398
+ previous file up, and touches only its own keys.
1319
1399
 
1320
1400
  `connect` also writes omp's `models.yml` (a managed block, other providers untouched, the
1321
1401
  previous file backed up). That entry is what makes `auto-model-router/auto` resolvable at
@@ -1474,10 +1554,12 @@ plus a harness header; the rest needs the harness's own hook API.
1474
1554
  | Hermes | provider plugin | yes | yes (native plugin) | yes (native plugin) | no | text | yes (native plugin) | on demand | no |
1475
1555
  | Codex CLI | Responses API wire | yes | yes (from body) | yes (from body) | no | no | compaction only | no | no |
1476
1556
  | Aider | config only | via model settings | no | no | no | no | no tools | no | no |
1477
- | Cline CLI | config only | no | no | no | no | no | compaction only | no | no |
1557
+ | Cline (CLI + VS Code) | config only | yes | no | no | no | no | compaction only | no | no |
1478
1558
  | Kilo Code CLI | config only | yes | no | no | no | no | compaction only | no | no |
1479
1559
  | Roo Code (VS Code) | config only | yes | no | no | no | no | compaction only | no | no |
1480
- | Cline (VS Code) | config only, unverified | if headers supported | no | no | no | no | compaction only | no | no |
1560
+ | Continue | config only | yes | no | no | no | no | compaction only | no | no |
1561
+ | Cursor | manual (no config file; needs a public URL) | no | no | no | no | no | compaction only | no | no |
1562
+ | Windsurf | via the Cline or Continue extension | as that extension | no | no | no | no | compaction only | no | no |
1481
1563
  | OpenCode | config + plugin | yes | yes (plugin) | yes (plugin) | yes (plugin) | no | yes (plugin) | no | no |
1482
1564
  | Claude Code | needs an Anthropic Messages wire module | — | — | — | — | — | — | — | — |
1483
1565
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -17,6 +17,15 @@
17
17
  * Claude Code ~/.claude/settings.json gains the base URL (its `env` block) and
18
18
  * `apiKeyHelper` running `auto-model-router token`, so no key
19
19
  * sits in its environment or on disk for it
20
+ * OpenCode ~/.config/opencode/opencode.json gains the provider block, and
21
+ * plugin/ the native plugin
22
+ * Cline ~/.cline/data/settings/providers.json gains the
23
+ * openai-compatible provider — the CLI and the extension share it
24
+ * Continue ~/.continue/config.yaml gains the three profiles as models
25
+ *
26
+ * Cursor and Windsurf keep their provider settings in application state rather
27
+ * than in a file, so they are PRINTED, not written; see configureExtraHarnesses
28
+ * for where that line is drawn and why.
20
29
  *
21
30
  * Every write is idempotent and announced. `--profile` persists the
22
31
  * environment lines (shell rc on POSIX, user environment on Windows).
@@ -34,6 +43,7 @@ import { executablePath, materializePackage, readEmbeddedPackage } from "./embed
34
43
  import { fetchSkills, installSkills, type SkillsBundle, type SkillsInstallReport, type SkillsTarget } from "./skills.ts";
35
44
  import { pickStore, saveRefreshToken, type StoreDeps, type StoreKind } from "./credential-store.ts";
36
45
  import { flagString, type CliArgs } from "./args.ts";
46
+ import { cursorSnippet, mergeClineProviders, mergeContinueConfig, mergeOpenCodeConfig, windsurfSnippet, type ManualSnippet } from "./harnesses.ts";
37
47
 
38
48
  export interface ConnectOptions {
39
49
  url: string;
@@ -42,7 +52,7 @@ export interface ConnectOptions {
42
52
  name: string;
43
53
  profile: boolean;
44
54
  dryRun: boolean;
45
- /** Restrict to these harnesses (omp, hermes, codex, aider, claude); empty ⇒ every one detected. */
55
+ /** Restrict to these harnesses (omp, hermes, codex, aider, claude, opencode, cline, continue, cursor, windsurf); empty ⇒ every one detected. */
46
56
  only: string[];
47
57
  env: Record<string, string | undefined>;
48
58
  home: string;
@@ -84,6 +94,12 @@ export interface ConnectReport {
84
94
  skipped: string[];
85
95
  envLines: string[];
86
96
  notes: string[];
97
+ /**
98
+ * Harnesses whose provider settings live in application state rather than a
99
+ * documented file: what to paste into their settings UI. Never a guess at a
100
+ * file — see harnesses.ts for why the line is drawn on confidence.
101
+ */
102
+ manual: ManualSnippet[];
87
103
  /** What the remote's skills bundle did, when there was one. */
88
104
  skills?: SkillsInstallReport;
89
105
  /** Files that gained (or lost) the team-context MCP server. */
@@ -278,7 +294,7 @@ export function hasForeignRouterProvider(text: string): boolean {
278
294
  }
279
295
 
280
296
  export function connectRemote(o: ConnectOptions): ConnectReport {
281
- const report: ConnectReport = { remoteFile: "", configured: [], skipped: [], envLines: [], notes: [] };
297
+ const report: ConnectReport = { remoteFile: "", configured: [], skipped: [], envLines: [], notes: [], manual: [] };
282
298
  const write = (path: string, content: string): void => {
283
299
  if (o.dryRun) return;
284
300
  mkdirSync(dirname(path), { recursive: true });
@@ -405,6 +421,8 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
405
421
  }
406
422
  report.configured.push(`Claude Code (${settingsPath}: env.ANTHROPIC_BASE_URL + apiKeyHelper; open a new session)`);
407
423
  } else report.skipped.push("Claude Code (not on PATH and no ~/.claude)");
424
+
425
+ configureExtraHarnesses(o, report, write);
408
426
  report.envLines.unshift(`AUTO_MODEL_ROUTER_URL=${o.url}`, `AUTO_MODEL_ROUTER_API_KEY=${o.key}`);
409
427
  report.envLines = [...new Set(report.envLines)];
410
428
 
@@ -463,6 +481,112 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
463
481
  return report;
464
482
  }
465
483
 
484
+ /**
485
+ * The harnesses added in 0.20.0, kept out of `connectRemote` only for its
486
+ * length: same contract, same report, same idempotence.
487
+ *
488
+ * Two shapes appear here. The automated ones own a documented config file, and
489
+ * `connect` merges its own keys into it (harnesses.ts holds those merges, and
490
+ * says why each format is trusted). The manual ones keep their provider
491
+ * settings in application state — a VS Code `globalState` blob, an IDE's own
492
+ * database — where there is no file to edit, so `connect` prints the values to
493
+ * paste and says so in the report rather than writing something that would look
494
+ * like success and do nothing.
495
+ *
496
+ * A manual harness is announced when it is INSTALLED, or when `--harness` named
497
+ * it outright; a user without Cursor should not be read a Cursor recipe.
498
+ */
499
+ function configureExtraHarnesses(o: ConnectOptions, report: ConnectReport, write: (path: string, content: string) => void): void {
500
+ const scope = o.agentdoxScope ?? "";
501
+ const named = (h: string): boolean => o.only.includes(h);
502
+
503
+ // OpenCode: the provider block the README documents, plus the plugin that
504
+ // gives it session identity, the toast and the digest — the same two-part
505
+ // install Hermes gets, since OpenCode is the other harness with a real hook API.
506
+ const ocDir = openCodeDir(o);
507
+ if (wants(o, "opencode") && (existsSync(ocDir) || o.pathHas("opencode"))) {
508
+ const p = join(ocDir, "opencode.json");
509
+ const before = existsSync(p) ? readFileSync(p, "utf8") : "";
510
+ const after = mergeOpenCodeConfig(before, o.url, o.key, scope);
511
+ if (after === null && before.trim() !== "" && !isJsonObject(before)) {
512
+ report.notes.push(`${p} is not a JSON object; left alone — add the auto-model-router provider by hand`);
513
+ } else if (after !== null) {
514
+ if (before !== "" && !o.dryRun) writeFileSync(`${p}.${backupStamp()}.bak`, before, "utf8");
515
+ write(p, after);
516
+ }
517
+ if (!o.dryRun) cpSync(join(o.packageDir, "opencode-plugin", "auto-model-router.ts"), join(ocDir, "plugin", "auto-model-router.ts"));
518
+ report.configured.push(`OpenCode (${p} + ${join(ocDir, "plugin")}; model auto-model-router/auto)`);
519
+ } else report.skipped.push("OpenCode (not on PATH and no ~/.config/opencode)");
520
+
521
+ // Cline: one provider store for the CLI and, since its settings migration, the
522
+ // VS Code extension — so this single write serves both, and every editor that
523
+ // hosts the extension (see the Windsurf snippet below).
524
+ const clineDataDir = clineDir(o);
525
+ let clineConfigured = false;
526
+ if (wants(o, "cline") && (existsSync(clineDataDir) || o.pathHas("cline"))) {
527
+ const p = join(clineDataDir, "settings", "providers.json");
528
+ const before = existsSync(p) ? readFileSync(p, "utf8") : "";
529
+ const after = mergeClineProviders(before, o.url, o.key, new Date().toISOString(), scope);
530
+ if (after === null && before.trim() !== "" && !isJsonObject(before)) {
531
+ report.notes.push(`${p} is not a JSON object; left alone — run \`cline auth -p openai -b ${o.url}/v1 -k <key> -m auto\` instead`);
532
+ } else if (after !== null) {
533
+ if (before !== "" && !o.dryRun) writeFileSync(`${p}.${backupStamp()}.bak`, before, "utf8");
534
+ write(p, after);
535
+ }
536
+ clineConfigured = true;
537
+ report.configured.push(`Cline (${p}; \`cline -m auto\`, or the VS Code extension)`);
538
+ } else report.skipped.push("Cline (not on PATH and no ~/.cline)");
539
+
540
+ // Continue: one assistant file holds the models, so the three profiles go in as
541
+ // three entries and the user picks between them in the model dropdown.
542
+ const continueDir = join(o.home, ".continue");
543
+ if (wants(o, "continue") && (existsSync(continueDir) || o.pathHas("cn"))) {
544
+ const p = join(continueDir, "config.yaml");
545
+ const before = existsSync(p) ? readFileSync(p, "utf8") : "";
546
+ const after = mergeContinueConfig(before, o.url, o.key, scope);
547
+ if (after === null && before.trim() !== "") {
548
+ report.notes.push(`${p} is not an assistant file we can edit; left alone — add the models entry by hand`);
549
+ } else if (after !== null) {
550
+ if (before !== "" && !o.dryRun) writeFileSync(`${p}.${backupStamp()}.bak`, before, "utf8");
551
+ write(p, after);
552
+ // config.yaml wins over the older config.json, so say so rather than let a
553
+ // user wonder why the settings they had stopped applying.
554
+ if (before === "" && existsSync(join(continueDir, "config.json"))) report.notes.push(`${join(continueDir, "config.json")} is Continue's older format and config.yaml now takes precedence over it`);
555
+ }
556
+ report.configured.push(`Continue (${p}; pick auto-model-router in the model dropdown)`);
557
+ } else report.skipped.push("Continue (no ~/.continue)");
558
+
559
+ // Cursor and Windsurf keep provider settings where no file can reach them.
560
+ if (wants(o, "cursor") && (named("cursor") || existsSync(join(o.home, ".cursor")) || o.pathHas("cursor"))) report.manual.push(cursorSnippet(o.url, o.key));
561
+ else report.skipped.push("Cursor (no ~/.cursor)");
562
+ if (wants(o, "windsurf") && (named("windsurf") || existsSync(join(o.home, ".codeium")) || o.pathHas("windsurf"))) report.manual.push(windsurfSnippet(clineConfigured));
563
+ else report.skipped.push("Windsurf (no ~/.codeium)");
564
+ }
565
+
566
+ /** Cline's state directory: `--data-dir`'s default, `~/.cline/data`, or `CLINE_DATA_DIR` when the user moved it. */
567
+ function clineDir(o: ConnectOptions): string {
568
+ const d = o.env.CLINE_DATA_DIR;
569
+ return d !== undefined && d !== "" ? expand(d, o.home) : join(o.home, ".cline", "data");
570
+ }
571
+
572
+ /** OpenCode's config home: `XDG_CONFIG_HOME`, else `~/.config` — the same on Windows, where it does not use APPDATA. */
573
+ function openCodeDir(o: ConnectOptions): string {
574
+ const xdg = o.env.XDG_CONFIG_HOME;
575
+ return join(xdg !== undefined && xdg !== "" ? expand(xdg, o.home) : join(o.home, ".config"), "opencode");
576
+ }
577
+
578
+ /** A filename-safe timestamp for the `.bak` beside a file we are about to replace. */
579
+ const backupStamp = (): string => new Date().toISOString().replaceAll(":", "-");
580
+
581
+ const isJsonObject = (text: string): boolean => {
582
+ try {
583
+ const v = JSON.parse(text) as unknown;
584
+ return typeof v === "object" && v !== null && !Array.isArray(v);
585
+ } catch {
586
+ return false;
587
+ }
588
+ };
589
+
466
590
  /** What a team's one-time setup token is traded for. */
467
591
  export interface IssuedCredential {
468
592
  key: string;
@@ -597,6 +721,11 @@ export async function connectCommand(args: CliArgs): Promise<void> {
597
721
  console.log(`${args.flags.has("dry-run") ? "would write" : "wrote"} ${report.remoteFile}${name === "" ? "" : ` for ${name}`}`);
598
722
  for (const c of report.configured) console.log(` configured ${c}`);
599
723
  for (const s of report.skipped) console.log(` skipped ${s}`);
724
+ // A manual harness gets the values printed rather than a file written; see configureExtraHarnesses.
725
+ for (const m of report.manual) {
726
+ console.log(` manual ${m.harness} — ${m.reason}`);
727
+ for (const l of m.lines) console.log(` ${l}`);
728
+ }
600
729
  console.log("environment:");
601
730
  for (const l of report.envLines) console.log(` ${process.platform === "win32" ? "$env:" : "export "}${process.platform === "win32" ? l.replace("=", '="') + '"' : l}`);
602
731
  for (const n of report.notes) console.log(`note: ${n}`);
@@ -0,0 +1,277 @@
1
+ /**
2
+ * The harness configurations `connect` writes beyond the five it has always
3
+ * written (omp, Hermes, Codex, Aider, Claude Code), and the snippets it prints
4
+ * for the ones it deliberately does not write.
5
+ *
6
+ * The line between the two is confidence, not effort. A harness whose config
7
+ * FILE and keys are documented — and, where the harness could be run here, seen
8
+ * to read them — gets an automated path: a pure merge over the file's text, so
9
+ * `connect` edits its own keys and leaves every other one alone. A harness
10
+ * whose provider settings live in application state (an editor's `globalState`
11
+ * database, a vendor's web account page) has no file to edit honestly, so it
12
+ * gets a snippet the user pastes into its settings UI instead. An invented key
13
+ * writes a file that silently does nothing and then reports success, which is
14
+ * worse for the user than a printed instruction that works.
15
+ *
16
+ * Every merge returns `null` when nothing changes — so a second `connect` is a
17
+ * no-op — and also when the file cannot be parsed: a file we do not understand
18
+ * is not ours to rewrite, and the caller says so rather than guessing.
19
+ */
20
+
21
+ import { isMap, isScalar, isSeq, parseDocument, type Document, type YAMLSeq } from "yaml";
22
+
23
+ import { SCOPE_ENV } from "../context/scope.ts";
24
+
25
+ /** The provider id every automated harness registers the router under. */
26
+ export const PROVIDER_ID = "auto-model-router";
27
+
28
+ /** The virtual models a router serves; `auto` is the one each harness is pointed at. */
29
+ export const PROFILE_IDS = ["auto", "auto-cheap", "auto-max"] as const;
30
+
31
+ /**
32
+ * The headers a harness's provider entry sends on every turn.
33
+ *
34
+ * `X-Omp-Harness` is what splits budgets, reports and toasts per harness, so it
35
+ * is always here. The agentdox scope is not: these files hold LITERAL header
36
+ * values — unlike omp's models.yml, none of these harnesses resolves a value
37
+ * that names an environment variable — so a machine-wide file could only pin one
38
+ * project onto every workspace. It is written only when `connect --scope` asked
39
+ * for exactly that; otherwise the scope is left to the remote's own default.
40
+ */
41
+ export function harnessHeaders(harness: string, scope: string): Record<string, string> {
42
+ return { "X-Omp-Harness": harness, ...(scope !== "" && scope !== SCOPE_ENV ? { "X-Agentdox-Scope": scope } : {}) };
43
+ }
44
+
45
+ /** A harness `connect` will not write a file for: what to set, and where. */
46
+ export interface ManualSnippet {
47
+ /** The harness's display name, as it appears in the report. */
48
+ harness: string;
49
+ /** Why there is no file to write — one line, shown beside the name. */
50
+ reason: string;
51
+ /** The instruction lines, already formatted for a terminal. */
52
+ lines: string[];
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // OpenCode — automated
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /**
60
+ * Merges the router into OpenCode's `opencode.json`.
61
+ *
62
+ * OpenCode reaches any OpenAI-compatible endpoint through a `provider` entry
63
+ * backed by the `@ai-sdk/openai-compatible` npm package, which it installs
64
+ * itself; `options` is handed to that package verbatim, which is where the base
65
+ * URL, the key and the per-request headers go. This is the shape the README
66
+ * documents, verified live against opencode 1.18 — the captured request,
67
+ * `test/fixtures/harness/opencode.json`, carries the `X-Omp-Harness` header
68
+ * that `options.headers` put there.
69
+ *
70
+ * `model` is pointed at our `auto` so the harness comes up on the router rather
71
+ * than leaving the user to find it in the picker; every other key in the file —
72
+ * `$schema`, the theme, MCP servers, other providers — is carried through.
73
+ */
74
+ export function mergeOpenCodeConfig(before: string, url: string, key: string, scope = ""): string | null {
75
+ const root = parseJsonObject(before);
76
+ if (root === null) return null;
77
+ const providers = { ...((root.provider as Record<string, unknown> | undefined) ?? {}) };
78
+ providers[PROVIDER_ID] = {
79
+ npm: "@ai-sdk/openai-compatible",
80
+ name: "auto-model-router",
81
+ options: { baseURL: `${url}/v1`, apiKey: key, headers: harnessHeaders("opencode", scope) },
82
+ models: Object.fromEntries(PROFILE_IDS.map((id) => [id, { name: id }])),
83
+ };
84
+ const next = { ...root, provider: providers, model: `${PROVIDER_ID}/auto` };
85
+ const after = `${JSON.stringify(next, null, 2)}\n`;
86
+ return after === before ? null : after;
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Cline — automated
91
+ // ---------------------------------------------------------------------------
92
+
93
+ /** The provider id Cline files an OpenAI-compatible endpoint under; `cline auth -p openai` resolves to it. */
94
+ const CLINE_PROVIDER = "openai-compatible";
95
+
96
+ /**
97
+ * Merges the router into Cline's `~/.cline/data/settings/providers.json`.
98
+ *
99
+ * The shape was captured by running `cline auth -p openai -b <base> -k <key> -m
100
+ * auto` — the command the README documents — against an isolated `--data-dir`
101
+ * on cline 3.0.61: a `version`, the `lastUsedProvider`, and one entry per
102
+ * provider holding `settings`, an `updatedAt` stamp and a `tokenSource`. One
103
+ * file serves the CLI and, since the extension's settings migration, the VS
104
+ * Code extension too, which is also how Cline reaches the router inside an
105
+ * editor that has no provider settings of its own (see windsurfSnippet).
106
+ *
107
+ * Writing the keys rather than shelling out to `cline auth` buys two things
108
+ * the command cannot give: `--dry-run` can show the change, and `settings.headers`
109
+ * gets written. That map is in the on-disk schema but has no CLI flag, and it is
110
+ * what finally gives Cline a harness id — verified live by pointing cline 3.0.61
111
+ * at a recording server, whose capture is `test/fixtures/harness/cline-cli-connected.json`.
112
+ *
113
+ * `updatedAt` is deliberately NOT refreshed when the settings already match: a
114
+ * stamp that moved on every run would make `connect` write a different file each
115
+ * time, which is the one thing every path here promises not to do.
116
+ */
117
+ export function mergeClineProviders(before: string, url: string, key: string, nowIso: string, scope = ""): string | null {
118
+ const root = parseJsonObject(before);
119
+ if (root === null) return null;
120
+ const providers = { ...((root.providers as Record<string, unknown> | undefined) ?? {}) };
121
+ const entry = (providers[CLINE_PROVIDER] as Record<string, unknown> | undefined) ?? {};
122
+ const settings = { provider: CLINE_PROVIDER, apiKey: key, model: "auto", baseUrl: `${url}/v1`, headers: harnessHeaders("cline", scope) };
123
+ if (JSON.stringify(entry.settings) === JSON.stringify(settings) && root.lastUsedProvider === CLINE_PROVIDER) return null;
124
+ providers[CLINE_PROVIDER] = { ...entry, settings, updatedAt: nowIso, tokenSource: entry.tokenSource ?? "manual" };
125
+ // `version` leads the file cline writes; spreading root after it keeps that order on a file that has one.
126
+ const next = { version: 1, ...root, lastUsedProvider: CLINE_PROVIDER, modes: root.modes ?? {}, providers };
127
+ return `${JSON.stringify(next, null, 2)}\n`;
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Continue — automated
132
+ // ---------------------------------------------------------------------------
133
+
134
+ /** The `name:` our model entries carry in Continue's config, and how they are found again on a re-run. */
135
+ const CONTINUE_PREFIX = PROVIDER_ID;
136
+
137
+ /**
138
+ * Merges the router's three profiles into Continue's `~/.continue/config.yaml`.
139
+ *
140
+ * Continue's documented assistant file is a YAML map with `name`, `version` and
141
+ * `schema` at the top and a `models:` list under it; an OpenAI-compatible
142
+ * endpoint is a model entry with `provider: openai` and `apiBase`, `apiKey`,
143
+ * `roles` and `requestOptions.headers` — the last is what carries the harness
144
+ * id. Continue was not run here, so this is the documented shape rather than an
145
+ * observed one; it is automated because the file, its location and its keys are
146
+ * all published, which is the bar (nothing about it is inferred).
147
+ *
148
+ * The edit goes through the YAML *document*, not a re-serialisation of parsed
149
+ * data, so a hand-written config keeps its comments, key order and quoting and
150
+ * only our own entries move. Entries are matched by `name`, so a re-run with a
151
+ * new key replaces them in place instead of appending a second copy.
152
+ */
153
+ export function mergeContinueConfig(before: string, url: string, key: string, scope = ""): string | null {
154
+ let doc: Document.Parsed;
155
+ try {
156
+ doc = parseDocument(before);
157
+ } catch {
158
+ return null;
159
+ }
160
+ if (doc.errors.length > 0) return null;
161
+ // A new file is seeded in the schema's own order rather than assembled key by
162
+ // key, which would leave the three required fields trailing the model list.
163
+ if (before.trim() === "") doc = parseDocument(`name: ${PROVIDER_ID}\nversion: 0.0.1\nschema: v1\nmodels:\n`);
164
+ if (!isMap(doc.contents)) return null;
165
+ // Required by the schema, and only supplied when the file does not already say otherwise.
166
+ if (doc.get("name") === undefined) doc.set("name", PROVIDER_ID);
167
+ if (doc.get("version") === undefined) doc.set("version", "0.0.1");
168
+ if (doc.get("schema") === undefined) doc.set("schema", "v1");
169
+ const models = doc.get("models", true);
170
+ // `models:` with nothing under it parses to a null SCALAR node, not to null itself.
171
+ if (models === undefined || models === null || (isScalar(models) && models.value === null)) doc.set("models", doc.createNode([]));
172
+ else if (!isSeq(models)) return null;
173
+ const seq = doc.get("models", true) as YAMLSeq<unknown>;
174
+ seq.flow = false; // an empty seq is created in flow style, which would drag every entry onto one line
175
+ const wanted = PROFILE_IDS.map((id) =>
176
+ doc.createNode({
177
+ name: id === "auto" ? CONTINUE_PREFIX : `${CONTINUE_PREFIX}-${id.replace("auto-", "")}`,
178
+ provider: "openai",
179
+ model: id,
180
+ apiBase: `${url}/v1`,
181
+ apiKey: key,
182
+ roles: ["chat", "edit", "apply", "summarize"],
183
+ capabilities: ["tool_use", "image_input"],
184
+ requestOptions: { headers: harnessHeaders("continue", scope) },
185
+ }),
186
+ );
187
+ const nameOf = (item: unknown): string => (isMap(item) ? String(item.get("name") ?? "") : "");
188
+ for (const node of wanted) {
189
+ const name = nameOf(node);
190
+ const at = seq.items.findIndex((item) => nameOf(item) === name);
191
+ if (at >= 0) seq.items[at] = node;
192
+ else seq.items.push(node);
193
+ }
194
+ const after = doc.toString();
195
+ return after === before ? null : after;
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Manual paths
200
+ // ---------------------------------------------------------------------------
201
+
202
+ /**
203
+ * The values every OpenAI-compatible settings form asks for, as lines.
204
+ *
205
+ * Shared by the manual harnesses because the substance is identical in each —
206
+ * a base URL, a key and a model id — and only the form differs. One renderer
207
+ * means the three recipes cannot drift apart from each other or from what the
208
+ * automated paths write.
209
+ */
210
+ function providerFields(url: string, key: string): string[] {
211
+ return [`base URL: ${url}/v1`, `API key: ${key}`, `model: auto (also auto-cheap, auto-max)`];
212
+ }
213
+
214
+ /**
215
+ * Cursor.
216
+ *
217
+ * Cursor's OpenAI override is an application setting in the editor's own state
218
+ * database, not a documented file: what lives under `~/.cursor` is MCP servers
219
+ * and rules, and the official key documentation describes only the settings
220
+ * pane. So the values are printed instead of written.
221
+ *
222
+ * The second line is the one that saves an afternoon: Cursor routes chat
223
+ * through its own servers with the key attached, so the override only works
224
+ * against a router the internet can reach. A loopback or LAN router cannot
225
+ * serve Cursor however it is configured — which is worth saying plainly, since
226
+ * every other harness here is happy with `127.0.0.1`.
227
+ */
228
+ export function cursorSnippet(url: string, key: string): ManualSnippet {
229
+ const reachable = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\]|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(url);
230
+ return {
231
+ harness: "Cursor",
232
+ reason: "its OpenAI override lives in the editor's settings, not a file",
233
+ lines: [
234
+ "Cursor Settings → Models → OpenAI API Key: enable the base-URL override, then",
235
+ ...providerFields(url, key),
236
+ "Add `auto` as a custom model and select it. Cursor sends no custom header,",
237
+ "so its turns carry no X-Omp-Harness id and share the unnamed budget.",
238
+ ...(reachable ? ["WARNING: Cursor proxies chat through its own servers, so this URL must be", "reachable from the internet — a loopback or LAN router will never answer it."] : []),
239
+ ],
240
+ };
241
+ }
242
+
243
+ /**
244
+ * Windsurf.
245
+ *
246
+ * Windsurf's own files under `~/.codeium/<channel>` cover MCP servers, rules
247
+ * and skills; the model provider is not among them, and its bring-your-own-key
248
+ * page takes first-party provider keys rather than an arbitrary base URL —
249
+ * there is no field for one to write. So the useful answer is not a snippet of
250
+ * Windsurf settings at all but the extension route: Windsurf is a VS Code fork,
251
+ * and the Cline extension reads the same `providers.json` `connect` has already
252
+ * written, so installing it is the whole configuration.
253
+ */
254
+ export function windsurfSnippet(clineConfigured: boolean): ManualSnippet {
255
+ return {
256
+ harness: "Windsurf",
257
+ reason: "it has no custom base-URL field; reach the router through an extension",
258
+ lines: [
259
+ "Windsurf's own provider settings take first-party keys, not a base URL.",
260
+ "Install the Cline extension in Windsurf: it reads the same providers.json",
261
+ clineConfigured ? "this connect just wrote, so it needs no further setup." : "connect writes — re-run `connect --harness cline` once Cline is installed.",
262
+ "The Continue extension works the same way against ~/.continue/config.yaml.",
263
+ ],
264
+ };
265
+ }
266
+
267
+ /** Parses a JSON object file, treating an empty file as `{}` and anything else as not ours. */
268
+ function parseJsonObject(before: string): Record<string, unknown> | null {
269
+ if (before.trim() === "") return {};
270
+ try {
271
+ const parsed = JSON.parse(before) as unknown;
272
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
273
+ return parsed as Record<string, unknown>;
274
+ } catch {
275
+ return null;
276
+ }
277
+ }
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ Usage: auto-model-router <command> [options]
29
29
  report Usage analytics: providers, models, tiers, cost, speed, cache hit rate
30
30
  export One row per day, harness and model as CSV (--json for rows)
31
31
  connect Point this machine at a remote router (--url with --key[, --refresh-token] or --setup-token <one-time token from a team>; --scope pins one project for the whole machine (default: each workspace's own); --profile persists the environment and, from the compiled executable, PATH; the remote's skills and its MCP endpoint are installed for Claude Code and omp)
32
+ --harness omp,hermes,codex,aider,claude,opencode,cline,continue,cursor,windsurf restricts it; cursor and windsurf are printed, not written
32
33
  refresh Trade the refresh token for a new access key and re-write every harness config (--force: even when not near expiry)
33
34
  token Print an access key that is good right now, refreshing first if needed (for a harness key-helper)
34
35
  models Show what each complexity tier would consider, and why