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.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +207 -15
- package/docs/data-governance.md +210 -0
- package/package.json +1 -1
- package/src/cli/config-wizard.ts +11 -1
- package/src/cli/connect.ts +131 -2
- package/src/cli/harnesses.ts +277 -0
- package/src/config/defaults.ts +7 -1
- package/src/config/hot-reload.ts +3 -2
- package/src/config/redaction.ts +265 -0
- package/src/config/schema.ts +25 -1
- package/src/config/types.ts +52 -5
- package/src/cost/ledger.ts +39 -5
- package/src/cost/report.ts +19 -0
- package/src/cost/retention.ts +58 -0
- package/src/cost/types.ts +26 -2
- package/src/index.ts +1 -0
- package/src/lib.ts +8 -2
- package/src/server/http.ts +44 -14
- package/src/server/redact.ts +104 -0
- package/src/server/turn.ts +21 -0
- package/src/util/sqlite.ts +23 -1
- package/test/config-wizard.test.ts +1 -1
- package/test/connect-harnesses.test.ts +299 -0
- package/test/failover.test.ts +1 -0
- package/test/fixtures/connect/cline-providers.json +16 -0
- package/test/fixtures/connect/continue-config.yaml +14 -0
- package/test/fixtures/connect/opencode.json +15 -0
- package/test/fixtures/harness/cline-cli-connected.json +817 -0
- package/test/harness-requests.test.ts +13 -2
- package/test/migrations.test.ts +6 -3
- package/test/redaction.test.ts +300 -0
- package/test/report-hub.test.ts +2 -0
- package/test/report.test.ts +2 -0
- package/test/retention.test.ts +242 -0
- package/test/tokens.test.ts +3 -3
- package/test/trust-attribution.test.ts +2 -2
- package/test/turn.test.ts +107 -0
- package/tools/capture-proxy.ts +1 -1
package/src/cli/connect.ts
CHANGED
|
@@ -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/config/defaults.ts
CHANGED
|
@@ -385,8 +385,14 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
385
385
|
// so early cost reporting never underreports.
|
|
386
386
|
fallbackBlend: { inputPerMtok: 1.5, outputPerMtok: 7.5 },
|
|
387
387
|
conversationTtlMs: 7 * 24 * 60 * 60 * 1000,
|
|
388
|
-
|
|
388
|
+
// Keep everything until an operator says otherwise: how long a record of
|
|
389
|
+
// what people asked a model lives is their decision, and deleting is the
|
|
390
|
+
// direction that cannot be undone. `POST /v1/router/prune` and the hourly
|
|
391
|
+
// scheduler both do nothing while this is null.
|
|
392
|
+
retentionDays: null,
|
|
389
393
|
},
|
|
394
|
+
// Off, with no rules: redaction only ever removes what an operator names.
|
|
395
|
+
redaction: { enabled: false, rules: [], scanTools: false },
|
|
390
396
|
// On by default: an absolute floor that no available model meets is how the
|
|
391
397
|
// router ends up serving every turn from the cheapest tier.
|
|
392
398
|
adaptiveTierFloors: true,
|
package/src/config/hot-reload.ts
CHANGED
|
@@ -95,8 +95,9 @@ export interface WatchConfigOptions {
|
|
|
95
95
|
* Config paths captured at construction, so a file edit cannot reach the
|
|
96
96
|
* running process: the socket, the upstream clients, the agentdox bridge, the
|
|
97
97
|
* ledger file. Everything else, including `ollama.costBias`,
|
|
98
|
-
* `ollama.biasUntilUsage`, `server.subagentProfile
|
|
99
|
-
*
|
|
98
|
+
* `ollama.biasUntilUsage`, `server.subagentProfile`, `ledger.retentionDays` and
|
|
99
|
+
* the whole `redaction` block (rules recompile when their text changes), is
|
|
100
|
+
* read at call time and hot-reloads. A bare block name pins the whole
|
|
100
101
|
* block; `block.key` pins one key and lets its siblings through.
|
|
101
102
|
*/
|
|
102
103
|
/**
|