auto-model-router 0.1.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 (83) hide show
  1. package/.env.example +24 -0
  2. package/.github/workflows/publish.yml +40 -0
  3. package/.omp-plugin/marketplace.json +30 -0
  4. package/LICENSE +21 -0
  5. package/README.md +639 -0
  6. package/bun.lock +32 -0
  7. package/docs/claude-anthropic-wire.md +116 -0
  8. package/omp-extension/configure-logic.ts +128 -0
  9. package/omp-extension/embed-logic.ts +141 -0
  10. package/omp-extension/router-configure.ts +111 -0
  11. package/omp-extension/router-embed.ts +118 -0
  12. package/omp-extension/router-toast.ts +130 -0
  13. package/omp-extension/toast-logic.ts +136 -0
  14. package/package.json +56 -0
  15. package/src/catalog/openrouter-catalog.ts +428 -0
  16. package/src/catalog/types.ts +104 -0
  17. package/src/cli/args.ts +105 -0
  18. package/src/cli/config-cmd.ts +362 -0
  19. package/src/cli/config-wizard.ts +636 -0
  20. package/src/cli/explain.ts +167 -0
  21. package/src/cli/models.ts +240 -0
  22. package/src/cli/stats.ts +69 -0
  23. package/src/config/defaults.ts +136 -0
  24. package/src/config/load.ts +143 -0
  25. package/src/config/omp-credentials.ts +124 -0
  26. package/src/config/schema.ts +161 -0
  27. package/src/config/types.ts +244 -0
  28. package/src/cost/blended.ts +80 -0
  29. package/src/cost/forecast.ts +129 -0
  30. package/src/cost/ledger.ts +291 -0
  31. package/src/cost/types.ts +148 -0
  32. package/src/index.ts +93 -0
  33. package/src/router/cache-control.ts +66 -0
  34. package/src/router/candidates.ts +246 -0
  35. package/src/router/classify.ts +329 -0
  36. package/src/router/escalate.ts +264 -0
  37. package/src/router/features.ts +225 -0
  38. package/src/router/index.ts +99 -0
  39. package/src/router/select.ts +365 -0
  40. package/src/router/state.ts +118 -0
  41. package/src/router/tier-plan.ts +151 -0
  42. package/src/router/types.ts +222 -0
  43. package/src/server/http.ts +343 -0
  44. package/src/server/turn.ts +393 -0
  45. package/src/tokens/estimate.ts +74 -0
  46. package/src/upstream/openrouter.ts +221 -0
  47. package/src/upstream/sse-parse.ts +208 -0
  48. package/src/upstream/types.ts +75 -0
  49. package/src/util/hash.ts +0 -0
  50. package/src/util/log.ts +53 -0
  51. package/src/util/sqlite.ts +140 -0
  52. package/src/util/sse.ts +23 -0
  53. package/src/wire/openai/errors.ts +48 -0
  54. package/src/wire/openai/models.ts +37 -0
  55. package/src/wire/openai/request.ts +279 -0
  56. package/src/wire/openai/sink.ts +213 -0
  57. package/src/wire/types.ts +156 -0
  58. package/test/catalog.test.ts +319 -0
  59. package/test/classify.test.ts +269 -0
  60. package/test/config-wizard.test.ts +482 -0
  61. package/test/config.test.ts +121 -0
  62. package/test/configure-logic.test.ts +151 -0
  63. package/test/cost.test.ts +137 -0
  64. package/test/embed-logic.test.ts +107 -0
  65. package/test/escalate.test.ts +223 -0
  66. package/test/failover.test.ts +494 -0
  67. package/test/features.test.ts +228 -0
  68. package/test/fixtures/openrouter-models.json +15340 -0
  69. package/test/models-yml.test.ts +186 -0
  70. package/test/omp-credentials.test.ts +185 -0
  71. package/test/select.test.ts +538 -0
  72. package/test/sse-parse.test.ts +142 -0
  73. package/test/tier-plan.test.ts +302 -0
  74. package/test/toast-logic.test.ts +160 -0
  75. package/test/tokens.test.ts +160 -0
  76. package/test/trust-attribution.test.ts +175 -0
  77. package/test/turn.test.ts +498 -0
  78. package/test/wire-request.test.ts +297 -0
  79. package/test/wire-sink.test.ts +179 -0
  80. package/tools/install.ts +140 -0
  81. package/tools/mock-openrouter.ts +269 -0
  82. package/tools/smoke.ts +326 -0
  83. package/tsconfig.json +23 -0
