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,175 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { loadConfig } from "../src/config/load.ts";
4
+ import { createLedger } from "../src/cost/ledger.ts";
5
+ import { EMPTY_USAGE, type LedgerEntry } from "../src/cost/types.ts";
6
+ import { openDb } from "../src/util/sqlite.ts";
7
+
8
+ const cfg = loadConfig({});
9
+
10
+ function entry(over: Partial<LedgerEntry>): LedgerEntry {
11
+ return {
12
+ id: crypto.randomUUID(),
13
+ createdAtMs: Date.now(),
14
+ conversationKey: "k",
15
+ sessionId: "omp-k",
16
+ turn: 1,
17
+ requestedModel: "auto",
18
+ harnessId: "",
19
+ slug: "vendor/model",
20
+ servedSlug: "vendor/model",
21
+ tier: "simple",
22
+ classificationSource: "heuristic",
23
+ reasons: [],
24
+ predictedUsd: 0.001,
25
+ reportedUsd: 0.001,
26
+ usage: EMPTY_USAGE,
27
+ attempt: 0,
28
+ escalationSignal: null,
29
+ latencyMs: 100,
30
+ ttftMs: 50,
31
+ finishReason: "stop",
32
+ wasted: false,
33
+ upstreamGenerationId: null,
34
+ error: null,
35
+ ...over,
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Trust must reflect the MODEL's reliability. A client hanging up, an
41
+ * account-level auth/policy refusal, or a guardrail excluding the endpoint say
42
+ * nothing about model quality, and counting them shrinks the candidate pool
43
+ * onto whichever models happened to avoid those conditions.
44
+ */
45
+ describe("trust attribution", () => {
46
+ function trustAfter(errors: Array<string | null>): number {
47
+ const db = openDb(":memory:");
48
+ try {
49
+ const ledger = createLedger(db, cfg);
50
+ for (const error of errors) ledger.record(entry({ error }));
51
+ const trust = ledger.trust("vendor/model");
52
+ expect(trust).not.toBeNull();
53
+ return trust?.successRate ?? 0;
54
+ } finally {
55
+ db.close();
56
+ }
57
+ }
58
+
59
+ const CLEAN = trustAfter([null, null, null, null]);
60
+
61
+ test("client aborts do not count against the model", () => {
62
+ expect(trustAfter([null, null, "request aborted", "request aborted"])).toBe(CLEAN);
63
+ });
64
+
65
+ test("account-level auth refusals do not count against the model", () => {
66
+ expect(
67
+ trustAfter([
68
+ null,
69
+ null,
70
+ "auth: Request blocked: prompt injection patterns detected",
71
+ "auth: This model requires 18+ age confirmation",
72
+ ]),
73
+ ).toBe(CLEAN);
74
+ });
75
+
76
+ test("guardrail model_unavailable does not count against the model", () => {
77
+ expect(
78
+ trustAfter([null, null, "model_unavailable: No endpoints available matching your guardrail", null]),
79
+ ).toBeGreaterThan(0.7);
80
+ });
81
+
82
+ test("a genuine upstream error DOES count against the model", () => {
83
+ const withError = trustAfter([null, null, "upstream_error: Upstream request failed", "upstream_error: boom"]);
84
+ expect(withError).toBeLessThan(CLEAN);
85
+ });
86
+
87
+ test("an unclassifiable legacy error stays attributable", () => {
88
+ // No "<kind>: " prefix and not the known abort text: we cannot prove it
89
+ // was blameless, so it keeps counting (the stricter reading).
90
+ const withError = trustAfter([null, null, "something odd happened", "another"]);
91
+ expect(withError).toBeLessThan(CLEAN);
92
+ });
93
+
94
+ test("errors field still records the raw text regardless of attribution", () => {
95
+ const db = openDb(":memory:");
96
+ try {
97
+ const ledger = createLedger(db, cfg);
98
+ ledger.record(entry({ error: "request aborted" }));
99
+ const row = db.query("SELECT error, error_kind FROM ledger").get() as {
100
+ error: string | null;
101
+ error_kind: string | null;
102
+ };
103
+ expect(row.error).toBe("request aborted");
104
+ expect(row.error_kind).toBe("aborted");
105
+ } finally {
106
+ db.close();
107
+ }
108
+ });
109
+
110
+ test("escalations still count as failures independently of errors", () => {
111
+ const db = openDb(":memory:");
112
+ try {
113
+ const ledger = createLedger(db, cfg);
114
+ ledger.record(entry({ error: null }));
115
+ ledger.record(entry({ error: null }));
116
+ ledger.record(entry({ escalationSignal: "empty_completion" }));
117
+ const trust = ledger.trust("vendor/model");
118
+ expect(trust?.escalations).toBe(1);
119
+ expect(trust?.successRate).toBeLessThan(CLEAN);
120
+ } finally {
121
+ db.close();
122
+ }
123
+ });
124
+
125
+ test("aborted rows are still counted as attempts", () => {
126
+ const db = openDb(":memory:");
127
+ try {
128
+ const ledger = createLedger(db, cfg);
129
+ ledger.record(entry({ error: "request aborted" }));
130
+ ledger.record(entry({ error: null }));
131
+ expect(ledger.trust("vendor/model")?.attempts).toBe(2);
132
+ // ...but not as errors.
133
+ expect(ledger.trust("vendor/model")?.errors).toBe(0);
134
+ } finally {
135
+ db.close();
136
+ }
137
+ });
138
+ });
139
+
140
+ describe("v4 migration", () => {
141
+ test("backfills error_kind from stored error text", () => {
142
+ const db = openDb(":memory:");
143
+ try {
144
+ const ledger = createLedger(db, cfg);
145
+ ledger.record(entry({ error: "request aborted" }));
146
+ ledger.record(entry({ error: "auth: nope" }));
147
+ ledger.record(entry({ error: "model_unavailable: guardrail" }));
148
+ ledger.record(entry({ error: "upstream_error: boom" }));
149
+ ledger.record(entry({ error: null }));
150
+
151
+ const rows = db
152
+ .query("SELECT error, error_kind FROM ledger ORDER BY rowid")
153
+ .all() as Array<{ error: string | null; error_kind: string | null }>;
154
+ expect(rows.map((r) => r.error_kind)).toEqual([
155
+ "aborted",
156
+ "auth",
157
+ "model_unavailable",
158
+ "upstream_error",
159
+ null,
160
+ ]);
161
+ } finally {
162
+ db.close();
163
+ }
164
+ });
165
+
166
+ test("schema is at user_version 4", () => {
167
+ const db = openDb(":memory:");
168
+ try {
169
+ const row = db.query("PRAGMA user_version").get() as { user_version: number };
170
+ expect(row.user_version).toBe(4);
171
+ } finally {
172
+ db.close();
173
+ }
174
+ });
175
+ });
@@ -0,0 +1,498 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { CatalogSource } from "../src/catalog/types.ts";
3
+ import type { EscalationConfig, RouterConfig } from "../src/config/types.ts";
4
+ import { EMPTY_USAGE, type Ledger, type LedgerEntry, type UsageCounts } from "../src/cost/types.ts";
5
+ import type {
6
+ ConversationState,
7
+ ConversationStore,
8
+ Decision,
9
+ Features,
10
+ ProbePlan,
11
+ Router,
12
+ Tier,
13
+ } from "../src/router/types.ts";
14
+ import { runTurn } from "../src/server/turn.ts";
15
+ import { UpstreamError, type DispatchOptions, type UpstreamClient } from "../src/upstream/types.ts";
16
+ import type {
17
+ FinishReason,
18
+ NormRequest,
19
+ ResponseSink,
20
+ StreamEvent,
21
+ TurnSummary,
22
+ UpstreamChunk,
23
+ WireError,
24
+ } from "../src/wire/types.ts";
25
+
26
+ // ---------- fakes ----------
27
+
28
+ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
29
+ return {
30
+ server: { host: "127.0.0.1", port: 8787 },
31
+ openrouter: { baseUrl: "https://openrouter.ai/api/v1", apiKey: "", title: "test", timeoutMs: 30_000, catalogTtlMs: 3_600_000, catalogRefreshMs: 0 },
32
+ tiers: {
33
+ trivial: { minQuality: 0, maxInputPerMtok: 0.3, qualityExponent: 0, pin: [] },
34
+ simple: { minQuality: 40, maxInputPerMtok: 1.5, qualityExponent: 0, pin: [] },
35
+ moderate: { minQuality: 60, maxInputPerMtok: 4, qualityExponent: 1, pin: [] },
36
+ hard: { minQuality: 72, qualityExponent: 3, pin: [] },
37
+ },
38
+ tasks: {
39
+ coding: { axis: "coding", minQuality: 40 },
40
+ vision: { axis: "intelligence", requireImage: true },
41
+ documentation: { axis: "intelligence", minQuality: 0 },
42
+ data: { axis: "intelligence", minQuality: 0 },
43
+ chat: { axis: "intelligence", minQuality: 0 },
44
+ },
45
+ filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, minTrustSamples: 5, trustScopedByHarness: false, contextHeadroom: 1.2 },
46
+ classifier: {
47
+ ambiguityThreshold: 0,
48
+ model: "test/adjudicator",
49
+ maxCostFraction: 0.1,
50
+ maxCostUsd: 0.01,
51
+ timeoutMs: 5000,
52
+ cacheSize: 128,
53
+ toolAxis: "coding",
54
+ chatAxis: "intelligence",
55
+ agenticLoopDepth: 3,
56
+ },
57
+ escalation: {
58
+ enabled: true,
59
+ probeTokens: 24,
60
+ maxHoldMs: 5000,
61
+ maxAttempts: 3,
62
+ probeTiers: ["trivial", "simple", "moderate"],
63
+ triggers: ["malformed_tool_args", "refusal", "empty_completion", "repeat_tool_call", "missing_expected_tool_call"],
64
+ escalateOnLengthStop: false,
65
+ ...escalation,
66
+ },
67
+ hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1 },
68
+ cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024 },
69
+ budget: { onExceeded: "downgrade" },
70
+ profiles: [],
71
+ ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 },
72
+ adaptiveTierFloors: true,
73
+ logLevel: "silent",
74
+ };
75
+ }
76
+
77
+ function mkReq(): NormRequest {
78
+ return {
79
+ protocol: "openai-chat",
80
+ conversationKey: "conv-test",
81
+ harnessId: "",
82
+ requestedModel: "auto",
83
+ messages: [{ role: "user", text: "hi", images: 0, textBytes: 2, toolCalls: [] }],
84
+ tools: [],
85
+ forcedToolChoice: false,
86
+ stream: true,
87
+ hasImages: false,
88
+ promptBytes: 2,
89
+ renderUpstreamBody: (m) => ({ model: m.slug, session_id: m.sessionId }),
90
+ };
91
+ }
92
+
93
+ // Neutral feature vector for fake decisions; runTurn never reads it, but the
94
+ // amended contract requires it on every Decision.
95
+ const FEATURES: Features = {
96
+ promptTokens: 0,
97
+ newContentTokens: 0,
98
+ turnDepth: 0,
99
+ toolCount: 0,
100
+ toolSchemaBytes: 0,
101
+ isToolResultContinuation: false,
102
+ toolLoopDepth: 0,
103
+ distinctToolsUsed: 0,
104
+ lastToolFailed: false,
105
+ repeatedToolCall: false,
106
+ hasImages: false,
107
+ codeBlocks: 0,
108
+ codeBytes: 0,
109
+ looksLikeDiff: false,
110
+ complexityKeywords: [],
111
+ trivialityKeywords: [],
112
+ requestedReasoning: undefined,
113
+ questionCount: 0,
114
+ isTerseInstruction: false,
115
+ };
116
+
117
+ function mkDecision(tier: Tier, slug: string, probe: Partial<ProbePlan> = {}): Decision {
118
+ return {
119
+ slug,
120
+ fallbacks: [],
121
+ tier,
122
+ features: FEATURES,
123
+ classification: { tier, task: "chat", confidence: 0.9, source: "heuristic", reasons: ["test"], score: 0.5 },
124
+ forecast: {
125
+ slug,
126
+ expectedUsd: 0.001,
127
+ coldUsd: 0.002,
128
+ breakdown: { freshPrompt: 0.001, cacheRead: 0, cacheWrite: 0, completion: 0.001, reasoning: 0, images: 0, request: 0, total: 0.002, tierAtPromptTokens: 0 },
129
+ assumedPromptTokens: 100,
130
+ assumedCompletionTokens: 50,
131
+ assumedCacheHitRate: 0,
132
+ },
133
+ sessionId: "omp-conv-test",
134
+ sticky: false,
135
+ cacheBreakpointMessageIndices: [],
136
+ reasoning: undefined,
137
+ maxTokens: undefined,
138
+ stripAssistantReasoning: false,
139
+ probe: { enabled: true, maxTokens: 24, maxHoldMs: 5000, escalateTo: null, ...probe },
140
+ considered: [],
141
+ rejected: [],
142
+ reasons: ["test decision"],
143
+ budgetDowngraded: false,
144
+ };
145
+ }
146
+
147
+ function chunk(events: StreamEvent[]): UpstreamChunk {
148
+ return { raw: {}, events };
149
+ }
150
+
151
+ function startChunk(slug: string): UpstreamChunk {
152
+ return chunk([{ type: "start", servedSlug: slug, generationId: "gen-1" }]);
153
+ }
154
+
155
+ function textChunk(delta: string): UpstreamChunk {
156
+ return chunk([{ type: "text", delta }]);
157
+ }
158
+
159
+ function finishChunk(reason: FinishReason): UpstreamChunk {
160
+ return chunk([{ type: "finish", reason }]);
161
+ }
162
+
163
+ function usageChunk(usage: Partial<UsageCounts>, cost: number | null): UpstreamChunk {
164
+ return chunk([{ type: "usage", usage: { ...EMPTY_USAGE, ...usage }, reportedCostUsd: cost }]);
165
+ }
166
+
167
+ type FakePlan =
168
+ | { kind: "chunks"; chunks: UpstreamChunk[] }
169
+ | { kind: "fail"; error: UpstreamError }
170
+ | { kind: "die"; chunks: UpstreamChunk[]; error: UpstreamError };
171
+
172
+ function mkUpstream(plans: FakePlan[]): { upstream: UpstreamClient; calls: DispatchOptions[] } {
173
+ const calls: DispatchOptions[] = [];
174
+ let i = 0;
175
+ const upstream: UpstreamClient = {
176
+ dispatch: (opts) => {
177
+ calls.push(opts);
178
+ const plan = plans[Math.min(i, plans.length - 1)]!;
179
+ i++;
180
+ if (plan.kind === "fail") return Promise.reject(plan.error);
181
+ const error = plan.kind === "die" ? plan.error : null;
182
+ return Promise.resolve({
183
+ generationId: () => Promise.resolve<string | null>("gen-fake"),
184
+ chunks: (async function* (): AsyncGenerator<UpstreamChunk> {
185
+ for (const c of plan.chunks) yield c;
186
+ if (error) throw error;
187
+ })(),
188
+ });
189
+ },
190
+ complete: () => Promise.reject(new Error("not used by runTurn")),
191
+ fetchModels: () => Promise.resolve([]),
192
+ fetchModelsForUser: () => Promise.resolve([]),
193
+ };
194
+ return { upstream, calls };
195
+ }
196
+
197
+ function mkRouter(decisions: Decision[]): { router: Router; calls: { attempt: number; escalateFrom?: Tier }[] } {
198
+ const calls: { attempt: number; escalateFrom?: Tier }[] = [];
199
+ let i = 0;
200
+ const router: Router = {
201
+ route: (_req, opts) => {
202
+ calls.push(opts.escalateFrom !== undefined ? { attempt: opts.attempt, escalateFrom: opts.escalateFrom } : { attempt: opts.attempt });
203
+ const d = decisions[Math.min(i, decisions.length - 1)];
204
+ i++;
205
+ if (!d) return Promise.reject(new Error("no decision queued"));
206
+ return Promise.resolve(d);
207
+ },
208
+ };
209
+ return { router, calls };
210
+ }
211
+
212
+ function mkLedger(): { ledger: Ledger; entries: LedgerEntry[] } {
213
+ const entries: LedgerEntry[] = [];
214
+ const ledger: Ledger = {
215
+ record: (e) => {
216
+ entries.push(e);
217
+ },
218
+ conversationSpend: () => 0,
219
+ spendSince: () => 0,
220
+ blendedRate: () => null,
221
+ trust: () => null,
222
+ allTrust: () => [],
223
+ tokenRatio: () => null,
224
+ recentEntries: () => [],
225
+ };
226
+ return { ledger, entries };
227
+ }
228
+
229
+ function mkConversations(): { store: ConversationStore; map: Map<string, ConversationState> } {
230
+ const map = new Map<string, ConversationState>();
231
+ const store: ConversationStore = {
232
+ get: (k) => map.get(k) ?? null,
233
+ load: (k) => {
234
+ const existing = map.get(k);
235
+ if (existing) return existing;
236
+ const fresh: ConversationState = {
237
+ key: k,
238
+ sessionId: `omp-${k}`,
239
+ turn: 0,
240
+ currentSlug: null,
241
+ currentTier: null,
242
+ stickyUntilTurn: 0,
243
+ escalations: 0,
244
+ spentUsd: 0,
245
+ lastPromptTokens: 0,
246
+ cacheWarmSlug: null,
247
+ cacheWarmAtMs: 0,
248
+ updatedAtMs: 0,
249
+ };
250
+ map.set(k, fresh);
251
+ return fresh;
252
+ },
253
+ save: (s) => {
254
+ map.set(s.key, s);
255
+ },
256
+ prune: () => 0,
257
+ };
258
+ return { store, map };
259
+ }
260
+
261
+ function mkSink(): { sink: ResponseSink; chunks: UpstreamChunk[]; errors: WireError[]; finishes: TurnSummary[] } {
262
+ const chunks: UpstreamChunk[] = [];
263
+ const errors: WireError[] = [];
264
+ const finishes: TurnSummary[] = [];
265
+ const sink: ResponseSink = {
266
+ chunk: (c) => {
267
+ chunks.push(c);
268
+ },
269
+ error: (e) => {
270
+ errors.push(e);
271
+ },
272
+ finish: (s) => {
273
+ finishes.push(s);
274
+ },
275
+ };
276
+ return { sink, chunks, errors, finishes };
277
+ }
278
+
279
+ const catalog: CatalogSource = {
280
+ get: () => Promise.resolve({ models: [], fetchedAtMs: 0 }),
281
+ refresh: () => Promise.resolve({ models: [], fetchedAtMs: 0 }),
282
+ peek: () => null,
283
+ find: () => undefined,
284
+ };
285
+
286
+ function textOut(chunks: UpstreamChunk[]): string {
287
+ return chunks
288
+ .flatMap((c) => c.events)
289
+ .filter((e): e is Extract<StreamEvent, { type: "text" }> => e.type === "text")
290
+ .map((e) => e.delta)
291
+ .join("");
292
+ }
293
+
294
+ // ---------- tests ----------
295
+
296
+ describe("runTurn", () => {
297
+ test("a clean cheap-tier generation writes exactly one ledger entry, wasted: false", async () => {
298
+ const { router } = mkRouter([mkDecision("trivial", "cheap/model", { escalateTo: "simple" })]);
299
+ const { upstream } = mkUpstream([
300
+ {
301
+ kind: "chunks",
302
+ chunks: [
303
+ startChunk("cheap/model"),
304
+ textChunk("hi"),
305
+ finishChunk("stop"),
306
+ usageChunk({ promptTokens: 120, cachedTokens: 100, completionTokens: 4 }, 0.0004),
307
+ ],
308
+ },
309
+ ]);
310
+ const { ledger, entries } = mkLedger();
311
+ const { store, map } = mkConversations();
312
+ const { sink, chunks, errors, finishes } = mkSink();
313
+
314
+ await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog }, new AbortController().signal);
315
+
316
+ expect(errors).toHaveLength(0);
317
+ expect(finishes).toHaveLength(1);
318
+ expect(entries).toHaveLength(1);
319
+ const entry = entries[0]!;
320
+ expect(entry.wasted).toBe(false);
321
+ expect(entry.escalationSignal).toBeNull();
322
+ expect(entry.attempt).toBe(0);
323
+ expect(entry.slug).toBe("cheap/model");
324
+ expect(entry.servedSlug).toBe("cheap/model");
325
+ expect(entry.reportedUsd).toBe(0.0004);
326
+ expect(entry.usage.promptTokens).toBe(120);
327
+ expect(entry.finishReason).toBe("stop");
328
+ expect(entry.error).toBeNull();
329
+ expect(chunks).toHaveLength(4);
330
+ expect(finishes[0]!.escalated).toBe(false);
331
+ expect(finishes[0]!.attempts).toBe(1);
332
+
333
+ const state = map.get("conv-test")!;
334
+ expect(state.turn).toBe(1);
335
+ expect(state.currentSlug).toBe("cheap/model");
336
+ expect(state.currentTier).toBe("trivial");
337
+ expect(state.lastPromptTokens).toBe(120);
338
+ expect(state.spentUsd).toBeCloseTo(0.0004);
339
+ // cachedTokens > 0 is direct evidence of an upstream cache.
340
+ expect(state.cacheWarmSlug).toBe("cheap/model");
341
+ expect(state.cacheWarmAtMs).toBeGreaterThan(0);
342
+ });
343
+
344
+ test("an escalated turn writes two entries; the client sees only the second generation", async () => {
345
+ const { router, calls } = mkRouter([
346
+ mkDecision("trivial", "cheap/model", { escalateTo: "simple" }),
347
+ mkDecision("simple", "better/model", { escalateTo: "moderate" }),
348
+ ]);
349
+ const { upstream } = mkUpstream([
350
+ {
351
+ kind: "chunks",
352
+ chunks: [startChunk("cheap/model"), textChunk("I'm sorry, but I can't help with that request."), finishChunk("stop")],
353
+ },
354
+ {
355
+ kind: "chunks",
356
+ chunks: [startChunk("better/model"), textChunk("Here is the answer."), finishChunk("stop"), usageChunk({ promptTokens: 130, completionTokens: 6 }, 0.0009)],
357
+ },
358
+ ]);
359
+ const { ledger, entries } = mkLedger();
360
+ const { store } = mkConversations();
361
+ const { sink, chunks, errors, finishes } = mkSink();
362
+
363
+ await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog }, new AbortController().signal);
364
+
365
+ expect(errors).toHaveLength(0);
366
+ expect(entries).toHaveLength(2);
367
+ expect(entries[0]!.wasted).toBe(true);
368
+ expect(entries[0]!.escalationSignal).toBe("refusal");
369
+ expect(entries[0]!.slug).toBe("cheap/model");
370
+ expect(entries[0]!.attempt).toBe(0);
371
+ expect(entries[1]!.wasted).toBe(false);
372
+ expect(entries[1]!.attempt).toBe(1);
373
+ expect(entries[1]!.slug).toBe("better/model");
374
+
375
+ // The escalated attempt re-routed one tier up.
376
+ expect(calls).toHaveLength(2);
377
+ expect(calls[0]).toEqual({ attempt: 0 });
378
+ expect(calls[1]).toEqual({ attempt: 1, escalateFrom: "trivial" });
379
+
380
+ // The held refusal text never reached the client.
381
+ expect(textOut(chunks)).toBe("Here is the answer.");
382
+ expect(finishes).toHaveLength(1);
383
+ expect(finishes[0]!.escalated).toBe(true);
384
+ expect(finishes[0]!.attempts).toBe(2);
385
+ expect(finishes[0]!.servedSlug).toBe("better/model");
386
+ });
387
+
388
+ test("a committed stream is never retried, even when later chunks fail", async () => {
389
+ const { router } = mkRouter([mkDecision("trivial", "cheap/model", { maxTokens: 1 })]);
390
+ const { upstream, calls } = mkUpstream([
391
+ {
392
+ kind: "die",
393
+ chunks: [startChunk("cheap/model"), textChunk("lots of text here, plenty to commit on")],
394
+ error: new UpstreamError("rate_limit", 429, "slow down", true),
395
+ },
396
+ ]);
397
+ const { ledger, entries } = mkLedger();
398
+ const { store } = mkConversations();
399
+ const { sink, chunks, errors, finishes } = mkSink();
400
+
401
+ await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog }, new AbortController().signal);
402
+
403
+ // Bytes reached the client, so the 429 mid-stream is surfaced, not retried.
404
+ expect(calls).toHaveLength(1);
405
+ expect(entries).toHaveLength(1);
406
+ expect(entries[0]!.wasted).toBe(false);
407
+ expect(entries[0]!.error).toContain("rate_limit");
408
+ expect(textOut(chunks)).toBe("lots of text here, plenty to commit on");
409
+ expect(errors).toHaveLength(1);
410
+ expect(errors[0]!.code).toBe("rate_limit");
411
+ expect(finishes).toHaveLength(0);
412
+ });
413
+
414
+ test("a non-retryable upstream error before commit reaches sink.error", async () => {
415
+ const { router, calls } = mkRouter([mkDecision("trivial", "cheap/model", { escalateTo: "simple" })]);
416
+ const { upstream } = mkUpstream([{ kind: "fail", error: new UpstreamError("auth", 401, "invalid key", false) }]);
417
+ const { ledger, entries } = mkLedger();
418
+ const { store } = mkConversations();
419
+ const { sink, errors, finishes } = mkSink();
420
+
421
+ await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog }, new AbortController().signal);
422
+
423
+ expect(calls).toHaveLength(1); // no retry, no escalation on auth
424
+ expect(finishes).toHaveLength(0);
425
+ expect(errors).toHaveLength(1);
426
+ expect(errors[0]).toEqual({ status: 401, code: "auth", message: "invalid key" });
427
+ expect(entries).toHaveLength(1);
428
+ expect(entries[0]!.wasted).toBe(false);
429
+ expect(entries[0]!.error).toContain("auth");
430
+ });
431
+
432
+ test("a 429 before commit fails over to a different model in the same tier", async () => {
433
+ const { router, calls } = mkRouter([
434
+ mkDecision("trivial", "cheap/model", { escalateTo: "simple" }),
435
+ mkDecision("trivial", "spare/model", { escalateTo: "simple" }),
436
+ ]);
437
+ const { upstream } = mkUpstream([
438
+ { kind: "fail", error: new UpstreamError("rate_limit", 429, "slow down", true) },
439
+ { kind: "chunks", chunks: [startChunk("spare/model"), textChunk("done"), finishChunk("stop"), usageChunk({}, 0.001)] },
440
+ ]);
441
+ const { ledger, entries } = mkLedger();
442
+ const { store } = mkConversations();
443
+ const { sink, errors, finishes } = mkSink();
444
+
445
+ await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog }, new AbortController().signal);
446
+
447
+ expect(errors).toHaveLength(0);
448
+ expect(finishes).toHaveLength(1);
449
+ expect(entries).toHaveLength(2);
450
+ expect(entries[0]!.wasted).toBe(true);
451
+ expect(entries[0]!.error).toContain("rate_limit");
452
+ expect(entries[0]!.escalationSignal).toBeNull(); // same-tier failover, not an escalation
453
+ expect(entries[0]!.slug).toBe("cheap/model");
454
+ expect(entries[1]!.wasted).toBe(false);
455
+ expect(entries[1]!.slug).toBe("spare/model");
456
+ expect(entries[1]!.tier).toBe("trivial");
457
+
458
+ expect(calls).toHaveLength(2);
459
+ expect(calls[0]).toEqual({ attempt: 0 });
460
+ expect(calls[1]).toEqual({ attempt: 1 });
461
+ expect(finishes[0]!.escalated).toBe(false);
462
+ expect(finishes[0]!.attempts).toBe(2);
463
+ expect(finishes[0]!.servedSlug).toBe("spare/model");
464
+ });
465
+
466
+ test("a stable tier does not re-arm the hysteresis window (no permanent hard lock)", async () => {
467
+ // Regression: the sticky window was re-armed on EVERY committed turn, so
468
+ // once a conversation reached `hard` it stayed there forever — the
469
+ // classifier kept saying trivial but the window kept getting pushed out.
470
+ // A stable tier must NOT extend the window; only a tier change or an
471
+ // escalation re-arms it.
472
+ const { router } = mkRouter([
473
+ mkDecision("hard", "strong/model", { escalateTo: null }),
474
+ mkDecision("hard", "strong/model", { escalateTo: null }),
475
+ ]);
476
+ const { upstream } = mkUpstream([
477
+ { kind: "chunks", chunks: [startChunk("strong/model"), textChunk("a"), finishChunk("stop"), usageChunk({}, 0.001)] },
478
+ { kind: "chunks", chunks: [startChunk("strong/model"), textChunk("b"), finishChunk("stop"), usageChunk({}, 0.001)] },
479
+ ]);
480
+ const { ledger } = mkLedger();
481
+ const { store, map } = mkConversations();
482
+ const { sink, errors } = mkSink();
483
+
484
+ // Turn 1: first turn, no prior tier → re-arms (tierChanged true).
485
+ await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog }, new AbortController().signal);
486
+ const afterFirst = map.get("conv-test")!;
487
+ expect(afterFirst.currentTier).toBe("hard");
488
+ expect(afterFirst.stickyUntilTurn).toBe(1 + 2); // holdTurns=2
489
+
490
+ // Turn 2: same tier served again → must NOT re-arm. The window should
491
+ // stay at its previous expiry (turn 3), not extend to turn 4.
492
+ await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog }, new AbortController().signal);
493
+ const afterSecond = map.get("conv-test")!;
494
+ expect(afterSecond.currentTier).toBe("hard");
495
+ expect(afterSecond.stickyUntilTurn).toBe(3); // unchanged, not 4
496
+ expect(errors).toHaveLength(0);
497
+ });
498
+ });