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.
- package/.env.example +24 -0
- package/.github/workflows/publish.yml +40 -0
- package/.omp-plugin/marketplace.json +30 -0
- package/LICENSE +21 -0
- package/README.md +639 -0
- package/bun.lock +32 -0
- package/docs/claude-anthropic-wire.md +116 -0
- package/omp-extension/configure-logic.ts +128 -0
- package/omp-extension/embed-logic.ts +141 -0
- package/omp-extension/router-configure.ts +111 -0
- package/omp-extension/router-embed.ts +118 -0
- package/omp-extension/router-toast.ts +130 -0
- package/omp-extension/toast-logic.ts +136 -0
- package/package.json +56 -0
- package/src/catalog/openrouter-catalog.ts +428 -0
- package/src/catalog/types.ts +104 -0
- package/src/cli/args.ts +105 -0
- package/src/cli/config-cmd.ts +362 -0
- package/src/cli/config-wizard.ts +636 -0
- package/src/cli/explain.ts +167 -0
- package/src/cli/models.ts +240 -0
- package/src/cli/stats.ts +69 -0
- package/src/config/defaults.ts +136 -0
- package/src/config/load.ts +143 -0
- package/src/config/omp-credentials.ts +124 -0
- package/src/config/schema.ts +161 -0
- package/src/config/types.ts +244 -0
- package/src/cost/blended.ts +80 -0
- package/src/cost/forecast.ts +129 -0
- package/src/cost/ledger.ts +291 -0
- package/src/cost/types.ts +148 -0
- package/src/index.ts +93 -0
- package/src/router/cache-control.ts +66 -0
- package/src/router/candidates.ts +246 -0
- package/src/router/classify.ts +329 -0
- package/src/router/escalate.ts +264 -0
- package/src/router/features.ts +225 -0
- package/src/router/index.ts +99 -0
- package/src/router/select.ts +365 -0
- package/src/router/state.ts +118 -0
- package/src/router/tier-plan.ts +151 -0
- package/src/router/types.ts +222 -0
- package/src/server/http.ts +343 -0
- package/src/server/turn.ts +393 -0
- package/src/tokens/estimate.ts +74 -0
- package/src/upstream/openrouter.ts +221 -0
- package/src/upstream/sse-parse.ts +208 -0
- package/src/upstream/types.ts +75 -0
- package/src/util/hash.ts +0 -0
- package/src/util/log.ts +53 -0
- package/src/util/sqlite.ts +140 -0
- package/src/util/sse.ts +23 -0
- package/src/wire/openai/errors.ts +48 -0
- package/src/wire/openai/models.ts +37 -0
- package/src/wire/openai/request.ts +279 -0
- package/src/wire/openai/sink.ts +213 -0
- package/src/wire/types.ts +156 -0
- package/test/catalog.test.ts +319 -0
- package/test/classify.test.ts +269 -0
- package/test/config-wizard.test.ts +482 -0
- package/test/config.test.ts +121 -0
- package/test/configure-logic.test.ts +151 -0
- package/test/cost.test.ts +137 -0
- package/test/embed-logic.test.ts +107 -0
- package/test/escalate.test.ts +223 -0
- package/test/failover.test.ts +494 -0
- package/test/features.test.ts +228 -0
- package/test/fixtures/openrouter-models.json +15340 -0
- package/test/models-yml.test.ts +186 -0
- package/test/omp-credentials.test.ts +185 -0
- package/test/select.test.ts +538 -0
- package/test/sse-parse.test.ts +142 -0
- package/test/tier-plan.test.ts +302 -0
- package/test/toast-logic.test.ts +160 -0
- package/test/tokens.test.ts +160 -0
- package/test/trust-attribution.test.ts +175 -0
- package/test/turn.test.ts +498 -0
- package/test/wire-request.test.ts +297 -0
- package/test/wire-sink.test.ts +179 -0
- package/tools/install.ts +140 -0
- package/tools/mock-openrouter.ts +269 -0
- package/tools/smoke.ts +326 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,494 @@
|
|
|
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 (mirrors turn.test.ts; the router also records excludeSlugs) ----------
|
|
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
|
+
type RouteCall = { attempt: number; escalateFrom?: Tier; excludeSlugs?: readonly string[] };
|
|
198
|
+
|
|
199
|
+
function mkRouter(decisions: Decision[]): { router: Router; calls: RouteCall[] } {
|
|
200
|
+
const calls: RouteCall[] = [];
|
|
201
|
+
let i = 0;
|
|
202
|
+
const router: Router = {
|
|
203
|
+
route: (_req, opts) => {
|
|
204
|
+
const call: RouteCall = { attempt: opts.attempt };
|
|
205
|
+
if (opts.escalateFrom !== undefined) call.escalateFrom = opts.escalateFrom;
|
|
206
|
+
// Copy: runTurn passes its live failedSlugs array, which keeps growing.
|
|
207
|
+
if (opts.excludeSlugs !== undefined) call.excludeSlugs = [...opts.excludeSlugs];
|
|
208
|
+
calls.push(call);
|
|
209
|
+
const d = decisions[Math.min(i, decisions.length - 1)];
|
|
210
|
+
i++;
|
|
211
|
+
if (!d) return Promise.reject(new Error("no decision queued"));
|
|
212
|
+
return Promise.resolve(d);
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
return { router, calls };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function mkLedger(): { ledger: Ledger; entries: LedgerEntry[] } {
|
|
219
|
+
const entries: LedgerEntry[] = [];
|
|
220
|
+
const ledger: Ledger = {
|
|
221
|
+
record: (e) => {
|
|
222
|
+
entries.push(e);
|
|
223
|
+
},
|
|
224
|
+
conversationSpend: () => 0,
|
|
225
|
+
spendSince: () => 0,
|
|
226
|
+
blendedRate: () => null,
|
|
227
|
+
trust: () => null,
|
|
228
|
+
allTrust: () => [],
|
|
229
|
+
tokenRatio: () => null,
|
|
230
|
+
recentEntries: () => [],
|
|
231
|
+
};
|
|
232
|
+
return { ledger, entries };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function mkConversations(): { store: ConversationStore; map: Map<string, ConversationState> } {
|
|
236
|
+
const map = new Map<string, ConversationState>();
|
|
237
|
+
const store: ConversationStore = {
|
|
238
|
+
get: (k) => map.get(k) ?? null,
|
|
239
|
+
load: (k) => {
|
|
240
|
+
const existing = map.get(k);
|
|
241
|
+
if (existing) return existing;
|
|
242
|
+
const fresh: ConversationState = {
|
|
243
|
+
key: k,
|
|
244
|
+
sessionId: `omp-${k}`,
|
|
245
|
+
turn: 0,
|
|
246
|
+
currentSlug: null,
|
|
247
|
+
currentTier: null,
|
|
248
|
+
stickyUntilTurn: 0,
|
|
249
|
+
escalations: 0,
|
|
250
|
+
spentUsd: 0,
|
|
251
|
+
lastPromptTokens: 0,
|
|
252
|
+
cacheWarmSlug: null,
|
|
253
|
+
cacheWarmAtMs: 0,
|
|
254
|
+
updatedAtMs: 0,
|
|
255
|
+
};
|
|
256
|
+
map.set(k, fresh);
|
|
257
|
+
return fresh;
|
|
258
|
+
},
|
|
259
|
+
save: (s) => {
|
|
260
|
+
map.set(s.key, s);
|
|
261
|
+
},
|
|
262
|
+
prune: () => 0,
|
|
263
|
+
};
|
|
264
|
+
return { store, map };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function mkSink(): { sink: ResponseSink; chunks: UpstreamChunk[]; errors: WireError[]; finishes: TurnSummary[] } {
|
|
268
|
+
const chunks: UpstreamChunk[] = [];
|
|
269
|
+
const errors: WireError[] = [];
|
|
270
|
+
const finishes: TurnSummary[] = [];
|
|
271
|
+
const sink: ResponseSink = {
|
|
272
|
+
chunk: (c) => {
|
|
273
|
+
chunks.push(c);
|
|
274
|
+
},
|
|
275
|
+
error: (e) => {
|
|
276
|
+
errors.push(e);
|
|
277
|
+
},
|
|
278
|
+
finish: (s) => {
|
|
279
|
+
finishes.push(s);
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
return { sink, chunks, errors, finishes };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const catalog: CatalogSource = {
|
|
286
|
+
get: () => Promise.resolve({ models: [], fetchedAtMs: 0 }),
|
|
287
|
+
refresh: () => Promise.resolve({ models: [], fetchedAtMs: 0 }),
|
|
288
|
+
peek: () => null,
|
|
289
|
+
find: () => undefined,
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
function textOut(chunks: UpstreamChunk[]): string {
|
|
293
|
+
return chunks
|
|
294
|
+
.flatMap((c) => c.events)
|
|
295
|
+
.filter((e): e is Extract<StreamEvent, { type: "text" }> => e.type === "text")
|
|
296
|
+
.map((e) => e.delta)
|
|
297
|
+
.join("");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function okChunks(slug: string): UpstreamChunk[] {
|
|
301
|
+
return [startChunk(slug), textChunk("done"), finishChunk("stop"), usageChunk({ promptTokens: 100, completionTokens: 5 }, 0.001)];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---------- tests ----------
|
|
305
|
+
|
|
306
|
+
describe("same-tier failover", () => {
|
|
307
|
+
test("a retryable 404 on model A dispatches a DIFFERENT model B at the same tier", async () => {
|
|
308
|
+
const { router, calls } = mkRouter([mkDecision("moderate", "a/model"), mkDecision("moderate", "b/model")]);
|
|
309
|
+
const { upstream, calls: dispatches } = mkUpstream([
|
|
310
|
+
{ kind: "fail", error: new UpstreamError("model_unavailable", 404, "no endpoints found", true) },
|
|
311
|
+
{ kind: "chunks", chunks: okChunks("b/model") },
|
|
312
|
+
]);
|
|
313
|
+
const { ledger, entries } = mkLedger();
|
|
314
|
+
const { store } = mkConversations();
|
|
315
|
+
const { sink, errors, finishes } = mkSink();
|
|
316
|
+
|
|
317
|
+
await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog }, new AbortController().signal);
|
|
318
|
+
|
|
319
|
+
expect(errors).toHaveLength(0);
|
|
320
|
+
expect(finishes).toHaveLength(1);
|
|
321
|
+
|
|
322
|
+
// The retry re-routed with the failed slug excluded and dispatched B at
|
|
323
|
+
// the SAME tier — not A again, not a higher tier.
|
|
324
|
+
expect(calls).toHaveLength(2);
|
|
325
|
+
expect(calls[0]).toEqual({ attempt: 0 });
|
|
326
|
+
expect(calls[1]).toEqual({ attempt: 1, excludeSlugs: ["a/model"] });
|
|
327
|
+
expect(dispatches.map((d) => d.body.model)).toEqual(["a/model", "b/model"]);
|
|
328
|
+
|
|
329
|
+
expect(entries).toHaveLength(2);
|
|
330
|
+
expect(entries[0]!.slug).toBe("a/model");
|
|
331
|
+
expect(entries[0]!.wasted).toBe(true);
|
|
332
|
+
expect(entries[0]!.escalationSignal).toBeNull(); // failover, not escalation
|
|
333
|
+
expect(entries[0]!.error).toContain("model_unavailable");
|
|
334
|
+
expect(entries[1]!.slug).toBe("b/model");
|
|
335
|
+
expect(entries[1]!.tier).toBe("moderate");
|
|
336
|
+
expect(entries[1]!.wasted).toBe(false);
|
|
337
|
+
|
|
338
|
+
// The failover is visible on the surviving decision's reasons.
|
|
339
|
+
expect(entries[1]!.reasons).toContain("failover: a/model returned model_unavailable; retrying b/model in moderate");
|
|
340
|
+
|
|
341
|
+
expect(finishes[0]!.servedSlug).toBe("b/model");
|
|
342
|
+
expect(finishes[0]!.tier).toBe("moderate");
|
|
343
|
+
expect(finishes[0]!.escalated).toBe(false);
|
|
344
|
+
expect(finishes[0]!.attempts).toBe(2);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("a tier with no other eligible model falls back to tier escalation", async () => {
|
|
348
|
+
// The router widens to "simple" when "trivial" excludes a/model: the
|
|
349
|
+
// failover probe's decision is discarded and the normal escalation path
|
|
350
|
+
// (escalateFrom + upstream_error signal) runs instead.
|
|
351
|
+
const { router, calls } = mkRouter([
|
|
352
|
+
mkDecision("trivial", "a/model"),
|
|
353
|
+
mkDecision("simple", "b/model"),
|
|
354
|
+
mkDecision("simple", "b/model"),
|
|
355
|
+
]);
|
|
356
|
+
const { upstream, calls: dispatches } = mkUpstream([
|
|
357
|
+
{ kind: "fail", error: new UpstreamError("model_unavailable", 404, "no endpoints found", true) },
|
|
358
|
+
{ kind: "chunks", chunks: okChunks("b/model") },
|
|
359
|
+
]);
|
|
360
|
+
const { ledger, entries } = mkLedger();
|
|
361
|
+
const { store } = mkConversations();
|
|
362
|
+
const { sink, errors, finishes } = mkSink();
|
|
363
|
+
|
|
364
|
+
await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog }, new AbortController().signal);
|
|
365
|
+
|
|
366
|
+
expect(errors).toHaveLength(0);
|
|
367
|
+
expect(finishes).toHaveLength(1);
|
|
368
|
+
|
|
369
|
+
expect(calls).toHaveLength(3);
|
|
370
|
+
expect(calls[0]).toEqual({ attempt: 0 });
|
|
371
|
+
expect(calls[1]).toEqual({ attempt: 1, excludeSlugs: ["a/model"] }); // failover probe, rejected (wrong tier)
|
|
372
|
+
expect(calls[2]).toEqual({ attempt: 1, escalateFrom: "trivial", excludeSlugs: ["a/model"] }); // real escalation
|
|
373
|
+
|
|
374
|
+
// a/model is never re-dispatched; the turn escalated to simple.
|
|
375
|
+
expect(dispatches.map((d) => d.body.model)).toEqual(["a/model", "b/model"]);
|
|
376
|
+
|
|
377
|
+
expect(entries).toHaveLength(2);
|
|
378
|
+
expect(entries[0]!.wasted).toBe(true);
|
|
379
|
+
expect(entries[0]!.escalationSignal).toBe("upstream_error");
|
|
380
|
+
expect(entries[1]!.slug).toBe("b/model");
|
|
381
|
+
expect(entries[1]!.tier).toBe("simple");
|
|
382
|
+
expect(entries[1]!.wasted).toBe(false);
|
|
383
|
+
expect(entries[1]!.reasons.some((r) => r.startsWith("failover:"))).toBe(false);
|
|
384
|
+
|
|
385
|
+
expect(finishes[0]!.escalated).toBe(true);
|
|
386
|
+
expect(finishes[0]!.servedSlug).toBe("b/model");
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("a non-retryable error fails fast with no failover and no re-route", async () => {
|
|
390
|
+
const { router, calls } = mkRouter([mkDecision("trivial", "a/model")]);
|
|
391
|
+
const { upstream, calls: dispatches } = mkUpstream([
|
|
392
|
+
{ kind: "fail", error: new UpstreamError("auth", 401, "invalid key", false) },
|
|
393
|
+
]);
|
|
394
|
+
const { ledger, entries } = mkLedger();
|
|
395
|
+
const { store } = mkConversations();
|
|
396
|
+
const { sink, errors, finishes } = mkSink();
|
|
397
|
+
|
|
398
|
+
await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog }, new AbortController().signal);
|
|
399
|
+
|
|
400
|
+
expect(calls).toHaveLength(1);
|
|
401
|
+
expect(calls[0]).toEqual({ attempt: 0 }); // never re-routed, never given excludeSlugs
|
|
402
|
+
expect(dispatches).toHaveLength(1);
|
|
403
|
+
expect(finishes).toHaveLength(0);
|
|
404
|
+
expect(errors).toHaveLength(1);
|
|
405
|
+
expect(errors[0]).toEqual({ status: 401, code: "auth", message: "invalid key" });
|
|
406
|
+
expect(entries).toHaveLength(1);
|
|
407
|
+
expect(entries[0]!.wasted).toBe(false);
|
|
408
|
+
expect(entries[0]!.error).toContain("auth");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("same-tier failover is bounded; the next retryable failure escalates", async () => {
|
|
412
|
+
const { router, calls } = mkRouter([
|
|
413
|
+
mkDecision("trivial", "a/model"),
|
|
414
|
+
mkDecision("trivial", "b/model"),
|
|
415
|
+
mkDecision("trivial", "c/model"),
|
|
416
|
+
mkDecision("simple", "d/model"),
|
|
417
|
+
]);
|
|
418
|
+
const { upstream, calls: dispatches } = mkUpstream([
|
|
419
|
+
{ kind: "fail", error: new UpstreamError("rate_limit", 429, "slow down", true) },
|
|
420
|
+
{ kind: "fail", error: new UpstreamError("rate_limit", 429, "slow down", true) },
|
|
421
|
+
{ kind: "fail", error: new UpstreamError("rate_limit", 429, "slow down", true) },
|
|
422
|
+
{ kind: "chunks", chunks: okChunks("d/model") },
|
|
423
|
+
]);
|
|
424
|
+
const { ledger, entries } = mkLedger();
|
|
425
|
+
const { store } = mkConversations();
|
|
426
|
+
const { sink, errors, finishes } = mkSink();
|
|
427
|
+
|
|
428
|
+
await runTurn(
|
|
429
|
+
mkReq(),
|
|
430
|
+
sink,
|
|
431
|
+
{ config: mkConfig({ maxAttempts: 5 }), router, upstream, ledger, conversations: store, catalog },
|
|
432
|
+
new AbortController().signal,
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
expect(errors).toHaveLength(0);
|
|
436
|
+
expect(finishes).toHaveLength(1);
|
|
437
|
+
|
|
438
|
+
// Two same-tier failovers (A→B, B→C), then the bound bites and the
|
|
439
|
+
// third failure escalates to simple instead of spinning further.
|
|
440
|
+
expect(calls).toHaveLength(4);
|
|
441
|
+
expect(calls[0]).toEqual({ attempt: 0 });
|
|
442
|
+
expect(calls[1]).toEqual({ attempt: 1, excludeSlugs: ["a/model"] });
|
|
443
|
+
expect(calls[2]).toEqual({ attempt: 2, excludeSlugs: ["a/model", "b/model"] });
|
|
444
|
+
expect(calls[3]).toEqual({ attempt: 3, escalateFrom: "trivial", excludeSlugs: ["a/model", "b/model", "c/model"] });
|
|
445
|
+
|
|
446
|
+
expect(dispatches.map((d) => d.body.model)).toEqual(["a/model", "b/model", "c/model", "d/model"]);
|
|
447
|
+
|
|
448
|
+
expect(entries).toHaveLength(4);
|
|
449
|
+
expect(entries[0]!.escalationSignal).toBeNull();
|
|
450
|
+
expect(entries[1]!.escalationSignal).toBeNull();
|
|
451
|
+
expect(entries[1]!.reasons).toContain("failover: a/model returned rate_limit; retrying b/model in trivial");
|
|
452
|
+
expect(entries[2]!.escalationSignal).toBe("upstream_error"); // bound hit: escalate
|
|
453
|
+
expect(entries[2]!.reasons).toContain("failover: b/model returned rate_limit; retrying c/model in trivial");
|
|
454
|
+
expect(entries[3]!.slug).toBe("d/model");
|
|
455
|
+
expect(entries[3]!.tier).toBe("simple");
|
|
456
|
+
expect(entries[3]!.wasted).toBe(false);
|
|
457
|
+
|
|
458
|
+
expect(finishes[0]!.escalated).toBe(true);
|
|
459
|
+
expect(finishes[0]!.attempts).toBe(4);
|
|
460
|
+
expect(finishes[0]!.servedSlug).toBe("d/model");
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("a mid-stream retryable error before commit fails over the same way", async () => {
|
|
464
|
+
const { router, calls } = mkRouter([mkDecision("simple", "a/model"), mkDecision("simple", "b/model")]);
|
|
465
|
+
const { upstream, calls: dispatches } = mkUpstream([
|
|
466
|
+
{
|
|
467
|
+
kind: "die",
|
|
468
|
+
chunks: [startChunk("a/model")],
|
|
469
|
+
error: new UpstreamError("upstream_error", 500, "provider crashed", true),
|
|
470
|
+
},
|
|
471
|
+
{ kind: "chunks", chunks: okChunks("b/model") },
|
|
472
|
+
]);
|
|
473
|
+
const { ledger, entries } = mkLedger();
|
|
474
|
+
const { store } = mkConversations();
|
|
475
|
+
const { sink, chunks, errors, finishes } = mkSink();
|
|
476
|
+
|
|
477
|
+
await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog }, new AbortController().signal);
|
|
478
|
+
|
|
479
|
+
expect(errors).toHaveLength(0);
|
|
480
|
+
expect(calls).toHaveLength(2);
|
|
481
|
+
expect(calls[1]).toEqual({ attempt: 1, excludeSlugs: ["a/model"] });
|
|
482
|
+
expect(dispatches.map((d) => d.body.model)).toEqual(["a/model", "b/model"]);
|
|
483
|
+
|
|
484
|
+
expect(entries).toHaveLength(2);
|
|
485
|
+
expect(entries[0]!.wasted).toBe(true);
|
|
486
|
+
expect(entries[0]!.escalationSignal).toBeNull();
|
|
487
|
+
expect(entries[0]!.error).toContain("upstream_error");
|
|
488
|
+
|
|
489
|
+
// Nothing from the failed generation reached the client.
|
|
490
|
+
expect(textOut(chunks)).toBe("done");
|
|
491
|
+
expect(finishes).toHaveLength(1);
|
|
492
|
+
expect(finishes[0]!.servedSlug).toBe("b/model");
|
|
493
|
+
});
|
|
494
|
+
});
|