@@ -0,0 +1,362 @@
1
+ /**
2
+ * `auto-model-router config`.
3
+ *
4
+ * Bare invocation opens the interactive wizard over the router's own
5
+ * config.yml (see `config-wizard.ts`). `--print` emits the `models.yml`
6
+ * provider block that registers this router with omp, and `--write` splices
7
+ * that block into omp's models.yml.
8
+ *
9
+ * The splice operates on TEXT, never a YAML round-trip. A user's models.yml is
10
+ * hand-maintained and full of comments explaining non-obvious context-window
11
+ * choices; reserializing it would silently delete all of that.
12
+ *
13
+ * The router's own config.yml is ours, so the wizard DOES reserialize it —
14
+ * but only after merging the user's edits over the existing file, so unedited
15
+ * keys survive.
16
+ */
17
+
18
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { dirname, join } from "node:path";
21
+ import { parse as parseYaml, stringify } from "yaml";
22
+
23
+ import { loadConfig, resolveTilde } from "../config/load.ts";
24
+ import { configInputSchema } from "../config/schema.ts";
25
+ import type { RouterConfig } from "../config/types.ts";
26
+ import { createLedger } from "../cost/ledger.ts";
27
+ import type { BlendedRate } from "../cost/types.ts";
28
+ import { openDb } from "../util/sqlite.ts";
29
+ import { configOpts, flagString, type CliArgs } from "./args.ts";
30
+ import {
31
+ mergeConfigPartial,
32
+ runWizard,
33
+ StreamLineSource,
34
+ type WizardIo,
35
+ } from "./config-wizard.ts";
36
+
37
+ export const BEGIN_GUARD = "# BEGIN auto-model-router";
38
+ export const END_GUARD = "# END auto-model-router";
39
+
40
+ /**
41
+ * Cache-price multipliers used only when the ledger has no measured blend yet.
42
+ * These are the rates the major upstreams publish (reads ~0.1x input, writes
43
+ * ~1.25x input); they are a starting estimate, replaced by measurement as soon
44
+ * as enough turns are recorded.
45
+ */
46
+ const FALLBACK_CACHE_READ_MULTIPLIER = 0.1;
47
+ const FALLBACK_CACHE_WRITE_MULTIPLIER = 1.25;
48
+
49
+ export interface SpliceResult {
50
+ text: string;
51
+ action: "replaced" | "inserted" | "created";
52
+ }
53
+
54
+ /**
55
+ * Renders the provider block body (without guards) as YAML lines.
56
+ *
57
+ * `cost` is in omp's units: USD per MILLION tokens. The catalog works in
58
+ * per-token rates, so everything is scaled by 1e6 exactly once, here.
59
+ */
60
+ export function renderProviderBlock(cfg: RouterConfig, blend: BlendedRate | null): string {
61
+ const input = blend?.inputPerMtok ?? cfg.ledger.fallbackBlend.inputPerMtok;
62
+ const output = blend?.outputPerMtok ?? cfg.ledger.fallbackBlend.outputPerMtok;
63
+ const cacheRead = blend?.cacheReadPerMtok ?? input * FALLBACK_CACHE_READ_MULTIPLIER;
64
+ const cacheWrite = blend?.cacheWritePerMtok ?? input * FALLBACK_CACHE_WRITE_MULTIPLIER;
65
+
66
+ const round = (v: number): number => Math.round(v * 1e4) / 1e4;
67
+ const host = cfg.server.host === "0.0.0.0" || cfg.server.host === "::" ? "127.0.0.1" : cfg.server.host;
68
+
69
+ const provider: Record<string, unknown> = {
70
+ "auto-model-router": {
71
+ baseUrl: `http://${host}:${cfg.server.port}/v1`,
72
+ api: "openai-completions",
73
+ auth: "none",
74
+ // When a harness id is configured, tag every request so the router can
75
+ // scope daily budgets and toasts per harness.
76
+ ...(cfg.server.harnessId !== undefined && cfg.server.harnessId !== ""
77
+ ? { headers: { "X-Omp-Harness": cfg.server.harnessId } }
78
+ : {}),
79
+ models: cfg.profiles.map((p) => ({
80
+ id: p.id,
81
+ name: p.name,
82
+ contextWindow: p.contextWindow,
83
+ maxTokens: p.maxTokens,
84
+ input: ["text", "image"],
85
+ cost: {
86
+ input: round(input),
87
+ output: round(output),
88
+ cacheRead: round(cacheRead),
89
+ cacheWrite: round(cacheWrite),
90
+ },
91
+ })),
92
+ },
93
+ };
94
+
95
+ return stringify(provider, { indent: 2 }).trimEnd();
96
+ }
97
+
98
+ function detectEol(text: string): string {
99
+ return text.includes("\r\n") ? "\r\n" : "\n";
100
+ }
101
+
102
+ /**
103
+ * Indentation used by the children of a top-level `providers:` mapping.
104
+ *
105
+ * Matching the file's existing style matters: YAML is indentation-sensitive,
106
+ * and mixing 2- and 4-space children under one mapping is invalid.
107
+ */
108
+ function detectChildIndent(lines: string[], providersIdx: number): string {
109
+ for (let i = providersIdx + 1; i < lines.length; i++) {
110
+ const line = lines[i];
111
+ if (line === undefined) continue;
112
+ if (line.trim() === "") continue;
113
+ const match = /^([ \t]+)\S/.exec(line);
114
+ if (match === null) break; // dedented to a new top-level key
115
+ const indent = match[1];
116
+ if (indent !== undefined) return indent;
117
+ }
118
+ return " ";
119
+ }
120
+
121
+ function indentBlock(block: string, indent: string, eol: string): string[] {
122
+ return block.split(/\r?\n/).map((line) => (line === "" ? "" : `${indent}${line}`)).join(eol).split(eol);
123
+ }
124
+
125
+ /**
126
+ * Inserts or replaces the guarded region in an existing models.yml body.
127
+ *
128
+ * Exported so tests can exercise it without touching a real omp config.
129
+ */
130
+ export function spliceProviderBlock(existing: string, block: string): SpliceResult {
131
+ // omp's own models.yml is frequently BOM-prefixed (editors on Windows add
132
+ // one). Left in place, the BOM makes the very first line read as
133
+ // "\uFEFFproviders:" so the top-level key is missed and a SECOND
134
+ // `providers:` gets appended -- duplicate keys, and omp then discards the
135
+ // whole file. Strip it for processing and restore it verbatim on output.
136
+ const bom = existing.startsWith("\uFEFF") ? "\uFEFF" : "";
137
+ const source = bom === "" ? existing : existing.slice(1);
138
+ const eol = detectEol(source === "" ? "\n" : source);
139
+ const note = "# Managed by auto-model-router. Cost figures are a rolling blend of actual routed";
140
+ const note2 = "# spend; re-run `auto-model-router config --write` to refresh them.";
141
+
142
+ if (source.trim() === "") {
143
+ const indent = " ";
144
+ const body = [
145
+ "providers:",
146
+ `${indent}${BEGIN_GUARD}`,
147
+ `${indent}${note}`,
148
+ `${indent}${note2}`,
149
+ ...indentBlock(block, indent, eol),
150
+ `${indent}${END_GUARD}`,
151
+ "",
152
+ ];
153
+ return { text: bom + body.join(eol), action: "created" };
154
+ }
155
+
156
+ const lines = source.split(/\r?\n/);
157
+ const beginIdx = lines.findIndex((l) => l.trim() === BEGIN_GUARD);
158
+ const endIdx = lines.findIndex((l) => l.trim() === END_GUARD);
159
+
160
+ if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
161
+ const beginLine = lines[beginIdx] ?? "";
162
+ const indent = /^([ \t]*)/.exec(beginLine)?.[1] ?? " ";
163
+ const replacement = [
164
+ `${indent}${BEGIN_GUARD}`,
165
+ `${indent}${note}`,
166
+ `${indent}${note2}`,
167
+ ...indentBlock(block, indent, eol),
168
+ `${indent}${END_GUARD}`,
169
+ ];
170
+ const next = [...lines.slice(0, beginIdx), ...replacement, ...lines.slice(endIdx + 1)];
171
+ return { text: bom + next.join(eol), action: "replaced" };
172
+ }
173
+
174
+ const providersIdx = lines.findIndex((l) => /^providers\s*:/.test(l));
175
+ if (providersIdx === -1) {
176
+ const indent = " ";
177
+ const appended = [
178
+ ...lines,
179
+ ...(lines[lines.length - 1]?.trim() === "" ? [] : [""]),
180
+ "providers:",
181
+ `${indent}${BEGIN_GUARD}`,
182
+ `${indent}${note}`,
183
+ `${indent}${note2}`,
184
+ ...indentBlock(block, indent, eol),
185
+ `${indent}${END_GUARD}`,
186
+ "",
187
+ ];
188
+ return { text: bom + appended.join(eol), action: "inserted" };
189
+ }
190
+
191
+ // Insert as the first child of `providers:`. Placing it at the top of the
192
+ // mapping keeps every following line untouched, which is what makes the
193
+ // "preserves comments" guarantee hold.
194
+ const indent = detectChildIndent(lines, providersIdx);
195
+ const inserted = [
196
+ ...lines.slice(0, providersIdx + 1),
197
+ `${indent}${BEGIN_GUARD}`,
198
+ `${indent}${note}`,
199
+ `${indent}${note2}`,
200
+ ...indentBlock(block, indent, eol),
201
+ `${indent}${END_GUARD}`,
202
+ ...lines.slice(providersIdx + 1),
203
+ ];
204
+ return { text: bom + inserted.join(eol), action: "inserted" };
205
+ }
206
+
207
+ /**
208
+ * Rejects a spliced result that omp could not load.
209
+ *
210
+ * Exported so tests can assert the guarantee directly. Duplicate top-level
211
+ * keys are the specific failure a BOM once produced here, and YAML treats
212
+ * them as a hard error rather than a merge.
213
+ */
214
+ export function assertUsableModelsYaml(text: string): void {
215
+ let parsed: unknown;
216
+ try {
217
+ parsed = parseYaml(text);
218
+ } catch (err) {
219
+ throw new Error(
220
+ `refusing to write: the spliced models.yml would not parse (${err instanceof Error ? err.message : String(err)}). Your file was left untouched.`,
221
+ );
222
+ }
223
+ if (typeof parsed !== "object" || parsed === null || !("providers" in parsed)) {
224
+ throw new Error("refusing to write: the spliced models.yml has no top-level `providers` mapping.");
225
+ }
226
+ const providers = parsed.providers;
227
+ if (typeof providers !== "object" || providers === null || !("auto-model-router" in providers)) {
228
+ throw new Error("refusing to write: the spliced models.yml does not contain the auto-model-router provider.");
229
+ }
230
+ }
231
+
232
+ /** The router's own config file path (the one `loadConfig` reads). */
233
+ export function routerConfigPath(): string {
234
+ const home = resolveTilde(process.env.AUTO_MODEL_ROUTER_HOME ?? "~/.auto-model-router");
235
+ return join(home, "config.yml");
236
+ }
237
+
238
+ /**
239
+ * Merges a wizard partial into the config file at `target`, validating the
240
+ * result before anything is written and backing up the previous file.
241
+ *
242
+ * Returns the backup path, or null when there was no prior file.
243
+ */
244
+ export function writeRouterConfig(target: string, partial: Record<string, unknown>): string | null {
245
+ const existing = existsSync(target) ? readFileSync(target, "utf8") : "";
246
+ const parsed = existing.trim() === "" ? {} : parseYaml(existing);
247
+ const base: Record<string, unknown> =
248
+ typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
249
+ ? (parsed as Record<string, unknown>)
250
+ : {};
251
+
252
+ const merged = mergeConfigPartial(base, partial);
253
+
254
+ // Validate the MERGED file, not just the partial: a legal edit can still
255
+ // combine with existing keys into something the loader would reject.
256
+ const validated = configInputSchema.safeParse(merged);
257
+ if (!validated.success) {
258
+ const lines = validated.error.issues.map(
259
+ (issue) => ` - ${issue.path.join(".") || "(root)"}: ${issue.message}`,
260
+ );
261
+ throw new Error(`refusing to write invalid config:\n${lines.join("\n")}`);
262
+ }
263
+
264
+ let backup: string | null = null;
265
+ if (existing !== "") {
266
+ backup = `${target}.${new Date().toISOString().replace(/[:.]/g, "-")}.bak`;
267
+ copyFileSync(target, backup);
268
+ }
269
+
270
+ mkdirSync(dirname(target), { recursive: true });
271
+ writeFileSync(target, stringify(merged, { indent: 2 }), "utf8");
272
+ return backup;
273
+ }
274
+
275
+ /**
276
+ * Runs the interactive wizard and persists the result to the router's own
277
+ * config.yml.
278
+ */
279
+ async function runWizardCommand(args: CliArgs): Promise<void> {
280
+ const cfg = loadConfig(configOpts(args));
281
+ const io: WizardIo = {
282
+ read: new StreamLineSource(process.stdin),
283
+ write: (text) => process.stdout.write(text),
284
+ };
285
+
286
+ const { partial, changed } = await runWizard(cfg, io);
287
+ if (partial === null) {
288
+ console.log("\nno changes written");
289
+ return;
290
+ }
291
+
292
+ const target = flagString(args, "config") ?? routerConfigPath();
293
+ const backup = writeRouterConfig(target, partial);
294
+ if (backup !== null) console.log(`\nbackup: ${backup}`);
295
+ console.log(`wrote ${target} (${changed} field${changed === 1 ? "" : "s"} changed)`);
296
+ console.log("restart the router to pick up the change");
297
+ }
298
+
299
+ /** omp's models.yml location: `$PI_CODING_AGENT_DIR` relocates the whole agent dir. */
300
+ export function ompModelsPath(): string {
301
+ const agentDir = process.env.PI_CODING_AGENT_DIR;
302
+ if (agentDir !== undefined && agentDir !== "") return join(agentDir, "models.yml");
303
+ return join(homedir(), ".omp", "agent", "models.yml");
304
+ }
305
+
306
+ export async function configCommand(args: CliArgs): Promise<void> {
307
+ const cfg = loadConfig(configOpts(args));
308
+
309
+ // Reading a blend must not create a database just by asking.
310
+ let blend: BlendedRate | null = null;
311
+ if (existsSync(cfg.ledger.path)) {
312
+ const db = openDb(cfg.ledger.path);
313
+ try {
314
+ blend = createLedger(db, cfg).blendedRate(cfg.ledger.blendWindowDays);
315
+ } finally {
316
+ db.close();
317
+ }
318
+ }
319
+
320
+ const block = renderProviderBlock(cfg, blend);
321
+
322
+ // Bare `config` runs the interactive wizard over the router's own
323
+ // config.yml. `--print` keeps the old block output; `--write` splices it.
324
+ if (!args.flags.has("write") && !args.flags.has("print")) {
325
+ await runWizardCommand(args);
326
+ return;
327
+ }
328
+
329
+ if (args.flags.has("print") && !args.flags.has("write")) {
330
+ console.log(block);
331
+ console.log("");
332
+ console.log(
333
+ blend === null
334
+ ? `# cost figures are estimates (no measured blend yet: needs ${cfg.ledger.blendMinSamples} routed turns)`
335
+ : `# cost figures blended from ${blend.sampleCount} routed turns over ${blend.windowDays}d`,
336
+ );
337
+ console.log(`# apply with: auto-model-router config --write (target: ${ompModelsPath()})`);
338
+ return;
339
+ }
340
+
341
+ const target = flagString(args, "path") ?? ompModelsPath();
342
+ const existing = existsSync(target) ? readFileSync(target, "utf8") : "";
343
+ const result = spliceProviderBlock(existing, block);
344
+
345
+ // Validate before overwriting. The splice is textual so comments survive,
346
+ // but that also means nothing else would catch a malformed result -- and a
347
+ // models.yml that fails to parse makes omp silently discard every custom
348
+ // provider in the file, not just ours. Parse is read-only: the validated
349
+ // text is what gets written, never a reserialization.
350
+ assertUsableModelsYaml(result.text);
351
+
352
+ if (existing !== "") {
353
+ const backup = `${target}.${new Date().toISOString().replace(/[:.]/g, "-")}.bak`;
354
+ copyFileSync(target, backup);
355
+ console.log(`backup: ${backup}`);
356
+ }
357
+
358
+ mkdirSync(dirname(target), { recursive: true });
359
+ writeFileSync(target, result.text, "utf8");
360
+ console.log(`${result.action} auto-model-router provider block in ${target}`);
361
+ console.log(`restart omp (or run /models) to pick up the change`);
362
+ }