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,151 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { WIZARD_SECTIONS } from "../src/cli/config-wizard.ts";
4
+ import type { FieldSpec } from "../src/cli/config-wizard.ts";
5
+
6
+ import { editProfile, walkSection, type ConfigUi } from "../omp-extension/configure-logic.ts";
7
+
8
+ function makeUi(script: Array<{ type: "select" | "input" | "confirm"; value?: string | boolean | undefined }>): ConfigUi {
9
+ const calls = script.slice();
10
+ return {
11
+ async select(_title, _options) {
12
+ const call = calls.shift();
13
+ if (call?.type !== "select") throw new Error("expected select, got " + JSON.stringify(call));
14
+ if (call.value === undefined) return undefined;
15
+ return call.value as string;
16
+ },
17
+ async input(_title, _placeholder, _initial) {
18
+ const call = calls.shift();
19
+ if (call?.type !== "input") throw new Error("expected input, got " + JSON.stringify(call));
20
+ if (call.value === undefined) return undefined;
21
+ return call.value as string;
22
+ },
23
+ async confirm() {
24
+ const call = calls.shift();
25
+ if (call?.type !== "confirm") throw new Error("expected confirm, got " + JSON.stringify(call));
26
+ return call.value as boolean;
27
+ },
28
+ notify(_text, _level) {},
29
+ };
30
+ }
31
+ const baseCfg = {
32
+ server: { host: "127.0.0.1", port: 8788, apiKey: undefined, harnessId: undefined },
33
+ openrouter: {
34
+ baseUrl: "https://openrouter.ai/api/v1",
35
+ title: "auto-model-router",
36
+ timeoutMs: 600000,
37
+ catalogTtlMs: 3600000,
38
+ catalogRefreshMs: 300000,
39
+ },
40
+ adaptiveTierFloors: true,
41
+ tiers: {},
42
+ tasks: {},
43
+ filters: {},
44
+ classifier: {},
45
+ escalation: {},
46
+ hysteresis: {},
47
+ cache: {},
48
+ budget: { onExceeded: "downgrade" },
49
+ profiles: [],
50
+ ledger: {},
51
+ logLevel: "info",
52
+ } as never;
53
+
54
+ const serverSection = WIZARD_SECTIONS.find((s) => s.title === "Server")!;
55
+
56
+ describe("promptField via walkSection", () => {
57
+ test("empty answer keeps the current value (no change)", async () => {
58
+ const ui = makeUi([
59
+ { type: "input", value: "" }, // keep host
60
+ { type: "input", value: "" }, // keep port
61
+ { type: "input", value: "" }, // keep apiKey
62
+ { type: "input", value: "" }, // keep harnessId
63
+ ]);
64
+ const answers: Record<string, unknown> = {};
65
+ const changed = await walkSection(ui, serverSection, baseCfg, answers);
66
+ expect(changed).toBe(false);
67
+ expect(answers).toEqual({});
68
+ });
69
+
70
+ test("an edit is collected under its dotted path", async () => {
71
+ const ui = makeUi([
72
+ { type: "input", value: "127.0.0.2" }, // host
73
+ { type: "input", value: "" }, // keep port
74
+ { type: "input", value: "" }, // keep apiKey
75
+ { type: "input", value: "" }, // keep harnessId
76
+ ]);
77
+ const answers: Record<string, unknown> = {};
78
+ const changed = await walkSection(ui, serverSection, baseCfg, answers);
79
+ expect(changed).toBe(true);
80
+ expect(answers).toEqual({ "server.host": "127.0.0.2" });
81
+ });
82
+
83
+ test("cancelling a dialog aborts the walk", async () => {
84
+ const ui = makeUi([{ type: "input", value: undefined }]);
85
+ const answers: Record<string, unknown> = {};
86
+ const changed = await walkSection(ui, serverSection, baseCfg, answers);
87
+ expect(changed).toBe(false);
88
+ expect(answers).toEqual({});
89
+ });
90
+
91
+ test("CLEAR_TOKEN clears an optional field to null", async () => {
92
+ const ui = makeUi([
93
+ { type: "input", value: "" }, // host
94
+ { type: "input", value: "" }, // port
95
+ { type: "input", value: "-" }, // clear apiKey
96
+ { type: "input", value: "" }, // harnessId
97
+ ]);
98
+ const answers: Record<string, unknown> = {};
99
+ const changed = await walkSection(ui, serverSection, baseCfg, answers);
100
+ expect(changed).toBe(true);
101
+ expect(answers).toEqual({ "server.apiKey": null });
102
+ });
103
+
104
+ test("boolean fields use the select dialog", async () => {
105
+ const adaptive = WIZARD_SECTIONS.find((s) => s.title === "Tiers")!;
106
+ const ui = makeUi([
107
+ { type: "select", value: "false" }, // adaptiveTierFloors
108
+ { type: "input", value: "" }, // trivial minQuality
109
+ { type: "input", value: "" }, // trivial maxInputPerMtok
110
+ { type: "input", value: "" }, // simple minQuality
111
+ { type: "input", value: "" }, // simple maxInputPerMtok
112
+ { type: "input", value: "" }, // moderate minQuality
113
+ { type: "input", value: "" }, // moderate maxInputPerMtok
114
+ { type: "input", value: "" }, // hard minQuality
115
+ { type: "input", value: "" }, // hard maxInputPerMtok
116
+ ]);
117
+ const answers: Record<string, unknown> = {};
118
+ await walkSection(ui, adaptive, baseCfg, answers);
119
+ expect(answers).toEqual({ adaptiveTierFloors: false });
120
+ });
121
+ });
122
+
123
+ describe("editProfile", () => {
124
+ test("updates a whole profile record", async () => {
125
+ const ui = makeUi([
126
+ { type: "input", value: "auto-cheap" }, // id
127
+ { type: "input", value: "Auto Cheap" }, // name
128
+ { type: "select", value: "trivial" }, // minTier
129
+ { type: "select", value: "simple" }, // maxTier
130
+ { type: "input", value: "" }, // contextWindow keep
131
+ { type: "input", value: "" }, // maxTokens keep
132
+ ]);
133
+ const fields = [
134
+ { path: "id", label: "Model id", kind: "string" },
135
+ { path: "name", label: "Display name", kind: "string" },
136
+ { path: "minTier", label: "Floor", kind: "enum", options: ["trivial", "simple", "moderate", "hard"] },
137
+ { path: "maxTier", label: "Ceiling", kind: "enum", options: ["trivial", "simple", "moderate", "hard"] },
138
+ { path: "contextWindow", label: "Context", kind: "number", min: 1 },
139
+ { path: "maxTokens", label: "Max output", kind: "number", min: 1 },
140
+ ] as FieldSpec[];
141
+ const out = await editProfile(ui, { id: "auto", name: "Auto", minTier: "trivial", maxTier: "hard", contextWindow: 400000, maxTokens: 32000 }, fields);
142
+ expect(out).toMatchObject({ id: "auto-cheap", name: "Auto Cheap", minTier: "trivial", maxTier: "simple", contextWindow: 400000 });
143
+ });
144
+
145
+ test("cancelled profile edit returns null", async () => {
146
+ const ui = makeUi([{ type: "input", value: undefined }]);
147
+ const fields = [{ path: "id", label: "id", kind: "string" }] as FieldSpec[];
148
+ const out = await editProfile(ui, { id: "auto" }, fields);
149
+ expect(out).toBeNull();
150
+ });
151
+ });
@@ -0,0 +1,137 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { normalizeCatalogModel } from "../src/catalog/openrouter-catalog.ts";
4
+ import type { CatalogModel } from "../src/catalog/types.ts";
5
+ import { computeCost, forecast, priceAt } from "../src/cost/forecast.ts";
6
+ import { EMPTY_USAGE, type UsageCounts } from "../src/cost/types.ts";
7
+
8
+ const FIXTURE = (await Bun.file("test/fixtures/openrouter-models.json").json()) as { data: unknown[] };
9
+
10
+ function model(slug: string): CatalogModel {
11
+ const raw = FIXTURE.data.find((m) => typeof m === "object" && m !== null && "id" in m && m.id === slug);
12
+ if (raw === undefined) throw new Error(`fixture is missing ${slug}`);
13
+ const normalized = normalizeCatalogModel(raw);
14
+ if (normalized === null) throw new Error(`${slug} did not normalize`);
15
+ return normalized;
16
+ }
17
+
18
+ function usage(over: Partial<UsageCounts>): UsageCounts {
19
+ return { ...EMPTY_USAGE, ...over };
20
+ }
21
+
22
+ const SONNET = model("anthropic/claude-sonnet-4.5"); // publishes cache prices + override tiers
23
+ const ALL = FIXTURE.data.map(normalizeCatalogModel).filter((m): m is CatalogModel => m !== null);
24
+
25
+ describe("priceAt", () => {
26
+ test("returns the base price below every override threshold", () => {
27
+ expect(priceAt(SONNET, 1000).prompt).toBe(SONNET.price.prompt);
28
+ expect(priceAt(SONNET, 199_999).prompt).toBe(SONNET.price.prompt);
29
+ });
30
+
31
+ test("crosses into the long-context tier at the threshold", () => {
32
+ const tier = SONNET.priceTiers[0];
33
+ expect(tier).toBeDefined();
34
+ if (tier === undefined) return;
35
+ expect(priceAt(SONNET, tier.minPromptTokens).prompt).toBe(tier.price.prompt);
36
+ expect(priceAt(SONNET, tier.minPromptTokens + 1).prompt).toBeGreaterThan(SONNET.price.prompt);
37
+ });
38
+
39
+ test("a long conversation is dearer per token than a short one", () => {
40
+ // The whole reason override tiers are modelled: ignoring them
41
+ // underestimates long-session cost by roughly half.
42
+ const short = computeCost(SONNET, usage({ promptTokens: 50_000, completionTokens: 1000 }));
43
+ const long = computeCost(SONNET, usage({ promptTokens: 400_000, completionTokens: 1000 }));
44
+ expect(long.total / 400_000).toBeGreaterThan(short.total / 50_000);
45
+ expect(long.tierAtPromptTokens).toBe(200_000);
46
+ expect(short.tierAtPromptTokens).toBe(0);
47
+ });
48
+ });
49
+
50
+ describe("computeCost", () => {
51
+ test("components sum to the reported total", () => {
52
+ const b = computeCost(
53
+ SONNET,
54
+ usage({ promptTokens: 10_000, cachedTokens: 6000, cacheWriteTokens: 1000, completionTokens: 500, reasoningTokens: 200, images: 2 }),
55
+ );
56
+ const sum = b.freshPrompt + b.cacheRead + b.cacheWrite + b.completion + b.reasoning + b.images + b.request;
57
+ expect(sum).toBeCloseTo(b.total, 12);
58
+ });
59
+
60
+ test("prompt_tokens already includes cached tokens, so they are not billed twice", () => {
61
+ // 10k prompt of which 10k cached must cost far less than 10k fresh,
62
+ // and must not be billed as 20k.
63
+ const allFresh = computeCost(SONNET, usage({ promptTokens: 10_000 }));
64
+ const allCached = computeCost(SONNET, usage({ promptTokens: 10_000, cachedTokens: 10_000 }));
65
+ expect(allCached.total).toBeLessThan(allFresh.total);
66
+ expect(allCached.freshPrompt).toBe(0);
67
+ const cacheRead = SONNET.price.cacheRead;
68
+ expect(cacheRead).toBeDefined();
69
+ if (cacheRead === undefined) return;
70
+ expect(allCached.cacheRead).toBeCloseTo(10_000 * cacheRead, 12);
71
+ });
72
+
73
+ test("cache reads are cheaper than fresh prompt tokens wherever published", () => {
74
+ let checked = 0;
75
+ for (const m of ALL) {
76
+ const read = m.price.cacheRead;
77
+ if (read === undefined || read === 0) continue;
78
+ checked++;
79
+ const fresh = computeCost(m, usage({ promptTokens: 20_000 }));
80
+ const cached = computeCost(m, usage({ promptTokens: 20_000, cachedTokens: 20_000 }));
81
+ expect(cached.total).toBeLessThanOrEqual(fresh.total);
82
+ }
83
+ expect(checked).toBeGreaterThan(0);
84
+ });
85
+
86
+ test("reasoning tokens are a subset of completion tokens and never double-billed", () => {
87
+ const withReasoning = computeCost(SONNET, usage({ completionTokens: 1000, reasoningTokens: 400 }));
88
+ const withoutReasoning = computeCost(SONNET, usage({ completionTokens: 1000 }));
89
+ // Sonnet publishes no separate reasoning rate, so 1000 completion tokens
90
+ // cost the same whether or not 400 of them were reasoning.
91
+ expect(withReasoning.total).toBeCloseTo(withoutReasoning.total, 12);
92
+ // And never more than billing all 1400 separately would have cost.
93
+ const inflated = computeCost(SONNET, usage({ completionTokens: 1400 }));
94
+ expect(withReasoning.total).toBeLessThan(inflated.total);
95
+ });
96
+
97
+ test("zero usage costs nothing beyond any flat per-request fee", () => {
98
+ const b = computeCost(SONNET, EMPTY_USAGE);
99
+ expect(b.total).toBe(b.request);
100
+ });
101
+ });
102
+
103
+ describe("forecast", () => {
104
+ test("cold is never cheaper than expected, for every model in the catalog", () => {
105
+ // A budget guard checks the cold number, so this ordering is load-bearing:
106
+ // several models publish a cache-write rate BELOW their prompt rate, so
107
+ // the honest worst case is the max of "no cache" and "full cache write".
108
+ for (const m of ALL) {
109
+ for (const hitRate of [0, 0.5, 0.9]) {
110
+ const f = forecast(m, { promptTokens: 30_000, completionTokens: 800, cacheHitRate: hitRate, images: 0 });
111
+ expect(f.coldUsd).toBeGreaterThanOrEqual(f.expectedUsd - 1e-12);
112
+ }
113
+ }
114
+ });
115
+
116
+ test("a higher assumed cache hit rate lowers the expected cost", () => {
117
+ const cold = forecast(SONNET, { promptTokens: 50_000, completionTokens: 500, cacheHitRate: 0, images: 0 });
118
+ const warm = forecast(SONNET, { promptTokens: 50_000, completionTokens: 500, cacheHitRate: 0.9, images: 0 });
119
+ expect(warm.expectedUsd).toBeLessThan(cold.expectedUsd);
120
+ expect(warm.assumedCacheHitRate).toBeCloseTo(0.9, 12);
121
+ });
122
+
123
+ test("records the assumptions it was given", () => {
124
+ const f = forecast(SONNET, { promptTokens: 1234, completionTokens: 567, cacheHitRate: 0.25, images: 3 });
125
+ expect(f.slug).toBe(SONNET.slug);
126
+ expect(f.assumedPromptTokens).toBe(1234);
127
+ expect(f.assumedCompletionTokens).toBe(567);
128
+ expect(f.expectedUsd).toBeGreaterThan(0);
129
+ });
130
+
131
+ test("a cheap model forecasts below an expensive one for identical work", () => {
132
+ const cheap = model("openai/gpt-5-nano");
133
+ const dear = model("openai/gpt-5-pro");
134
+ const args = { promptTokens: 20_000, completionTokens: 1000, cacheHitRate: 0, images: 0 };
135
+ expect(forecast(cheap, args).expectedUsd).toBeLessThan(forecast(dear, args).expectedUsd);
136
+ });
137
+ });
@@ -0,0 +1,107 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import {
7
+ buildProviderConfig,
8
+ EMBED_PORT_FILE,
9
+ EMBED_PROVIDER_ID,
10
+ embedPortPath,
11
+ readEmbedPort,
12
+ resolveEmbedPort,
13
+ writeEmbedPort,
14
+ type EmbedConfig,
15
+ } from "../omp-extension/embed-logic.ts";
16
+
17
+ describe("resolveEmbedPort", () => {
18
+ test("returns 0 (let the OS assign a free port) when AUTO_MODEL_ROUTER_PORT is absent", () => {
19
+ expect(resolveEmbedPort(undefined)).toBe(0);
20
+ expect(resolveEmbedPort("")).toBe(0);
21
+ });
22
+
23
+ test("uses an explicit valid env port verbatim", () => {
24
+ expect(resolveEmbedPort("8812")).toBe(8812);
25
+ expect(resolveEmbedPort("0")).toBe(0);
26
+ });
27
+
28
+ test("falls back to 0 on junk or out-of-range values", () => {
29
+ expect(resolveEmbedPort("notaport")).toBe(0);
30
+ expect(resolveEmbedPort("-1")).toBe(0);
31
+ expect(resolveEmbedPort("70000")).toBe(0);
32
+ });
33
+ });
34
+
35
+ describe("embed port file", () => {
36
+ let dir: string;
37
+
38
+ beforeAll(() => {
39
+ dir = mkdtempSync(join(tmpdir(), "omp-embed-"));
40
+ });
41
+ afterAll(() => {
42
+ if (dir) rmSync(dir, { recursive: true, force: true });
43
+ });
44
+
45
+ test("round-trips the bound port", () => {
46
+ const p = embedPortPath(dir);
47
+ expect(p).toBe(join(dir, EMBED_PORT_FILE));
48
+ writeEmbedPort(p, 45678);
49
+ expect(readEmbedPort(p)).toBe(45678);
50
+ });
51
+
52
+ test("returns null for a missing or malformed file", () => {
53
+ expect(readEmbedPort(embedPortPath(join(dir, "absent")))).toBeNull();
54
+ writeEmbedPort(embedPortPath(dir), -5);
55
+ expect(readEmbedPort(embedPortPath(dir))).toBeNull();
56
+ writeEmbedPort(embedPortPath(dir), 70000);
57
+ expect(readEmbedPort(embedPortPath(dir))).toBeNull();
58
+ writeEmbedPort(embedPortPath(dir), 0);
59
+ expect(readEmbedPort(embedPortPath(dir))).toBeNull();
60
+ });
61
+ });
62
+
63
+ describe("buildProviderConfig", () => {
64
+ const base = {
65
+ server: { host: "127.0.0.1" },
66
+ profiles: [
67
+ { id: "auto", name: "Auto (auto-model-router)", contextWindow: 400_000, maxTokens: 32_000 },
68
+ { id: "auto-cheap", name: "Auto Cheap (auto-model-router)", contextWindow: 400_000, maxTokens: 32_000 },
69
+ ],
70
+ ledger: { fallbackBlend: { inputPerMtok: 0.2, outputPerMtok: 0.8 } },
71
+ };
72
+
73
+ test("builds a provider config against the actual bound port", () => {
74
+ const c: EmbedConfig = buildProviderConfig(45678, base);
75
+ expect(c.baseUrl).toBe("http://127.0.0.1:45678/v1");
76
+ expect(c.port).toBe(45678);
77
+ expect(c.host).toBe("127.0.0.1");
78
+ expect(c.harnessId).toBeUndefined();
79
+ expect(c.models).toHaveLength(2);
80
+ expect(c.models[0]).toMatchObject({ id: "auto", contextWindow: 400_000, maxTokens: 32_000 });
81
+ });
82
+
83
+ test("converts cost to USD-per-million-token and applies cache multipliers", () => {
84
+ const c: EmbedConfig = buildProviderConfig(45678, base);
85
+ // input 0.2, output 0.8, cacheRead = 0.2*0.1 = 0.02, cacheWrite = 0.2*1.25 = 0.25
86
+ expect(c.models[0]!.cost).toEqual({ input: 0.2, output: 0.8, cacheRead: 0.02, cacheWrite: 0.25 });
87
+ });
88
+
89
+ test("normalizes a wildcard listen host to loopback", () => {
90
+ const c: EmbedConfig = buildProviderConfig(45678, { ...base, server: { host: "0.0.0.0" } });
91
+ expect(c.baseUrl).toBe("http://127.0.0.1:45678/v1");
92
+ });
93
+
94
+ test("carries the harness id through when configured", () => {
95
+ const c: EmbedConfig = buildProviderConfig(45678, { ...base, server: { host: "127.0.0.1", harnessId: "prod-a" } });
96
+ expect(c.harnessId).toBe("prod-a");
97
+ });
98
+ });
99
+
100
+ describe("embed constants", () => {
101
+ test("provider id and dummy key stay stable", () => {
102
+ expect(EMBED_PROVIDER_ID).toBe("auto-model-router");
103
+ });
104
+ test("port file name is stable", () => {
105
+ expect(EMBED_PORT_FILE).toBe("embed.port");
106
+ });
107
+ });
@@ -0,0 +1,223 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createProbe } from "../src/router/escalate.ts";
3
+ import type { ProbePlan } from "../src/router/types.ts";
4
+ import type { NormMessage, NormRequest, NormTool, StreamEvent, UpstreamChunk } from "../src/wire/types.ts";
5
+
6
+ const ALL_TRIGGERS: ReadonlySet<string> = new Set([
7
+ "malformed_tool_args",
8
+ "refusal",
9
+ "empty_completion",
10
+ "repeat_tool_call",
11
+ "missing_expected_tool_call",
12
+ "length_stop",
13
+ "upstream_error",
14
+ ]);
15
+
16
+ function plan(over: Partial<ProbePlan> = {}): ProbePlan {
17
+ return { enabled: true, maxTokens: 24, maxHoldMs: 60_000, escalateTo: "simple", ...over };
18
+ }
19
+
20
+ function req(messages: NormMessage[] = [], over: Partial<NormRequest> = {}): NormRequest {
21
+ return {
22
+ protocol: "openai-chat",
23
+ conversationKey: "k",
24
+ harnessId: "",
25
+ requestedModel: "auto",
26
+ messages,
27
+ tools: [],
28
+ forcedToolChoice: false,
29
+ stream: true,
30
+ hasImages: false,
31
+ promptBytes: 0,
32
+ renderUpstreamBody: () => ({}),
33
+ ...over,
34
+ };
35
+ }
36
+
37
+ function chunk(events: StreamEvent[]): UpstreamChunk {
38
+ return { raw: {}, events };
39
+ }
40
+
41
+ function text(delta: string): UpstreamChunk {
42
+ return chunk([{ type: "text", delta }]);
43
+ }
44
+
45
+ function toolDelta(
46
+ index: number,
47
+ fragment: { id?: string; name?: string; argsDelta?: string },
48
+ ): UpstreamChunk {
49
+ const ev: StreamEvent = { type: "tool_call", index, ...fragment };
50
+ return chunk([ev]);
51
+ }
52
+
53
+ describe("createProbe", () => {
54
+ test("valid tool-call JSON commits", () => {
55
+ const p = createProbe(plan(), req(), ALL_TRIGGERS);
56
+ expect(p.observe(toolDelta(0, { id: "c1", name: "read", argsDelta: '{"path":"a' }))).toBeNull();
57
+ const verdict = p.observe(toolDelta(0, { argsDelta: '.ts"}' }));
58
+ expect(verdict?.action).toBe("commit");
59
+ });
60
+
61
+ test("truncated tool-call JSON at stream end yields malformed_tool_args", () => {
62
+ // Via the finish event.
63
+ const p1 = createProbe(plan(), req(), ALL_TRIGGERS);
64
+ p1.observe(toolDelta(0, { id: "c1", name: "read", argsDelta: '{"path":"a' }));
65
+ const v1 = p1.observe(chunk([{ type: "finish", reason: "tool_calls" }]));
66
+ expect(v1?.action).toBe("escalate");
67
+ expect(v1).toMatchObject({ signal: "malformed_tool_args" });
68
+
69
+ // Via stream end with no finish event at all.
70
+ const p2 = createProbe(plan(), req(), ALL_TRIGGERS);
71
+ p2.observe(toolDelta(0, { id: "c1", name: "read", argsDelta: '{"path":"a' }));
72
+ const v2 = p2.verdictOnEnd();
73
+ expect(v2.action).toBe("escalate");
74
+ expect(v2).toMatchObject({ signal: "malformed_tool_args" });
75
+ });
76
+
77
+ test("a tool call identical to the previous assistant call yields repeat_tool_call", () => {
78
+ const history: NormMessage[] = [
79
+ { role: "user", text: "read it", images: 0, textBytes: 8, toolCalls: [] },
80
+ {
81
+ role: "assistant",
82
+ text: "",
83
+ images: 0,
84
+ textBytes: 0,
85
+ toolCalls: [{ id: "c1", name: "read", argsJson: '{"path":"a.ts","line":1}' }],
86
+ },
87
+ { role: "tool", text: "file contents", images: 0, textBytes: 13, toolCalls: [], toolCallId: "c1", toolName: "read" },
88
+ ];
89
+ const p = createProbe(plan(), req(history), ALL_TRIGGERS);
90
+ // Same call, key order shuffled: still a loop.
91
+ const verdict = p.observe(toolDelta(0, { id: "c2", name: "read", argsDelta: '{"line":1,"path":"a.ts"}' }));
92
+ expect(verdict?.action).toBe("escalate");
93
+ expect(verdict).toMatchObject({ signal: "repeat_tool_call" });
94
+ });
95
+
96
+ test("a different tool call is not a repeat", () => {
97
+ const history: NormMessage[] = [
98
+ {
99
+ role: "assistant",
100
+ text: "",
101
+ images: 0,
102
+ textBytes: 0,
103
+ toolCalls: [{ id: "c1", name: "read", argsJson: '{"path":"a.ts"}' }],
104
+ },
105
+ ];
106
+ const p = createProbe(plan(), req(history), ALL_TRIGGERS);
107
+ const verdict = p.observe(toolDelta(0, { id: "c2", name: "read", argsDelta: '{"path":"b.ts"}' }));
108
+ expect(verdict?.action).toBe("commit");
109
+ });
110
+
111
+ test("a disabled plan commits on the first chunk", () => {
112
+ const p = createProbe(plan({ enabled: false }), req(), ALL_TRIGGERS);
113
+ const verdict = p.observe(text("anything at all"));
114
+ expect(verdict?.action).toBe("commit");
115
+ expect(p.held()).toHaveLength(1);
116
+ });
117
+
118
+ test("a signal absent from triggers never fires", () => {
119
+ // Truncated args would be malformed_tool_args, but the trigger is off.
120
+ const p1 = createProbe(plan(), req(), new Set(["refusal"]));
121
+ p1.observe(toolDelta(0, { id: "c1", name: "read", argsDelta: '{"path":"a' }));
122
+ const v1 = p1.observe(chunk([{ type: "finish", reason: "tool_calls" }]));
123
+ expect(v1?.action).toBe("commit");
124
+
125
+ // Empty completion, but empty_completion is off.
126
+ const p2 = createProbe(plan(), req(), new Set(["malformed_tool_args"]));
127
+ p2.observe(chunk([{ type: "finish", reason: "stop" }]));
128
+ const v2 = p2.verdictOnEnd();
129
+ expect(v2.action).toBe("commit");
130
+ });
131
+
132
+ test("refusal openers escalate as soon as text arrives", () => {
133
+ const p = createProbe(plan(), req(), ALL_TRIGGERS);
134
+ const verdict = p.observe(text("I'm sorry, but I can't help with that request."));
135
+ expect(verdict?.action).toBe("escalate");
136
+ expect(verdict).toMatchObject({ signal: "refusal" });
137
+ });
138
+
139
+ test("a forced tool choice answered with prose yields missing_expected_tool_call", () => {
140
+ const tools: NormTool[] = [{ name: "read", description: "read a file", schemaBytes: 42 }];
141
+ const p = createProbe(plan(), req([], { tools, forcedToolChoice: true }), ALL_TRIGGERS);
142
+ p.observe(text("Sure, here is some prose instead."));
143
+ const verdict = p.observe(chunk([{ type: "finish", reason: "stop" }]));
144
+ expect(verdict?.action).toBe("escalate");
145
+ expect(verdict).toMatchObject({ signal: "missing_expected_tool_call" });
146
+ });
147
+
148
+ test("enough held text commits", () => {
149
+ const p = createProbe(plan({ maxTokens: 2 }), req(), ALL_TRIGGERS);
150
+ const verdict = p.observe(text("this is well over eight characters"));
151
+ expect(verdict?.action).toBe("commit");
152
+ });
153
+
154
+ test("an empty stop with nothing emitted yields empty_completion", () => {
155
+ const p = createProbe(plan(), req(), ALL_TRIGGERS);
156
+ const verdict = p.observe(chunk([{ type: "finish", reason: "stop" }]));
157
+ expect(verdict?.action).toBe("escalate");
158
+ expect(verdict).toMatchObject({ signal: "empty_completion" });
159
+ });
160
+
161
+ test("a stalled stream escalates at the hold ceiling instead of committing silence", () => {
162
+ let t = 0;
163
+ const p = createProbe(plan({ maxHoldMs: 1_000 }), req(), ALL_TRIGGERS, () => t);
164
+ expect(p.observe(chunk([]))).toBeNull();
165
+ t = 1_001;
166
+ const verdict = p.observe(chunk([]));
167
+ expect(verdict?.action).toBe("escalate");
168
+ expect(verdict).toMatchObject({ signal: "empty_completion" });
169
+ });
170
+
171
+ test("the hold ceiling still commits when content has arrived", () => {
172
+ let t = 0;
173
+ const p = createProbe(plan({ maxTokens: 1_000, maxHoldMs: 1_000 }), req(), ALL_TRIGGERS, () => t);
174
+ expect(p.observe(text("partial answer"))).toBeNull();
175
+ t = 1_001;
176
+ const verdict = p.observe(text(" more"));
177
+ expect(verdict?.action).toBe("commit");
178
+ });
179
+
180
+ test("a length finish on prose commits: that is the caller's max_tokens", () => {
181
+ // Escalating cannot fix it — the retry runs under the same cap and
182
+ // truncates in the same place, so it would just bill twice.
183
+ const p = createProbe(plan({ maxTokens: 1_000 }), req(), ALL_TRIGGERS);
184
+ expect(p.observe(text("a long answer that ran out of room"))).toBeNull();
185
+ const verdict = p.observe(chunk([{ type: "finish", reason: "length" }]));
186
+ expect(verdict?.action).toBe("commit");
187
+ });
188
+
189
+ test("a length finish that truncated tool-call arguments still escalates", () => {
190
+ // Structurally unusable output: another model may emit a complete call.
191
+ const p = createProbe(plan({ maxTokens: 1_000 }), req(), ALL_TRIGGERS);
192
+ expect(p.observe(toolDelta(0, { id: "c1", name: "read", argsDelta: '{"path":"a' }))).toBeNull();
193
+ const verdict = p.observe(chunk([{ type: "finish", reason: "length" }]));
194
+ expect(verdict?.action).toBe("escalate");
195
+ });
196
+
197
+ test("a length finish having produced nothing escalates as an empty completion", () => {
198
+ const p = createProbe(plan({ maxTokens: 1_000 }), req(), ALL_TRIGGERS);
199
+ const verdict = p.observe(chunk([{ type: "finish", reason: "length" }]));
200
+ expect(verdict?.action).toBe("escalate");
201
+ if (verdict?.action === "escalate") expect(verdict.signal).toBe("empty_completion");
202
+ });
203
+
204
+ test("reasoning-only output counts as alive at the hold ceiling", () => {
205
+ // A reasoning model that has emitted only reasoning tokens after the
206
+ // ceiling is working normally; escalating would discard a healthy paid
207
+ // generation.
208
+ let t = 0;
209
+ const p = createProbe(plan({ maxTokens: 1_000, maxHoldMs: 1_000 }), req(), ALL_TRIGGERS, () => t);
210
+ expect(p.observe(chunk([{ type: "reasoning", delta: "weighing options" }]))).toBeNull();
211
+ t = 1_001;
212
+ const verdict = p.observe(chunk([{ type: "reasoning", delta: " further" }]));
213
+ expect(verdict?.action).toBe("commit");
214
+ });
215
+
216
+ test("a stream that ENDS with only reasoning is still hollow", () => {
217
+ const p = createProbe(plan({ maxTokens: 1_000 }), req(), ALL_TRIGGERS);
218
+ expect(p.observe(chunk([{ type: "reasoning", delta: "thinking" }]))).toBeNull();
219
+ const verdict = p.verdictOnEnd();
220
+ expect(verdict.action).toBe("escalate");
221
+ if (verdict.action === "escalate") expect(verdict.signal).toBe("empty_completion");
222
+ });
223
+ });