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,538 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { normalizeCatalogModel } from "../src/catalog/openrouter-catalog.ts";
|
|
4
|
+
import type { CatalogModel, CatalogSnapshot } from "../src/catalog/types.ts";
|
|
5
|
+
import { loadConfig } from "../src/config/load.ts";
|
|
6
|
+
import type { ProfileConfig, RouterConfig } from "../src/config/types.ts";
|
|
7
|
+
import type { Ledger } from "../src/cost/types.ts";
|
|
8
|
+
import { extractFeatures } from "../src/router/features.ts";
|
|
9
|
+
import { scoreHeuristic } from "../src/router/classify.ts";
|
|
10
|
+
import { BudgetExceededError, select } from "../src/router/select.ts";
|
|
11
|
+
import type { ConversationState, Tier } from "../src/router/types.ts";
|
|
12
|
+
import { parseChatRequest } from "../src/wire/openai/request.ts";
|
|
13
|
+
import type { NormRequest } from "../src/wire/types.ts";
|
|
14
|
+
|
|
15
|
+
const FIXTURE = (await Bun.file("test/fixtures/openrouter-models.json").json()) as { data: unknown[] };
|
|
16
|
+
const MODELS: CatalogModel[] = FIXTURE.data.map(normalizeCatalogModel).filter((m): m is CatalogModel => m !== null);
|
|
17
|
+
const SNAPSHOT: CatalogSnapshot = { models: MODELS, fetchedAtMs: Date.now() };
|
|
18
|
+
|
|
19
|
+
const BASE = loadConfig({});
|
|
20
|
+
const PROFILE: ProfileConfig = {
|
|
21
|
+
id: "auto",
|
|
22
|
+
name: "Auto",
|
|
23
|
+
minTier: "trivial",
|
|
24
|
+
maxTier: "hard",
|
|
25
|
+
contextWindow: 400_000,
|
|
26
|
+
maxTokens: 32_000,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const TOOLS = [
|
|
30
|
+
{
|
|
31
|
+
type: "function",
|
|
32
|
+
function: {
|
|
33
|
+
name: "read",
|
|
34
|
+
description: "Read a file",
|
|
35
|
+
parameters: { type: "object", properties: { path: { type: "string" } } },
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
function request(userText: string): NormRequest {
|
|
41
|
+
return parseChatRequest(
|
|
42
|
+
{
|
|
43
|
+
model: "auto",
|
|
44
|
+
tools: TOOLS,
|
|
45
|
+
messages: [
|
|
46
|
+
{ role: "system", content: "You are a coding agent." },
|
|
47
|
+
{ role: "user", content: userText },
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
new Headers(),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function state(over: Partial<ConversationState> = {}): ConversationState {
|
|
55
|
+
return {
|
|
56
|
+
key: "abc123",
|
|
57
|
+
sessionId: "omp-abc123",
|
|
58
|
+
turn: 1,
|
|
59
|
+
currentSlug: null,
|
|
60
|
+
currentTier: null,
|
|
61
|
+
stickyUntilTurn: 0,
|
|
62
|
+
escalations: 0,
|
|
63
|
+
spentUsd: 0,
|
|
64
|
+
lastPromptTokens: 0,
|
|
65
|
+
cacheWarmSlug: null,
|
|
66
|
+
cacheWarmAtMs: 0,
|
|
67
|
+
updatedAtMs: Date.now(),
|
|
68
|
+
...over,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function run(opts: {
|
|
73
|
+
userText?: string;
|
|
74
|
+
promptTokens?: number;
|
|
75
|
+
cfg?: RouterConfig;
|
|
76
|
+
st?: ConversationState;
|
|
77
|
+
tier?: Tier;
|
|
78
|
+
ledger?: Ledger | null;
|
|
79
|
+
harnessId?: string;
|
|
80
|
+
}) {
|
|
81
|
+
const cfg = opts.cfg ?? BASE;
|
|
82
|
+
const req = request(opts.userText ?? "tidy the retry helper");
|
|
83
|
+
const features = extractFeatures(req, opts.promptTokens ?? 4000);
|
|
84
|
+
const heuristic = scoreHeuristic(features, cfg);
|
|
85
|
+
const classification = opts.tier === undefined ? heuristic : { ...heuristic, tier: opts.tier };
|
|
86
|
+
return select({
|
|
87
|
+
req: opts.harnessId === undefined ? req : { ...req, harnessId: opts.harnessId },
|
|
88
|
+
features,
|
|
89
|
+
classification,
|
|
90
|
+
profile: PROFILE,
|
|
91
|
+
state: opts.st ?? state(),
|
|
92
|
+
snapshot: SNAPSHOT,
|
|
93
|
+
ledger: opts.ledger === undefined ? null : opts.ledger,
|
|
94
|
+
cfg,
|
|
95
|
+
nowMs: Date.now(),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe("hard exclusions", () => {
|
|
100
|
+
test("never selects a meta-router, floating alias, batch endpoint, or cloaked model", () => {
|
|
101
|
+
for (const tier of ["trivial", "simple", "moderate", "hard"] as Tier[]) {
|
|
102
|
+
const d = run({ tier });
|
|
103
|
+
expect(d.slug.startsWith("openrouter/")).toBe(false);
|
|
104
|
+
expect(d.slug.startsWith("~")).toBe(false);
|
|
105
|
+
expect(d.slug.endsWith(":batch")).toBe(false);
|
|
106
|
+
expect(d.slug.startsWith("stealth/")).toBe(false);
|
|
107
|
+
for (const f of d.fallbacks) {
|
|
108
|
+
expect(f.startsWith("openrouter/")).toBe(false);
|
|
109
|
+
expect(f.endsWith(":batch")).toBe(false);
|
|
110
|
+
expect(f.startsWith("~")).toBe(false);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("only offers tool-capable models when the request offers tools", () => {
|
|
116
|
+
for (const tier of ["trivial", "simple", "moderate", "hard"] as Tier[]) {
|
|
117
|
+
const d = run({ tier });
|
|
118
|
+
for (const c of d.considered) expect(c.model.supportsTools).toBe(true);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("excludes free models by default", () => {
|
|
123
|
+
const d = run({ tier: "trivial" });
|
|
124
|
+
for (const c of d.considered) expect(c.model.isFree).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("quality floor", () => {
|
|
129
|
+
test("an unscored model never satisfies a tier with a floor above zero", () => {
|
|
130
|
+
for (const tier of ["simple", "moderate", "hard"] as Tier[]) {
|
|
131
|
+
const d = run({ tier });
|
|
132
|
+
for (const c of d.considered) {
|
|
133
|
+
const q = c.model.quality;
|
|
134
|
+
const unscored = q.coding === undefined && q.agentic === undefined && q.intelligence === undefined;
|
|
135
|
+
expect(unscored).toBe(false);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("unscored models are eligible in the trivial tier, whose floor is zero", () => {
|
|
141
|
+
const d = run({ tier: "trivial" });
|
|
142
|
+
expect(BASE.tiers.trivial.minQuality).toBe(0);
|
|
143
|
+
expect(d.considered.length).toBeGreaterThan(0);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("a higher tier selects a higher-quality model than a lower tier", () => {
|
|
147
|
+
const cheap = run({ tier: "trivial" });
|
|
148
|
+
const dear = run({ tier: "hard" });
|
|
149
|
+
const cheapModel = MODELS.find((m) => m.slug === cheap.slug);
|
|
150
|
+
const dearModel = MODELS.find((m) => m.slug === dear.slug);
|
|
151
|
+
expect(cheapModel).toBeDefined();
|
|
152
|
+
expect(dearModel).toBeDefined();
|
|
153
|
+
expect(dear.forecast.expectedUsd).toBeGreaterThan(cheap.forecast.expectedUsd);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("context window", () => {
|
|
158
|
+
test("rejects models whose context cannot hold the prompt", () => {
|
|
159
|
+
// Far larger than the small-context models in the catalog can take.
|
|
160
|
+
const d = run({ tier: "trivial", promptTokens: 300_000 });
|
|
161
|
+
expect(d.rejected.some((r) => r.reason === "context_too_small")).toBe(true);
|
|
162
|
+
const chosen = MODELS.find((m) => m.slug === d.slug);
|
|
163
|
+
expect(chosen).toBeDefined();
|
|
164
|
+
expect(chosen?.contextLength ?? 0).toBeGreaterThan(300_000);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("applies headroom so a token-estimate error cannot overflow the window", () => {
|
|
168
|
+
const d = run({ tier: "trivial", promptTokens: 100_000 });
|
|
169
|
+
const chosen = MODELS.find((m) => m.slug === d.slug);
|
|
170
|
+
expect(chosen?.contextLength ?? 0).toBeGreaterThanOrEqual(100_000 * BASE.filters.contextHeadroom);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("cache-aware switching", () => {
|
|
175
|
+
// Deliberately NOT the top-ranked hard candidate: staying must be a real
|
|
176
|
+
// choice against a better option, or the switch logic is never exercised.
|
|
177
|
+
const warmSlug = "x-ai/grok-4.6";
|
|
178
|
+
|
|
179
|
+
test("keeps the warm model when switching does not clear the margin", () => {
|
|
180
|
+
const cfg: RouterConfig = { ...BASE, hysteresis: { ...BASE.hysteresis, switchMargin: 1e6 } };
|
|
181
|
+
const d = run({
|
|
182
|
+
tier: "hard",
|
|
183
|
+
promptTokens: 80_000,
|
|
184
|
+
cfg,
|
|
185
|
+
st: state({
|
|
186
|
+
currentSlug: warmSlug,
|
|
187
|
+
currentTier: "hard",
|
|
188
|
+
cacheWarmSlug: warmSlug,
|
|
189
|
+
cacheWarmAtMs: Date.now(),
|
|
190
|
+
lastPromptTokens: 80_000,
|
|
191
|
+
}),
|
|
192
|
+
});
|
|
193
|
+
expect(d.slug).toBe(warmSlug);
|
|
194
|
+
expect(d.sticky).toBe(true);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("abandons a warm cache whose TTL has expired", () => {
|
|
198
|
+
const d = run({
|
|
199
|
+
tier: "hard",
|
|
200
|
+
promptTokens: 80_000,
|
|
201
|
+
st: state({
|
|
202
|
+
currentSlug: warmSlug,
|
|
203
|
+
currentTier: "hard",
|
|
204
|
+
cacheWarmSlug: warmSlug,
|
|
205
|
+
// Long past the sticky-session window, so there is no cache left to keep.
|
|
206
|
+
cacheWarmAtMs: Date.now() - BASE.hysteresis.cacheWarmTtlMs * 10,
|
|
207
|
+
lastPromptTokens: 80_000,
|
|
208
|
+
}),
|
|
209
|
+
});
|
|
210
|
+
expect(d.sticky).toBe(false);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("budget guard", () => {
|
|
215
|
+
test("downgrades when the cold forecast breaches the per-turn cap", () => {
|
|
216
|
+
// A hard-tier turn at this size forecasts ~$0.02 cold, while cheaper
|
|
217
|
+
// tiers land well under a cent, so a $0.005 cap is breachable AND
|
|
218
|
+
// satisfiable further down.
|
|
219
|
+
const cfg: RouterConfig = {
|
|
220
|
+
...BASE,
|
|
221
|
+
budget: { ...BASE.budget, perTurnUsd: 0.005, onExceeded: "downgrade" },
|
|
222
|
+
};
|
|
223
|
+
const d = run({ tier: "hard", promptTokens: 50_000, cfg });
|
|
224
|
+
expect(d.budgetDowngraded).toBe(true);
|
|
225
|
+
expect(d.forecast.coldUsd).toBeLessThanOrEqual(0.005);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("throws in downgrade mode when no candidate at any tier fits", () => {
|
|
229
|
+
// Failing loudly beats silently spending past an impossible cap.
|
|
230
|
+
const cfg: RouterConfig = {
|
|
231
|
+
...BASE,
|
|
232
|
+
budget: { ...BASE.budget, perTurnUsd: 1e-9, onExceeded: "downgrade" },
|
|
233
|
+
};
|
|
234
|
+
expect(() => run({ tier: "hard", promptTokens: 50_000, cfg })).toThrow(BudgetExceededError);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("rejects outright when configured to", () => {
|
|
238
|
+
const cfg: RouterConfig = {
|
|
239
|
+
...BASE,
|
|
240
|
+
budget: { ...BASE.budget, perTurnUsd: 1e-9, onExceeded: "reject" },
|
|
241
|
+
};
|
|
242
|
+
expect(() => run({ tier: "hard", promptTokens: 50_000, cfg })).toThrow(BudgetExceededError);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("a satisfiable budget does not downgrade", () => {
|
|
246
|
+
const cfg: RouterConfig = { ...BASE, budget: { ...BASE.budget, perTurnUsd: 100, onExceeded: "reject" } };
|
|
247
|
+
const d = run({ tier: "moderate", promptTokens: 5000, cfg });
|
|
248
|
+
expect(d.budgetDowngraded).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("scopes the daily budget to the requesting harness", () => {
|
|
252
|
+
// Harness A has already spent the whole daily cap; harness B has spent
|
|
253
|
+
// nothing. A request from B must NOT be budget-blocked by A's spend.
|
|
254
|
+
const spendByHarness: Record<string, number> = { "harness-a": 1.0 };
|
|
255
|
+
const ledger: Ledger = {
|
|
256
|
+
record: () => {},
|
|
257
|
+
conversationSpend: () => 0,
|
|
258
|
+
spendSince: (_sinceMs, harnessId) => (harnessId === undefined ? 1.0 : spendByHarness[harnessId] ?? 0),
|
|
259
|
+
blendedRate: () => null,
|
|
260
|
+
trust: () => null,
|
|
261
|
+
allTrust: () => [],
|
|
262
|
+
tokenRatio: () => null,
|
|
263
|
+
recentEntries: () => [],
|
|
264
|
+
};
|
|
265
|
+
const cfg: RouterConfig = { ...BASE, budget: { ...BASE.budget, perDayUsd: 0.5, onExceeded: "reject" } };
|
|
266
|
+
|
|
267
|
+
// Harness A is over its daily cap → rejected.
|
|
268
|
+
expect(() => run({ tier: "hard", promptTokens: 50_000, cfg, ledger, harnessId: "harness-a" })).toThrow(
|
|
269
|
+
BudgetExceededError,
|
|
270
|
+
);
|
|
271
|
+
// Harness B has spent nothing → not blocked by A's spend.
|
|
272
|
+
const d = run({ tier: "hard", promptTokens: 50_000, cfg, ledger, harnessId: "harness-b" });
|
|
273
|
+
expect(d.budgetDowngraded).toBe(false);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe("per-harness trust scoping", () => {
|
|
278
|
+
// When filters.trustScopedByHarness is on, trust is read from the requesting
|
|
279
|
+
// harness's own ledger rows, so one harness's flaky-model demotion does not
|
|
280
|
+
// leak into another's routing. Off (default), trust is shared.
|
|
281
|
+
const untrustedLedger = (): Ledger => ({
|
|
282
|
+
record: () => {},
|
|
283
|
+
conversationSpend: () => 0,
|
|
284
|
+
spendSince: () => 0,
|
|
285
|
+
blendedRate: () => null,
|
|
286
|
+
trust: (_slug, harnessId) => {
|
|
287
|
+
// Harness A has burned the model; harness B has never tried it.
|
|
288
|
+
if (harnessId === "harness-a") {
|
|
289
|
+
return { slug: "x", attempts: 40, escalations: 30, errors: 30, successRate: 0.1, meanCostError: 0.2 };
|
|
290
|
+
}
|
|
291
|
+
return null; // harness B / shared → unmeasured
|
|
292
|
+
},
|
|
293
|
+
allTrust: () => [],
|
|
294
|
+
tokenRatio: () => null,
|
|
295
|
+
recentEntries: () => [],
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("scoped trust passes the harness id into the ledger trust query", () => {
|
|
299
|
+
// The feature's contract is that the router's trust lookup is scoped to
|
|
300
|
+
// the requesting harness when enabled. Assert the wiring directly rather
|
|
301
|
+
// than via a post-rescue `rejected` reason, which tier-rescue relaxes.
|
|
302
|
+
let queriedWith: string | undefined;
|
|
303
|
+
const ledger: Ledger = {
|
|
304
|
+
...untrustedLedger(),
|
|
305
|
+
trust: (_slug, harnessId) => {
|
|
306
|
+
queriedWith = harnessId;
|
|
307
|
+
return null;
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
const cfg: RouterConfig = {
|
|
311
|
+
...BASE,
|
|
312
|
+
filters: { ...BASE.filters, trustScopedByHarness: true },
|
|
313
|
+
};
|
|
314
|
+
run({ tier: "simple", cfg, ledger, harnessId: "harness-a" });
|
|
315
|
+
expect(queriedWith).toBe("harness-a");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("shared trust (default) reads the whole ledger, not per-harness", () => {
|
|
319
|
+
// With scoping off, the trust lookup must NOT carry the harness id, so
|
|
320
|
+
// harness A's flaky history is visible globally (shared reliability).
|
|
321
|
+
let queriedWith: string | undefined;
|
|
322
|
+
const ledger: Ledger = {
|
|
323
|
+
...untrustedLedger(),
|
|
324
|
+
trust: (_slug, harnessId) => {
|
|
325
|
+
queriedWith = harnessId;
|
|
326
|
+
return null;
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
const cfg: RouterConfig = {
|
|
330
|
+
...BASE,
|
|
331
|
+
filters: { ...BASE.filters, trustScopedByHarness: false },
|
|
332
|
+
};
|
|
333
|
+
run({ tier: "simple", cfg, ledger, harnessId: "harness-a" });
|
|
334
|
+
// The trust lookup must NOT carry the harness id when scoping is off.
|
|
335
|
+
expect(queriedWith).toBeUndefined();
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
describe("decision shape", () => {
|
|
340
|
+
test("clamps max tokens to the chosen model's published ceiling", () => {
|
|
341
|
+
const d = run({ tier: "moderate" });
|
|
342
|
+
const chosen = MODELS.find((m) => m.slug === d.slug);
|
|
343
|
+
const ceiling = chosen?.maxCompletionTokens;
|
|
344
|
+
if (ceiling !== undefined && d.maxTokens !== undefined) {
|
|
345
|
+
expect(d.maxTokens).toBeLessThanOrEqual(ceiling);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("plans a probe for cheap tiers and leaves the top tier unprobed", () => {
|
|
350
|
+
expect(run({ tier: "trivial" }).probe.enabled).toBe(true);
|
|
351
|
+
// Nothing above `hard` to escalate into, so probing it would only add latency.
|
|
352
|
+
expect(run({ tier: "hard" }).probe.enabled).toBe(false);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("carries the session id, features, and a reasoning trail", () => {
|
|
356
|
+
const d = run({ tier: "simple" });
|
|
357
|
+
expect(d.sessionId.startsWith("omp-")).toBe(true);
|
|
358
|
+
expect(d.reasons.length).toBeGreaterThan(0);
|
|
359
|
+
expect(d.features.toolCount).toBe(1);
|
|
360
|
+
expect(d.considered.length).toBeGreaterThan(0);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("respects a profile that caps the tier", () => {
|
|
364
|
+
const req = request("redesign the whole architecture and explain the race condition root cause");
|
|
365
|
+
const features = extractFeatures(req, 4000);
|
|
366
|
+
const d = select({
|
|
367
|
+
req,
|
|
368
|
+
features,
|
|
369
|
+
classification: scoreHeuristic(features, BASE),
|
|
370
|
+
profile: { ...PROFILE, id: "auto-cheap", maxTier: "simple" },
|
|
371
|
+
state: state(),
|
|
372
|
+
snapshot: SNAPSHOT,
|
|
373
|
+
ledger: null,
|
|
374
|
+
cfg: BASE,
|
|
375
|
+
nowMs: Date.now(),
|
|
376
|
+
});
|
|
377
|
+
expect(["trivial", "simple"]).toContain(d.tier);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
describe("tier rescue under a guardrail-constrained catalog", () => {
|
|
382
|
+
// A tiny catalog containing only models that all fail the strict `trivial`
|
|
383
|
+
// tier config: they exceed its price ceiling or fail its trust/quality bar.
|
|
384
|
+
// Under the full catalog the cheap alternatives masked this; a guardrail
|
|
385
|
+
// can remove them entirely.
|
|
386
|
+
const pick = (slug: string): CatalogModel => {
|
|
387
|
+
const m = MODELS.find((x) => x.slug === slug);
|
|
388
|
+
if (m === undefined) throw new Error(`fixture missing ${slug}`);
|
|
389
|
+
return m;
|
|
390
|
+
};
|
|
391
|
+
const constrained: CatalogSnapshot = {
|
|
392
|
+
models: [pick("z-ai/glm-5.3"), pick("qwen/qwen3.8-2.4t-a95b"), pick("x-ai/grok-4.6")],
|
|
393
|
+
fetchedAtMs: Date.now(),
|
|
394
|
+
keyScoped: true,
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
function runConstrained(ledger: Ledger | null = null) {
|
|
398
|
+
const req = request("refactor the service layer and explain the cache coherence contract");
|
|
399
|
+
const features = extractFeatures(req, 4000);
|
|
400
|
+
const heuristic = scoreHeuristic(features, BASE);
|
|
401
|
+
return select({
|
|
402
|
+
req,
|
|
403
|
+
features,
|
|
404
|
+
classification: { ...heuristic, tier: "trivial" as Tier },
|
|
405
|
+
profile: PROFILE,
|
|
406
|
+
state: state(),
|
|
407
|
+
snapshot: constrained,
|
|
408
|
+
ledger,
|
|
409
|
+
cfg: BASE,
|
|
410
|
+
nowMs: Date.now(),
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Every model is probed-and-failed: below the trust floor at every tier. */
|
|
415
|
+
function untrustedLedger(): Ledger {
|
|
416
|
+
return {
|
|
417
|
+
record: () => {},
|
|
418
|
+
conversationSpend: () => 0,
|
|
419
|
+
spendSince: () => 0,
|
|
420
|
+
blendedRate: () => null,
|
|
421
|
+
trust: (slug) => ({
|
|
422
|
+
slug,
|
|
423
|
+
attempts: 40,
|
|
424
|
+
escalations: 30,
|
|
425
|
+
errors: 30,
|
|
426
|
+
successRate: 0.1,
|
|
427
|
+
meanCostError: 0.2,
|
|
428
|
+
}),
|
|
429
|
+
allTrust: () => [],
|
|
430
|
+
tokenRatio: () => null,
|
|
431
|
+
recentEntries: () => [],
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
test("rescues a model instead of throwing when no strict tier admits the catalog", () => {
|
|
436
|
+
const d = runConstrained(untrustedLedger());
|
|
437
|
+
// It must pick one of the available models, not throw `catalog exhausted`.
|
|
438
|
+
expect(constrained.models.some((m) => m.slug === d.slug)).toBe(true);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("records the rescue in the reasoning trail", () => {
|
|
442
|
+
const d = runConstrained(untrustedLedger());
|
|
443
|
+
expect(d.reasons.some((r) => r.startsWith("tier rescue:"))).toBe(true);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("the rescue chooses the cheapest available model when quality is secondary", () => {
|
|
447
|
+
const d = runConstrained(untrustedLedger());
|
|
448
|
+
const chosen = MODELS.find((m) => m.slug === d.slug);
|
|
449
|
+
expect(chosen).toBeDefined();
|
|
450
|
+
// Price ceilings are relaxed first; the cheapest surviving model wins.
|
|
451
|
+
const cheapest = constrained.models.reduce((a, b) => (a.price.prompt <= b.price.prompt ? a : b));
|
|
452
|
+
expect(d.slug).toBe(cheapest.slug);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test("a guardrail that leaves every model below the trust bar is rescued by relaxing it", () => {
|
|
456
|
+
// Reproduces the real failure: a tiny guardrail catalog whose models are
|
|
457
|
+
// all marked untrusted (probed and failed). The trust floor (minTrust 0.7
|
|
458
|
+
// over minTrustSamples 12) excludes them at EVERY tier, so strict widening
|
|
459
|
+
// finds nothing; the rescue relaxes trust and picks a model.
|
|
460
|
+
const d = runConstrained(untrustedLedger());
|
|
461
|
+
expect(constrained.models.some((m) => m.slug === d.slug)).toBe(true);
|
|
462
|
+
expect(d.reasons.some((r) => r.startsWith("tier rescue:"))).toBe(true);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test("still throws when the catalog is empty after relaxing all economic constraints", () => {
|
|
466
|
+
const empty: CatalogSnapshot = { models: [], fetchedAtMs: Date.now(), keyScoped: true };
|
|
467
|
+
const req = request("anything");
|
|
468
|
+
const features = extractFeatures(req, 4000);
|
|
469
|
+
const heuristic = scoreHeuristic(features, BASE);
|
|
470
|
+
expect(() =>
|
|
471
|
+
select({
|
|
472
|
+
req,
|
|
473
|
+
features,
|
|
474
|
+
classification: { ...heuristic, tier: "trivial" as Tier },
|
|
475
|
+
profile: PROFILE,
|
|
476
|
+
state: state(),
|
|
477
|
+
snapshot: empty,
|
|
478
|
+
ledger: null,
|
|
479
|
+
cfg: BASE,
|
|
480
|
+
nowMs: Date.now(),
|
|
481
|
+
}),
|
|
482
|
+
).toThrow(/catalog exhausted/);
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
describe("task-type routing", () => {
|
|
487
|
+
test("a vision task only considers image-capable models", () => {
|
|
488
|
+
// Force the vision task and a tier; every considered candidate must
|
|
489
|
+
// support image input.
|
|
490
|
+
const req = request("describe this image");
|
|
491
|
+
const features = extractFeatures(req, 4000);
|
|
492
|
+
const heuristic = scoreHeuristic(features, BASE);
|
|
493
|
+
const d = select({
|
|
494
|
+
req: { ...req, hasImages: true },
|
|
495
|
+
features: { ...features, hasImages: true },
|
|
496
|
+
classification: { ...heuristic, task: "vision" },
|
|
497
|
+
profile: PROFILE,
|
|
498
|
+
state: state(),
|
|
499
|
+
snapshot: SNAPSHOT,
|
|
500
|
+
ledger: null,
|
|
501
|
+
cfg: BASE,
|
|
502
|
+
nowMs: Date.now(),
|
|
503
|
+
});
|
|
504
|
+
expect(d.considered.length).toBeGreaterThan(0);
|
|
505
|
+
for (const c of d.considered) expect(c.model.inputModalities.includes("image")).toBe(true);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test("the task config's quality floor overrides the tier floor when higher", () => {
|
|
509
|
+
// A coding task with a high minQuality must not admit models below it,
|
|
510
|
+
// even in a tier whose own floor is lower.
|
|
511
|
+
const cfg: RouterConfig = {
|
|
512
|
+
...BASE,
|
|
513
|
+
tasks: { ...BASE.tasks, coding: { axis: "coding", minQuality: 60 } },
|
|
514
|
+
};
|
|
515
|
+
const req = request("refactor the service layer");
|
|
516
|
+
const features = extractFeatures(req, 4000);
|
|
517
|
+
const heuristic = scoreHeuristic(features, cfg);
|
|
518
|
+
const d = select({
|
|
519
|
+
req,
|
|
520
|
+
features,
|
|
521
|
+
classification: { ...heuristic, task: "coding" },
|
|
522
|
+
profile: PROFILE,
|
|
523
|
+
state: state(),
|
|
524
|
+
snapshot: SNAPSHOT,
|
|
525
|
+
ledger: null,
|
|
526
|
+
cfg,
|
|
527
|
+
nowMs: Date.now(),
|
|
528
|
+
});
|
|
529
|
+
// The task floor (60) is higher than the trivial tier floor (0); every
|
|
530
|
+
// considered candidate must clear it. (A floor so high nothing qualifies
|
|
531
|
+
// would trip tier rescue, so 60 is the meaningful override test.)
|
|
532
|
+
expect(d.considered.length).toBeGreaterThan(0);
|
|
533
|
+
for (const c of d.considered) {
|
|
534
|
+
const q = c.model.quality.coding ?? c.model.quality.intelligence ?? 0;
|
|
535
|
+
expect(q).toBeGreaterThanOrEqual(60);
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { parseSse } from "../src/upstream/sse-parse.ts";
|
|
3
|
+
import type { StreamEvent, UpstreamChunk } from "../src/wire/types.ts";
|
|
4
|
+
|
|
5
|
+
function streamOf(parts: string[]): ReadableStream<Uint8Array> {
|
|
6
|
+
const enc = new TextEncoder();
|
|
7
|
+
return new ReadableStream<Uint8Array>({
|
|
8
|
+
start(c) {
|
|
9
|
+
for (const p of parts) c.enqueue(enc.encode(p));
|
|
10
|
+
c.close();
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function collect(
|
|
16
|
+
stream: ReadableStream<Uint8Array>,
|
|
17
|
+
warn?: (msg: string, fields?: Record<string, unknown>) => void,
|
|
18
|
+
): Promise<UpstreamChunk[]> {
|
|
19
|
+
const out: UpstreamChunk[] = [];
|
|
20
|
+
for await (const c of parseSse(stream, warn)) out.push(c);
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function eventsOf(chunks: UpstreamChunk[]): StreamEvent[] {
|
|
25
|
+
return chunks.flatMap((c) => c.events);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("parseSse", () => {
|
|
29
|
+
test("a frame split across two byte chunks parses once and correctly", async () => {
|
|
30
|
+
const payload = JSON.stringify({
|
|
31
|
+
id: "gen-1",
|
|
32
|
+
model: "openai/gpt-x",
|
|
33
|
+
choices: [{ index: 0, delta: { content: "hello" } }],
|
|
34
|
+
});
|
|
35
|
+
const wire = `data: ${payload}\n\n`;
|
|
36
|
+
const mid = wire.indexOf('"mod'); // split mid-line, inside the JSON
|
|
37
|
+
const chunks = await collect(streamOf([wire.slice(0, mid), wire.slice(mid)]));
|
|
38
|
+
expect(chunks).toHaveLength(1);
|
|
39
|
+
const events = chunks[0]!.events;
|
|
40
|
+
expect(events).toContainEqual({ type: "start", servedSlug: "openai/gpt-x", generationId: "gen-1" });
|
|
41
|
+
expect(events).toContainEqual({ type: "text", delta: "hello" });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("keep-alive comments are ignored", async () => {
|
|
45
|
+
const payload = JSON.stringify({ id: "gen-1", model: "m", choices: [{ delta: { content: "hi" } }] });
|
|
46
|
+
const wire = `: OPENROUTER PROCESSING\n\n: OPENROUTER PROCESSING\n\ndata: ${payload}\n\n: OPENROUTER PROCESSING\n\n`;
|
|
47
|
+
const chunks = await collect(streamOf([wire]));
|
|
48
|
+
expect(chunks).toHaveLength(1);
|
|
49
|
+
expect(eventsOf(chunks).some((e) => e.type === "text")).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("data: [DONE] terminates the stream with no chunk", async () => {
|
|
53
|
+
const payload = JSON.stringify({ id: "gen-1", model: "m", choices: [{ delta: { content: "hi" } }] });
|
|
54
|
+
const chunks = await collect(streamOf([`data: ${payload}\n\ndata: [DONE]\n\ndata: {"never":true}\n\n`]));
|
|
55
|
+
expect(chunks).toHaveLength(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("usage maps to UsageCounts without double-counting cached tokens", async () => {
|
|
59
|
+
const payload = JSON.stringify({
|
|
60
|
+
choices: [],
|
|
61
|
+
usage: {
|
|
62
|
+
prompt_tokens: 100,
|
|
63
|
+
prompt_tokens_details: { cached_tokens: 40, cache_write_tokens: 10 },
|
|
64
|
+
completion_tokens: 20,
|
|
65
|
+
completion_tokens_details: { reasoning_tokens: 5 },
|
|
66
|
+
cost: 0.0012,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
const chunks = await collect(streamOf([`data: ${payload}\n\n`]));
|
|
70
|
+
const usage = eventsOf(chunks).find((e) => e.type === "usage");
|
|
71
|
+
expect(usage).toEqual({
|
|
72
|
+
type: "usage",
|
|
73
|
+
usage: {
|
|
74
|
+
promptTokens: 100, // includes the 40 cached; NOT 140
|
|
75
|
+
cachedTokens: 40,
|
|
76
|
+
cacheWriteTokens: 10,
|
|
77
|
+
completionTokens: 20,
|
|
78
|
+
reasoningTokens: 5,
|
|
79
|
+
images: 0,
|
|
80
|
+
},
|
|
81
|
+
reportedCostUsd: 0.0012,
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("usage without cost reports reportedCostUsd null", async () => {
|
|
86
|
+
const payload = JSON.stringify({ choices: [], usage: { prompt_tokens: 5, completion_tokens: 2 } });
|
|
87
|
+
const chunks = await collect(streamOf([`data: ${payload}\n\n`]));
|
|
88
|
+
const usage = eventsOf(chunks).find((e) => e.type === "usage");
|
|
89
|
+
expect(usage).toMatchObject({ reportedCostUsd: null });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("a malformed frame is skipped with a warning, not thrown", async () => {
|
|
93
|
+
const good = JSON.stringify({ id: "gen-1", model: "m", choices: [{ delta: { content: "fine" } }] });
|
|
94
|
+
const warnings: string[] = [];
|
|
95
|
+
const chunks = await collect(
|
|
96
|
+
streamOf([`data: {not json\n\ndata: ${good}\n\n`]),
|
|
97
|
+
(msg) => warnings.push(msg),
|
|
98
|
+
);
|
|
99
|
+
expect(chunks).toHaveLength(1);
|
|
100
|
+
expect(warnings).toHaveLength(1);
|
|
101
|
+
expect(eventsOf(chunks)).toContainEqual({ type: "text", delta: "fine" });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("tool-call argument fragments arrive as separate argsDelta events", async () => {
|
|
105
|
+
const f1 = JSON.stringify({
|
|
106
|
+
id: "gen-1",
|
|
107
|
+
model: "m",
|
|
108
|
+
choices: [{ delta: { tool_calls: [{ index: 0, id: "call_1", function: { name: "read", arguments: '{"pa' } }] } }],
|
|
109
|
+
});
|
|
110
|
+
const f2 = JSON.stringify({
|
|
111
|
+
choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: 'th":"x"}' } }] } }],
|
|
112
|
+
});
|
|
113
|
+
const chunks = await collect(streamOf([`data: ${f1}\n\ndata: ${f2}\n\n`]));
|
|
114
|
+
expect(chunks).toHaveLength(2);
|
|
115
|
+
expect(chunks[0]!.events).toContainEqual({
|
|
116
|
+
type: "tool_call",
|
|
117
|
+
index: 0,
|
|
118
|
+
id: "call_1",
|
|
119
|
+
name: "read",
|
|
120
|
+
argsDelta: '{"pa',
|
|
121
|
+
});
|
|
122
|
+
// Second fragment supplies only arguments: no id, no name keys.
|
|
123
|
+
expect(chunks[1]!.events).toEqual([{ type: "tool_call", index: 0, argsDelta: 'th":"x"}' }]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("reasoning deltas surface from whichever field the provider used", async () => {
|
|
127
|
+
const r1 = JSON.stringify({ choices: [{ delta: { reasoning: "thinking " } }] });
|
|
128
|
+
const r2 = JSON.stringify({ choices: [{ delta: { reasoning_content: "hard" } }] });
|
|
129
|
+
const r3 = JSON.stringify({ choices: [{ delta: { reasoning_details: [{ type: "reasoning.text", text: " about it" }] } }] });
|
|
130
|
+
const chunks = await collect(streamOf([`data: ${r1}\n\ndata: ${r2}\n\ndata: ${r3}\n\n`]));
|
|
131
|
+
const reasoning = eventsOf(chunks)
|
|
132
|
+
.filter((e): e is Extract<StreamEvent, { type: "reasoning" }> => e.type === "reasoning")
|
|
133
|
+
.map((e) => e.delta);
|
|
134
|
+
expect(reasoning).toEqual(["thinking ", "hard", " about it"]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("unknown finish reasons map to error", async () => {
|
|
138
|
+
const payload = JSON.stringify({ choices: [{ delta: {}, finish_reason: "provider_exploded" }] });
|
|
139
|
+
const chunks = await collect(streamOf([`data: ${payload}\n\n`]));
|
|
140
|
+
expect(eventsOf(chunks)).toContainEqual({ type: "finish", reason: "error" });
|
|
141
|
+
});
|
|
142
|
+
});
|