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,482 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { parse as parseYaml } from "yaml";
6
+
7
+ import { writeRouterConfig } from "../src/cli/config-cmd.ts";
8
+ import {
9
+ applyAnswers,
10
+ CLEAR_TOKEN,
11
+ formatValue,
12
+ getPath,
13
+ mergeConfigPartial,
14
+ runWizard,
15
+ ScriptedLineSource,
16
+ setPath,
17
+ StreamLineSource,
18
+ validateField,
19
+ WIZARD_SECTIONS,
20
+ type FieldSpec,
21
+ type WizardIo,
22
+ } from "../src/cli/config-wizard.ts";
23
+ import { loadConfig } from "../src/config/load.ts";
24
+
25
+ const cfg = loadConfig({});
26
+
27
+ /** Section numbers as shown in the menu (1-based, in declaration order). */
28
+ const SECTION = new Map(WIZARD_SECTIONS.map((s, i) => [s.title, String(i + 1)]));
29
+
30
+ /** Drives the wizard with a scripted answer list, capturing written output. */
31
+ async function drive(lines: string[]): Promise<{
32
+ partial: Record<string, unknown> | null;
33
+ changed: number;
34
+ out: string;
35
+ }> {
36
+ let out = "";
37
+ const io: WizardIo = {
38
+ read: new ScriptedLineSource(lines),
39
+ write: (text) => {
40
+ out += text;
41
+ },
42
+ };
43
+ const result = await runWizard(cfg, io);
44
+ return { partial: result.partial, changed: result.changed, out };
45
+ }
46
+
47
+ describe("getPath / setPath", () => {
48
+ test("reads nested and top-level config paths", () => {
49
+ expect(getPath(cfg, "server.port")).toBe(cfg.server.port);
50
+ expect(getPath(cfg, "logLevel")).toBe(cfg.logLevel);
51
+ expect(getPath(cfg, "tiers.hard.minQuality")).toBe(cfg.tiers.hard.minQuality);
52
+ });
53
+
54
+ test("returns undefined for missing paths without throwing", () => {
55
+ expect(getPath(cfg, "nope.missing.deep")).toBeUndefined();
56
+ expect(getPath(null, "a.b")).toBeUndefined();
57
+ });
58
+
59
+ test("creates intermediate objects", () => {
60
+ const target: Record<string, unknown> = {};
61
+ setPath(target, "a.b.c", 1);
62
+ expect(target).toEqual({ a: { b: { c: 1 } } });
63
+ });
64
+
65
+ test("replaces a non-object on the path rather than throwing", () => {
66
+ const target: Record<string, unknown> = { a: 5 };
67
+ setPath(target, "a.b", 1);
68
+ expect(target).toEqual({ a: { b: 1 } });
69
+ });
70
+ });
71
+
72
+ describe("validateField", () => {
73
+ const num: FieldSpec = { path: "n", label: "n", kind: "number", min: 0, max: 10 };
74
+ const opt: FieldSpec = { path: "o", label: "o", kind: "number", min: 0, optional: true };
75
+ const bool: FieldSpec = { path: "b", label: "b", kind: "boolean" };
76
+ const en: FieldSpec = { path: "e", label: "e", kind: "enum", options: ["a", "b"] };
77
+ const arr: FieldSpec = { path: "a", label: "a", kind: "stringArray" };
78
+
79
+ test("accepts in-range numbers", () => {
80
+ expect(validateField(num, "5")).toEqual({ ok: true, value: 5 });
81
+ expect(validateField(num, " 0 ")).toEqual({ ok: true, value: 0 });
82
+ });
83
+
84
+ test("rejects non-numbers and out-of-range numbers", () => {
85
+ expect(validateField(num, "abc").ok).toBe(false);
86
+ expect(validateField(num, "-1").ok).toBe(false);
87
+ expect(validateField(num, "11").ok).toBe(false);
88
+ expect(validateField(num, "Infinity").ok).toBe(false);
89
+ });
90
+
91
+ test("parses both boolean spellings", () => {
92
+ for (const yes of ["y", "yes", "true", "1", "on", "Y", "TRUE"]) {
93
+ expect(validateField(bool, yes)).toEqual({ ok: true, value: true });
94
+ }
95
+ for (const no of ["n", "no", "false", "0", "off"]) {
96
+ expect(validateField(bool, no)).toEqual({ ok: true, value: false });
97
+ }
98
+ expect(validateField(bool, "maybe").ok).toBe(false);
99
+ });
100
+
101
+ test("enforces enum options", () => {
102
+ expect(validateField(en, "b")).toEqual({ ok: true, value: "b" });
103
+ const bad = validateField(en, "z");
104
+ expect(bad.ok).toBe(false);
105
+ if (!bad.ok) expect(bad.error).toContain("a, b");
106
+ });
107
+
108
+ test("splits and trims string arrays, dropping blanks", () => {
109
+ expect(validateField(arr, "x, y ,, z")).toEqual({ ok: true, value: ["x", "y", "z"] });
110
+ });
111
+
112
+ test("clear token is allowed only on optional fields", () => {
113
+ expect(validateField(opt, CLEAR_TOKEN)).toEqual({ ok: true, value: null });
114
+ expect(validateField(num, CLEAR_TOKEN).ok).toBe(false);
115
+ });
116
+ });
117
+
118
+ describe("applyAnswers", () => {
119
+ test("nests a flat edit map into a deep partial", () => {
120
+ expect(
121
+ applyAnswers({
122
+ "server.port": 9000,
123
+ "budget.perDayUsd": 0.5,
124
+ "tiers.hard.minQuality": 80,
125
+ }),
126
+ ).toEqual({
127
+ server: { port: 9000 },
128
+ budget: { perDayUsd: 0.5 },
129
+ tiers: { hard: { minQuality: 80 } },
130
+ });
131
+ });
132
+ });
133
+
134
+ describe("mergeConfigPartial", () => {
135
+ test("deep-merges without clobbering sibling keys", () => {
136
+ const merged = mergeConfigPartial(
137
+ { server: { host: "127.0.0.1", port: 8788 } },
138
+ { server: { port: 9000 } },
139
+ );
140
+ expect(merged).toEqual({ server: { host: "127.0.0.1", port: 9000 } });
141
+ });
142
+
143
+ test("a null leaf deletes the key instead of writing null", () => {
144
+ const merged = mergeConfigPartial(
145
+ { budget: { perDayUsd: 1, onExceeded: "reject" } },
146
+ { budget: { perDayUsd: null } },
147
+ );
148
+ expect(merged).toEqual({ budget: { onExceeded: "reject" } });
149
+ });
150
+
151
+ test("prunes a section left empty by a clear", () => {
152
+ const merged = mergeConfigPartial({ budget: { perDayUsd: 1 } }, { budget: { perDayUsd: null } });
153
+ expect(merged).toEqual({});
154
+ });
155
+
156
+ test("replaces arrays wholesale rather than merging by index", () => {
157
+ const merged = mergeConfigPartial(
158
+ { filters: { deny: ["a", "b", "c"] } },
159
+ { filters: { deny: ["z"] } },
160
+ );
161
+ expect(merged).toEqual({ filters: { deny: ["z"] } });
162
+ });
163
+
164
+ test("does not mutate the base object", () => {
165
+ const base = { server: { port: 8788 } };
166
+ mergeConfigPartial(base, { server: { port: 1 } });
167
+ expect(base).toEqual({ server: { port: 8788 } });
168
+ });
169
+ });
170
+
171
+ describe("formatValue", () => {
172
+ test("renders scalars, arrays, and absent values", () => {
173
+ expect(formatValue(0.5)).toBe("0.5");
174
+ expect(formatValue(true)).toBe("y");
175
+ expect(formatValue(false)).toBe("n");
176
+ expect(formatValue(["a", "b"])).toBe("a, b");
177
+ expect(formatValue([])).toBe("empty");
178
+ expect(formatValue(undefined)).toBe("unset");
179
+ expect(formatValue(null)).toBe("unset");
180
+ });
181
+ });
182
+
183
+ describe("runWizard", () => {
184
+ test("quitting writes nothing", async () => {
185
+ const { partial, changed } = await drive(["q"]);
186
+ expect(partial).toBeNull();
187
+ expect(changed).toBe(0);
188
+ });
189
+
190
+ test("saving with no edits writes nothing", async () => {
191
+ const { partial } = await drive(["s"]);
192
+ expect(partial).toBeNull();
193
+ });
194
+
195
+ test("end of input aborts without saving", async () => {
196
+ const { partial } = await drive([]);
197
+ expect(partial).toBeNull();
198
+ });
199
+
200
+ test("blank answers keep current values; only edits are written", async () => {
201
+ // Server section: host, port, apiKey, harnessId.
202
+ const { partial, changed } = await drive([
203
+ SECTION.get("Server") ?? "",
204
+ "", // keep host
205
+ "9000", // change port
206
+ "", // keep apiKey
207
+ "", // keep harnessId
208
+ "s",
209
+ ]);
210
+ expect(partial).toEqual({ server: { port: 9000 } });
211
+ expect(changed).toBe(1);
212
+ });
213
+
214
+ test("re-prompts on invalid input and keeps the corrected value", async () => {
215
+ const { partial, out } = await drive([
216
+ SECTION.get("Logging") ?? "",
217
+ "loud", // invalid enum
218
+ "debug", // corrected
219
+ "s",
220
+ ]);
221
+ expect(partial).toEqual({ logLevel: "debug" });
222
+ expect(out).toContain("silent, error, warn, info, debug");
223
+ });
224
+
225
+ test("clear token clears an optional field", async () => {
226
+ const { partial } = await drive([
227
+ SECTION.get("Budget") ?? "",
228
+ CLEAR_TOKEN, // clear perTurnUsd
229
+ "",
230
+ "",
231
+ "",
232
+ "s",
233
+ ]);
234
+ expect(partial).toEqual({ budget: { perTurnUsd: null } });
235
+ });
236
+
237
+ test("rejects a bad menu choice and stays in the menu", async () => {
238
+ const { partial, out } = await drive(["99", "zzz", "q"]);
239
+ expect(partial).toBeNull();
240
+ expect(out).toContain("not a choice");
241
+ });
242
+
243
+ test("edits across two sections accumulate", async () => {
244
+ const { partial, changed } = await drive([
245
+ SECTION.get("Logging") ?? "",
246
+ "warn",
247
+ SECTION.get("Cache") ?? "",
248
+ "n", // injectBreakpoints
249
+ "", // maxBreakpoints
250
+ "", // minPromptTokens
251
+ "s",
252
+ ]);
253
+ expect(partial).toEqual({ logLevel: "warn", cache: { injectBreakpoints: false } });
254
+ expect(changed).toBe(2);
255
+ });
256
+
257
+ test("menu shows the pending edit count", async () => {
258
+ const { out } = await drive([SECTION.get("Logging") ?? "", "warn", "q"]);
259
+ expect(out).toContain("1 pending");
260
+ });
261
+ });
262
+
263
+ describe("runWizard: profiles", () => {
264
+ // PROFILE_FIELDS order: id, name, minTier, maxTier, contextWindow, maxTokens.
265
+ const KEEP_ALL = ["", "", "", "", "", ""];
266
+
267
+ test("editing one profile field leaves the other profiles intact", async () => {
268
+ const { partial, changed } = await drive([
269
+ "p",
270
+ "1",
271
+ "", // id
272
+ "", // name
273
+ "", // minTier
274
+ "", // maxTier
275
+ "500000", // contextWindow
276
+ "", // maxTokens
277
+ "b",
278
+ "s",
279
+ ]);
280
+ expect(changed).toBe(1);
281
+ const profiles = (partial ?? {})["profiles"];
282
+ expect(Array.isArray(profiles)).toBe(true);
283
+ if (!Array.isArray(profiles)) return;
284
+ expect(profiles).toHaveLength(3);
285
+ expect(profiles[0]).toMatchObject({ id: "auto", contextWindow: 500000 });
286
+ expect(profiles[1]).toMatchObject({ id: "auto-cheap", contextWindow: 400000 });
287
+ });
288
+
289
+ test("adding a profile appends it", async () => {
290
+ const { partial } = await drive([
291
+ "p",
292
+ "n",
293
+ "auto-fast",
294
+ "Auto Fast",
295
+ "trivial",
296
+ "simple",
297
+ "200000",
298
+ "8000",
299
+ "b",
300
+ "s",
301
+ ]);
302
+ const profiles = (partial ?? {})["profiles"];
303
+ expect(Array.isArray(profiles)).toBe(true);
304
+ if (!Array.isArray(profiles)) return;
305
+ expect(profiles).toHaveLength(4);
306
+ expect(profiles[3]).toEqual({
307
+ id: "auto-fast",
308
+ name: "Auto Fast",
309
+ minTier: "trivial",
310
+ maxTier: "simple",
311
+ contextWindow: 200000,
312
+ maxTokens: 8000,
313
+ });
314
+ });
315
+
316
+ test("a new profile refuses a blank id", async () => {
317
+ const { out } = await drive(["p", "n", "", "ok-id", "Name", "", "", "", "", "b", "q"]);
318
+ expect(out).toContain("is required");
319
+ });
320
+
321
+ test("deleting a profile removes it", async () => {
322
+ const { partial } = await drive(["p", "x2", "b", "s"]);
323
+ const profiles = (partial ?? {})["profiles"];
324
+ expect(Array.isArray(profiles)).toBe(true);
325
+ if (!Array.isArray(profiles)) return;
326
+ expect(profiles.map((p) => (p as Record<string, unknown>)["id"])).toEqual(["auto", "auto-max"]);
327
+ });
328
+
329
+ test("refuses to delete the last remaining profile", async () => {
330
+ const { out } = await drive(["p", "x3", "x2", "x1", "b", "q"]);
331
+ expect(out).toContain("cannot delete the last profile");
332
+ });
333
+
334
+ test("rejects an out-of-range delete and a bad choice", async () => {
335
+ const { out } = await drive(["p", "x9", "zzz", "b", "q"]);
336
+ expect(out).toContain("no profile 9");
337
+ expect(out).toContain("not a choice");
338
+ });
339
+
340
+ test("back without edits records nothing", async () => {
341
+ const { partial } = await drive(["p", "b", "s"]);
342
+ expect(partial).toBeNull();
343
+ });
344
+
345
+ test("keeping every field still records the array (idempotent rewrite)", async () => {
346
+ const { partial } = await drive(["p", "1", ...KEEP_ALL, "b", "s"]);
347
+ const profiles = (partial ?? {})["profiles"];
348
+ expect(Array.isArray(profiles)).toBe(true);
349
+ if (!Array.isArray(profiles)) return;
350
+ expect(profiles[0]).toMatchObject({ id: "auto", contextWindow: 400000 });
351
+ });
352
+
353
+ test("profile edits survive a round-trip through the config file", () => {
354
+ const dir = mkdtempSync(join(tmpdir(), "ompr-prof-"));
355
+ try {
356
+ const target = join(dir, "config.yml");
357
+ writeRouterConfig(target, {
358
+ profiles: [
359
+ { id: "only", name: "Only", minTier: "simple", maxTier: "hard", contextWindow: 123456, maxTokens: 4096 },
360
+ ],
361
+ });
362
+ const reloaded = loadConfig({ path: target });
363
+ expect(reloaded.profiles).toHaveLength(1);
364
+ expect(reloaded.profiles[0]).toMatchObject({ id: "only", contextWindow: 123456 });
365
+ } finally {
366
+ rmSync(dir, { recursive: true, force: true });
367
+ }
368
+ });
369
+ });
370
+
371
+ describe("WIZARD_SECTIONS", () => {
372
+ test("required fields resolve against the default config", () => {
373
+ for (const section of WIZARD_SECTIONS) {
374
+ for (const field of section.fields) {
375
+ if (field.optional === true) continue;
376
+ expect(getPath(cfg, field.path), `path ${field.path} should resolve`).not.toBeUndefined();
377
+ }
378
+ }
379
+ });
380
+
381
+ test("field paths are unique", () => {
382
+ const paths = WIZARD_SECTIONS.flatMap((s) => s.fields.map((f) => f.path));
383
+ expect(new Set(paths).size).toBe(paths.length);
384
+ });
385
+
386
+ test("every enum field declares its options", () => {
387
+ for (const section of WIZARD_SECTIONS) {
388
+ for (const field of section.fields) {
389
+ if (field.kind !== "enum") continue;
390
+ expect((field.options ?? []).length, `${field.path} needs options`).toBeGreaterThan(0);
391
+ }
392
+ }
393
+ });
394
+ });
395
+
396
+ describe("StreamLineSource", () => {
397
+ async function collect(chunks: string[]): Promise<Array<string | null>> {
398
+ const encoder = new TextEncoder();
399
+ async function* gen(): AsyncGenerator<Uint8Array> {
400
+ for (const chunk of chunks) yield encoder.encode(chunk);
401
+ }
402
+ const source = new StreamLineSource(gen());
403
+ const lines: Array<string | null> = [];
404
+ for (;;) {
405
+ const line = await source.next();
406
+ lines.push(line);
407
+ if (line === null) return lines;
408
+ }
409
+ }
410
+
411
+ test("splits lines across chunk boundaries", async () => {
412
+ expect(await collect(["a\nb", "c\n"])).toEqual(["a", "bc", null]);
413
+ });
414
+
415
+ test("strips CR for Windows line endings", async () => {
416
+ expect(await collect(["a\r\nb\r\n"])).toEqual(["a", "b", null]);
417
+ });
418
+
419
+ test("yields a trailing line with no newline", async () => {
420
+ expect(await collect(["only"])).toEqual(["only", null]);
421
+ });
422
+
423
+ test("yields blank lines (a bare Enter keypress)", async () => {
424
+ expect(await collect(["\n\n"])).toEqual(["", "", null]);
425
+ });
426
+ });
427
+
428
+ describe("writeRouterConfig", () => {
429
+ function withTemp<T>(fn: (dir: string) => T): T {
430
+ const dir = mkdtempSync(join(tmpdir(), "ompr-cfg-"));
431
+ try {
432
+ return fn(dir);
433
+ } finally {
434
+ rmSync(dir, { recursive: true, force: true });
435
+ }
436
+ }
437
+
438
+ test("creates the file and reloads to the chosen value", () => {
439
+ withTemp((dir) => {
440
+ const target = join(dir, "config.yml");
441
+ const backup = writeRouterConfig(target, { server: { port: 9123 } });
442
+ expect(backup).toBeNull();
443
+ expect(parseYaml(readFileSync(target, "utf8"))).toEqual({ server: { port: 9123 } });
444
+ // The written file must actually load through the real loader.
445
+ expect(loadConfig({ path: target }).server.port).toBe(9123);
446
+ });
447
+ });
448
+
449
+ test("preserves unrelated existing keys and backs up the old file", () => {
450
+ withTemp((dir) => {
451
+ const target = join(dir, "config.yml");
452
+ writeFileSync(target, "logLevel: debug\nserver:\n host: 0.0.0.0\n", "utf8");
453
+ const backup = writeRouterConfig(target, { server: { port: 9123 } });
454
+ expect(backup).not.toBeNull();
455
+ expect(parseYaml(readFileSync(target, "utf8"))).toEqual({
456
+ logLevel: "debug",
457
+ server: { host: "0.0.0.0", port: 9123 },
458
+ });
459
+ if (backup !== null) {
460
+ expect(readFileSync(backup, "utf8")).toContain("logLevel: debug");
461
+ }
462
+ });
463
+ });
464
+
465
+ test("refuses to write an invalid config and leaves the file untouched", () => {
466
+ withTemp((dir) => {
467
+ const target = join(dir, "config.yml");
468
+ writeFileSync(target, "logLevel: debug\n", "utf8");
469
+ expect(() => writeRouterConfig(target, { logLevel: "loud" })).toThrow(/invalid config/);
470
+ expect(readFileSync(target, "utf8")).toBe("logLevel: debug\n");
471
+ });
472
+ });
473
+
474
+ test("a clear deletes the key from the written file", () => {
475
+ withTemp((dir) => {
476
+ const target = join(dir, "config.yml");
477
+ writeFileSync(target, "budget:\n perDayUsd: 5\n onExceeded: reject\n", "utf8");
478
+ writeRouterConfig(target, { budget: { perDayUsd: null } });
479
+ expect(parseYaml(readFileSync(target, "utf8"))).toEqual({ budget: { onExceeded: "reject" } });
480
+ });
481
+ });
482
+ });
@@ -0,0 +1,121 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
7
+ import { loadConfig } from "../src/config/load.ts";
8
+
9
+ const dirs: string[] = [];
10
+ const savedEnv = new Map<string, string | undefined>();
11
+
12
+ function tempDir(): string {
13
+ const dir = mkdtempSync(join(tmpdir(), "ompr-config-"));
14
+ dirs.push(dir);
15
+ return dir;
16
+ }
17
+
18
+ function setEnv(key: string, value: string | undefined): void {
19
+ if (!savedEnv.has(key)) savedEnv.set(key, process.env[key]);
20
+ if (value === undefined) delete process.env[key];
21
+ else process.env[key] = value;
22
+ }
23
+
24
+ /** Writes a config file and returns its path. */
25
+ function writeConfig(body: string): string {
26
+ const dir = tempDir();
27
+ const path = join(dir, "config.yml");
28
+ writeFileSync(path, body, "utf8");
29
+ return path;
30
+ }
31
+
32
+ afterEach(() => {
33
+ for (const [key, value] of savedEnv) {
34
+ if (value === undefined) delete process.env[key];
35
+ else process.env[key] = value;
36
+ }
37
+ savedEnv.clear();
38
+ for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
39
+ });
40
+
41
+ describe("loadConfig", () => {
42
+ test("loads defaults with no config file and no API key", () => {
43
+ setEnv("OPENROUTER_API_KEY", undefined);
44
+ setEnv("AUTO_MODEL_ROUTER_HOME", tempDir());
45
+ setEnv("PI_CODING_AGENT_DIR", tempDir());
46
+ const cfg = loadConfig({});
47
+ expect(cfg.server.port).toBe(DEFAULT_CONFIG.server.port);
48
+ expect(cfg.openrouter.apiKey).toBe("");
49
+ expect(cfg.profiles.length).toBeGreaterThan(0);
50
+ // A missing key must not be fatal: the catalog and `config` work keyless.
51
+ expect(cfg.tiers.hard.minQuality).toBe(DEFAULT_CONFIG.tiers.hard.minQuality);
52
+ });
53
+
54
+ test("resolves the ledger path under the router home", () => {
55
+ const home = tempDir();
56
+ setEnv("AUTO_MODEL_ROUTER_HOME", home);
57
+ const cfg = loadConfig({});
58
+ expect(cfg.ledger.path).toContain(home);
59
+ });
60
+
61
+ test("deep-merges nested objects from the config file", () => {
62
+ const path = writeConfig("tiers:\n hard:\n minQuality: 88\n");
63
+ const cfg = loadConfig({ path });
64
+ expect(cfg.tiers.hard.minQuality).toBe(88);
65
+ // Sibling keys inside the same nested object survive the merge.
66
+ expect(cfg.tiers.hard.qualityExponent).toBe(DEFAULT_CONFIG.tiers.hard.qualityExponent);
67
+ expect(cfg.tiers.trivial.minQuality).toBe(DEFAULT_CONFIG.tiers.trivial.minQuality);
68
+ });
69
+
70
+ test("replaces arrays wholesale rather than merging them", () => {
71
+ // Matches omp's own settings semantics: arrays never union or append.
72
+ expect(DEFAULT_CONFIG.escalation.probeTiers.length).toBeGreaterThan(1);
73
+ const path = writeConfig("escalation:\n probeTiers:\n - trivial\n");
74
+ const cfg = loadConfig({ path });
75
+ expect(cfg.escalation.probeTiers).toEqual(["trivial"]);
76
+ });
77
+
78
+ test("names the offending path when a value is invalid", () => {
79
+ const path = writeConfig("server:\n port: not-a-number\n");
80
+ let message = "";
81
+ try {
82
+ loadConfig({ path });
83
+ } catch (err) {
84
+ message = err instanceof Error ? err.message : String(err);
85
+ }
86
+ expect(message).not.toBe("");
87
+ expect(message).toContain("port");
88
+ });
89
+
90
+ test("environment variables override file values", () => {
91
+ const path = writeConfig("server:\n port: 9001\n");
92
+ setEnv("AUTO_MODEL_ROUTER_PORT", "9999");
93
+ const cfg = loadConfig({ path });
94
+ expect(cfg.server.port).toBe(9999);
95
+ });
96
+
97
+ test("explicit overrides beat the environment", () => {
98
+ setEnv("AUTO_MODEL_ROUTER_PORT", "9999");
99
+ const cfg = loadConfig({ overrides: { server: { host: "127.0.0.1", port: 7777 } } });
100
+ expect(cfg.server.port).toBe(7777);
101
+ });
102
+
103
+ test("reads the OpenRouter key from the environment", () => {
104
+ setEnv("OPENROUTER_API_KEY", "sk-or-test-value");
105
+ const cfg = loadConfig({});
106
+ expect(cfg.openrouter.apiKey).toBe("sk-or-test-value");
107
+ });
108
+
109
+ test("accepts a config that only overrides one scalar", () => {
110
+ const path = writeConfig("logLevel: debug\n");
111
+ const cfg = loadConfig({ path });
112
+ expect(cfg.logLevel).toBe("debug");
113
+ expect(cfg.escalation.enabled).toBe(DEFAULT_CONFIG.escalation.enabled);
114
+ });
115
+
116
+ test("an empty config file is valid and changes nothing", () => {
117
+ const path = writeConfig("");
118
+ const cfg = loadConfig({ path });
119
+ expect(cfg.server.port).toBe(DEFAULT_CONFIG.server.port);
120
+ });
121
+ });