auto-model-router 0.35.0 → 0.37.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/.omp-plugin/marketplace.json +2 -2
- package/README.md +104 -10
- package/omp-extension/remote-logic.ts +15 -0
- package/package.json +1 -1
- package/src/catalog/benchmark-feeds.ts +179 -41
- package/src/catalog/openrouter-catalog.ts +10 -2
- package/src/cli/connect.ts +89 -17
- package/src/cli/context-token.ts +133 -0
- package/src/cli/credential-store.ts +65 -20
- package/src/cli/refresh.ts +26 -1
- package/src/config/defaults.ts +1 -0
- package/src/config/schema.ts +22 -0
- package/src/config/types.ts +31 -0
- package/src/cost/ledger-sql.ts +3 -2
- package/src/cost/ledger.ts +4 -0
- package/src/cost/types.ts +12 -0
- package/src/cost/views.ts +12 -0
- package/src/lib.ts +12 -0
- package/src/router/candidates.ts +17 -0
- package/src/router/index.ts +4 -0
- package/src/router/types.ts +2 -0
- package/src/server/catalog-view.ts +8 -2
- package/src/server/compaction-digest.ts +3 -0
- package/src/server/digest.ts +11 -0
- package/src/server/http.ts +46 -4
- package/src/server/turn.ts +5 -0
- package/src/util/requestid.ts +77 -0
- package/src/util/schema.ts +42 -3
- package/src/util/sqlite.ts +20 -1
- package/src/wire/openai/request.ts +8 -0
- package/src/wire/types.ts +18 -0
- package/test/benchmark-feeds.test.ts +194 -0
- package/test/catalog-view.test.ts +11 -0
- package/test/config.test.ts +26 -0
- package/test/context-token.test.ts +301 -0
- package/test/failover.test.ts +1 -1
- package/test/ledger-sql.test.ts +1 -1
- package/test/mcp-entry.test.ts +14 -5
- package/test/migrations.test.ts +11 -4
- package/test/reconfigure.test.ts +55 -0
- package/test/request-id.test.ts +424 -0
- package/test/schema.test.ts +2 -2
- package/test/tier-plan.test.ts +51 -0
- package/test/trust-attribution.test.ts +2 -2
- package/test/turn.test.ts +56 -1
package/test/mcp-entry.test.ts
CHANGED
|
@@ -144,14 +144,23 @@ describe("connect writes the team MCP endpoint", () => {
|
|
|
144
144
|
|
|
145
145
|
describe("fetchSetupInfo", () => {
|
|
146
146
|
test("reads mcp from a team, and answers false for a plain router, a bad body or a dead remote", async () => {
|
|
147
|
-
expect(await fetchSetupInfo("https://t", (async () => Response.json({ mcp: true })) as unknown as typeof fetch)).toEqual({ mcp: true });
|
|
148
|
-
expect(await fetchSetupInfo("https://t", (async () => Response.json({ mcp: "yes" })) as unknown as typeof fetch)).toEqual({ mcp: false });
|
|
149
|
-
expect(await fetchSetupInfo("https://t", (async () => new Response("", { status: 404 })) as unknown as typeof fetch)).toEqual({ mcp: false });
|
|
150
|
-
expect(await fetchSetupInfo("https://t", (async () => new Response("<html>", { status: 200 })) as unknown as typeof fetch)).toEqual({ mcp: false });
|
|
147
|
+
expect(await fetchSetupInfo("https://t", (async () => Response.json({ mcp: true })) as unknown as typeof fetch)).toEqual({ mcp: true, mcpAuth: "member-key" });
|
|
148
|
+
expect(await fetchSetupInfo("https://t", (async () => Response.json({ mcp: "yes" })) as unknown as typeof fetch)).toEqual({ mcp: false, mcpAuth: "member-key" });
|
|
149
|
+
expect(await fetchSetupInfo("https://t", (async () => new Response("", { status: 404 })) as unknown as typeof fetch)).toEqual({ mcp: false, mcpAuth: "member-key" });
|
|
150
|
+
expect(await fetchSetupInfo("https://t", (async () => new Response("<html>", { status: 200 })) as unknown as typeof fetch)).toEqual({ mcp: false, mcpAuth: "member-key" });
|
|
151
151
|
expect(
|
|
152
152
|
await fetchSetupInfo("https://t", (async () => {
|
|
153
153
|
throw new Error("down");
|
|
154
154
|
}) as unknown as typeof fetch),
|
|
155
|
-
).toEqual({ mcp: false });
|
|
155
|
+
).toEqual({ mcp: false, mcpAuth: "member-key" });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("mcpAuth says which credential the MCP entry carries; anything but context-token is the member key", async () => {
|
|
159
|
+
const info = async (body: Record<string, unknown>): Promise<{ mcp: boolean; mcpAuth: string }> => fetchSetupInfo("https://t", (async () => Response.json(body)) as unknown as typeof fetch);
|
|
160
|
+
expect(await info({ mcp: true, mcpAuth: "context-token" })).toEqual({ mcp: true, mcpAuth: "context-token" });
|
|
161
|
+
expect(await info({ mcp: true, mcpAuth: "member-key" })).toEqual({ mcp: true, mcpAuth: "member-key" });
|
|
162
|
+
// An older team edition says nothing at all; it must read as the member key.
|
|
163
|
+
expect(await info({ mcp: true })).toEqual({ mcp: true, mcpAuth: "member-key" });
|
|
164
|
+
expect(await info({ mcp: true, mcpAuth: 1 })).toEqual({ mcp: true, mcpAuth: "member-key" });
|
|
156
165
|
});
|
|
157
166
|
});
|
package/test/migrations.test.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { createFeedbackStore } from "../src/cost/feedback.ts";
|
|
|
11
11
|
import { createSqlLedger } from "../src/cost/ledger-sql.ts";
|
|
12
12
|
import { buildUsageReport } from "../src/cost/report.ts";
|
|
13
13
|
import { buildDailySummary, createKv } from "../src/cost/summary.ts";
|
|
14
|
-
import { exportRows, spendUsdSince } from "../src/cost/views.ts";
|
|
14
|
+
import { decisionEntries, exportRows, spendUsdSince } from "../src/cost/views.ts";
|
|
15
15
|
import { createConversationStore } from "../src/router/state.ts";
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -26,7 +26,7 @@ import { createConversationStore } from "../src/router/state.ts";
|
|
|
26
26
|
|
|
27
27
|
const FIXTURES = join(import.meta.dir, "fixtures", "migrations");
|
|
28
28
|
const files = readdirSync(FIXTURES).filter((f) => /^router-v\d+\.db$/.test(f)).sort((a, b) => Number(/\d+/.exec(a)![0]) - Number(/\d+/.exec(b)![0]));
|
|
29
|
-
const CURRENT_VERSION =
|
|
29
|
+
const CURRENT_VERSION = 20;
|
|
30
30
|
|
|
31
31
|
describe("schema migrations from every shipped version", () => {
|
|
32
32
|
test("fixtures exist for the versions that shipped", async () => {
|
|
@@ -42,14 +42,14 @@ describe("schema migrations from every shipped version", () => {
|
|
|
42
42
|
const cfg = structuredClone(DEFAULT_CONFIG);
|
|
43
43
|
cfg.ledger.path = path;
|
|
44
44
|
// `openDb` is the migration path for a SQLite file: it applies the
|
|
45
|
-
//
|
|
45
|
+
// twenty versions in order. The shim handle then reads the result.
|
|
46
46
|
const db = openDb(path);
|
|
47
47
|
const sdb = openSqlDb(path);
|
|
48
48
|
try {
|
|
49
49
|
expect((db.query("PRAGMA user_version").get() as { user_version: number }).user_version).toBe(CURRENT_VERSION);
|
|
50
50
|
// Every column the current code writes exists after migration.
|
|
51
51
|
const ledgerCols = new Set((db.query("PRAGMA table_info(ledger)").all() as { name: string }[]).map((c) => c.name));
|
|
52
|
-
for (const c of ["harness_id", "error_kind", "omp_session_id", "features", "explored_from", "hold_arm", "prompt_tokens_saved", "scope", "redactions"]) expect(ledgerCols.has(c)).toBe(true);
|
|
52
|
+
for (const c of ["harness_id", "error_kind", "omp_session_id", "features", "explored_from", "hold_arm", "prompt_tokens_saved", "scope", "redactions", "request_id"]) expect(ledgerCols.has(c)).toBe(true);
|
|
53
53
|
const convCols = new Set((db.query("PRAGMA table_info(conversations)").all() as { name: string }[]).map((c) => c.name));
|
|
54
54
|
for (const c of ["context_version", "compaction_plan", "compaction_plan_tokens", "upgrade_deferred_tier"]) expect(convCols.has(c)).toBe(true);
|
|
55
55
|
// The fixture's ledger row survived the ALTERs with its values.
|
|
@@ -76,6 +76,13 @@ describe("schema migrations from every shipped version", () => {
|
|
|
76
76
|
// v19: the fixture's row predates `redactions`, so nothing claims a
|
|
77
77
|
// redaction happened on it and the report totals it as zero.
|
|
78
78
|
expect((await buildUsageReport(sdb, { windowDays: 3650 })).totals.redactions).toBe(0);
|
|
79
|
+
// v20: the fixture's row predates `request_id`, so it reads as ABSENT
|
|
80
|
+
// rather than as an error or an empty string — an old turn genuinely
|
|
81
|
+
// has no request id, and the decision trail says so by omission. The
|
|
82
|
+
// column is there and queryable, so a lookup by id simply finds nothing.
|
|
83
|
+
expect((await ledger.recentEntries(5))[0]?.requestId).toBeUndefined();
|
|
84
|
+
expect(await decisionEntries(sdb, { sinceMs: 0, harness: null, requestId: "anything-at-all" })).toEqual([]);
|
|
85
|
+
expect((await decisionEntries(sdb, { sinceMs: 0, harness: null }))[0]?.requestId).toBeUndefined();
|
|
79
86
|
} finally {
|
|
80
87
|
await sdb.close();
|
|
81
88
|
db.close();
|
package/test/reconfigure.test.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { describe, expect, test } from "bun:test";
|
|
5
|
+
import { suppliedScores, type FeedScore } from "../src/catalog/benchmark-feeds.ts";
|
|
5
6
|
import { applyConfigPatch, assignInPlace, touched } from "../src/config/apply.ts";
|
|
6
7
|
import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
|
|
7
8
|
import type { RouterConfig } from "../src/config/types.ts";
|
|
@@ -154,6 +155,60 @@ describe("a running server reconfigures", () => {
|
|
|
154
155
|
}
|
|
155
156
|
});
|
|
156
157
|
|
|
158
|
+
test("supplied benchmark scores arrive live and apply on the next catalog build", async () => {
|
|
159
|
+
const dir = mkdtempSync(join(tmpdir(), "amr-extrascores-"));
|
|
160
|
+
const cfg: RouterConfig = {
|
|
161
|
+
...base(),
|
|
162
|
+
ledger: { ...DEFAULT_CONFIG.ledger, path: join(dir, "ledger.db") },
|
|
163
|
+
benchmarks: { ...DEFAULT_CONFIG.benchmarks, enabled: false },
|
|
164
|
+
};
|
|
165
|
+
const started = startServer(cfg);
|
|
166
|
+
const db = openDb(cfg.ledger.path);
|
|
167
|
+
const fetchedAt = (): number | null => {
|
|
168
|
+
const row = db.query("SELECT fetched_at_ms FROM benchmark_cache WHERE id = 1").get() as { fetched_at_ms: number } | null;
|
|
169
|
+
return row === null ? null : row.fetched_at_ms;
|
|
170
|
+
};
|
|
171
|
+
try {
|
|
172
|
+
const seeded = Date.now();
|
|
173
|
+
db.query("INSERT INTO benchmark_cache (id, payload, fetched_at_ms) VALUES (1, ?, ?)").run("[]", seeded);
|
|
174
|
+
// An older front door sends no such field; the default is simply absent.
|
|
175
|
+
expect(cfg.benchmarks.extraScores).toBeUndefined();
|
|
176
|
+
|
|
177
|
+
const table: FeedScore[] = [{ key: "deepseek/deepseek-v4.1-flash", creator: "DeepSeek", coding: 55.2, source: "vendor" }];
|
|
178
|
+
const r = await started.reconfigure({ benchmarks: { extraScores: table } });
|
|
179
|
+
expect(r.rejected).toEqual([]);
|
|
180
|
+
expect(r.changed).toEqual(["benchmarks.extraScores"]);
|
|
181
|
+
|
|
182
|
+
// Two things make the table take effect NOW rather than tomorrow: the
|
|
183
|
+
// feed cache is aged out (it is a benchmarks change like any other) and a
|
|
184
|
+
// catalog rebuild is started. The scores themselves are never cached —
|
|
185
|
+
// `suppliedScores` reads the live config the rebuild will read.
|
|
186
|
+
expect(fetchedAt()).toBe(0);
|
|
187
|
+
expect(r.catalogRefreshing).toBe(true);
|
|
188
|
+
expect(suppliedScores(cfg)).toEqual([{ key: "deepseek-v4-1-flash", creator: "deepseek", coding: 55.2, source: "vendor" }]);
|
|
189
|
+
|
|
190
|
+
// A patch cannot alias the live table, and re-sending the same one is not
|
|
191
|
+
// a change — the ~daily feed cadence is not every settings save's to reset.
|
|
192
|
+
table.push({ key: "later", creator: "", source: "vendor" });
|
|
193
|
+
expect(cfg.benchmarks.extraScores).toHaveLength(1);
|
|
194
|
+
db.query("UPDATE benchmark_cache SET fetched_at_ms = ? WHERE id = 1").run(seeded);
|
|
195
|
+
const again = await started.reconfigure({ benchmarks: { extraScores: [table[0] as FeedScore] } });
|
|
196
|
+
expect(again.changed).toEqual([]);
|
|
197
|
+
expect(again.catalogRefreshing).toBe(false);
|
|
198
|
+
expect(fetchedAt()).toBe(seeded);
|
|
199
|
+
|
|
200
|
+
// And taking the table away is a change too: supplied scores stop.
|
|
201
|
+
const cleared = await started.reconfigure({ benchmarks: { extraScores: [] } });
|
|
202
|
+
expect(cleared.changed).toEqual(["benchmarks.extraScores"]);
|
|
203
|
+
expect(suppliedScores(cfg)).toEqual([]);
|
|
204
|
+
expect(fetchedAt()).toBe(0);
|
|
205
|
+
} finally {
|
|
206
|
+
db.close();
|
|
207
|
+
await started.stop();
|
|
208
|
+
rmSync(dir, { recursive: true, force: true });
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
157
212
|
test("the socket and the ledger file are refused rather than half-applied", async () => {
|
|
158
213
|
const cfg = base();
|
|
159
214
|
const started = startServer(cfg);
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The request id, from the header a front door sends to the row support reads.
|
|
3
|
+
*
|
|
4
|
+
* The fact being defended: a customer quotes `x-request-id` and an operator must
|
|
5
|
+
* land on THAT turn. Before the ledger recorded it, the only join available was
|
|
6
|
+
* "the same member at the same instant", which answers "probably this one" and
|
|
7
|
+
* answers nothing at all when two of a member's turns overlap. Everything below
|
|
8
|
+
* is about that answer being exact, and about the id being safe to accept from
|
|
9
|
+
* outside: it arrives from the public internet through somebody else's proxy.
|
|
10
|
+
*
|
|
11
|
+
* SQLite runs always; Postgres runs when AMR_ROUTER_TEST_PG points at one,
|
|
12
|
+
* following this repo's convention for store tests — and there specifically
|
|
13
|
+
* because the ledger is partitioned by day there, so the id has to survive
|
|
14
|
+
* partition routing.
|
|
15
|
+
*/
|
|
16
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
17
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
|
|
22
|
+
import { completeUpstreamEntry } from "../src/config/upstreams.ts";
|
|
23
|
+
import type { RouterConfig } from "../src/config/types.ts";
|
|
24
|
+
import { createSqlLedger } from "../src/cost/ledger-sql.ts";
|
|
25
|
+
import { EMPTY_USAGE, type AsyncLedger, type LedgerEntry } from "../src/cost/types.ts";
|
|
26
|
+
import { decisionEntries } from "../src/cost/views.ts";
|
|
27
|
+
import { startServer, type StartedServer } from "../src/server/http.ts";
|
|
28
|
+
import {
|
|
29
|
+
acceptRequestId,
|
|
30
|
+
isMintedRequestId,
|
|
31
|
+
isRequestId,
|
|
32
|
+
mintRequestId,
|
|
33
|
+
requestIdFor,
|
|
34
|
+
MINTED_REQUEST_ID_PREFIX,
|
|
35
|
+
REQUEST_ID_MAX_LENGTH,
|
|
36
|
+
} from "../src/util/requestid.ts";
|
|
37
|
+
import { ledgerDayStart, ledgerLayout, ledgerPartitionName, migrateStore } from "../src/util/schema.ts";
|
|
38
|
+
import { openSqlDb, type SqlDb } from "../src/util/sql.ts";
|
|
39
|
+
import { openDb } from "../src/util/sqlite.ts";
|
|
40
|
+
import { parseMessagesRequest } from "../src/wire/anthropic/messages.ts";
|
|
41
|
+
import { parseChatRequest } from "../src/wire/openai/request.ts";
|
|
42
|
+
import { parseResponsesRequest } from "../src/wire/openai/responses.ts";
|
|
43
|
+
|
|
44
|
+
const DAY = 86_400_000;
|
|
45
|
+
const PG = process.env.AMR_ROUTER_TEST_PG;
|
|
46
|
+
const NUL = String.fromCharCode(0);
|
|
47
|
+
|
|
48
|
+
const engines: { name: string; url: string; partitions: boolean }[] = [
|
|
49
|
+
{ name: "sqlite", url: `sqlite://${join(tmpdir(), `ledger-reqid-${process.pid}-${Date.now()}.db`)}`, partitions: false },
|
|
50
|
+
...(PG === undefined ? [] : [{ name: "postgres", url: PG, partitions: true }]),
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
function entry(over: Partial<LedgerEntry> & { id: string }): LedgerEntry {
|
|
54
|
+
return {
|
|
55
|
+
createdAtMs: Date.now(),
|
|
56
|
+
conversationKey: `conv-${over.id}`,
|
|
57
|
+
sessionId: `sess-${over.id}`,
|
|
58
|
+
turn: 1,
|
|
59
|
+
requestedModel: "auto",
|
|
60
|
+
harnessId: "u_ada",
|
|
61
|
+
ompSessionId: `omp-${over.id}`,
|
|
62
|
+
slug: "x/model",
|
|
63
|
+
servedSlug: "x/model",
|
|
64
|
+
tier: "simple",
|
|
65
|
+
classificationSource: "heuristic",
|
|
66
|
+
reasons: ["cheapest"],
|
|
67
|
+
predictedUsd: 0.001,
|
|
68
|
+
reportedUsd: 0.002,
|
|
69
|
+
usage: { ...EMPTY_USAGE, promptTokens: 100, completionTokens: 10 },
|
|
70
|
+
attempt: 0,
|
|
71
|
+
escalationSignal: null,
|
|
72
|
+
latencyMs: 10,
|
|
73
|
+
ttftMs: 5,
|
|
74
|
+
finishReason: "stop",
|
|
75
|
+
wasted: false,
|
|
76
|
+
upstreamGenerationId: null,
|
|
77
|
+
error: null,
|
|
78
|
+
features: null,
|
|
79
|
+
score: null,
|
|
80
|
+
confidence: null,
|
|
81
|
+
task: null,
|
|
82
|
+
classifierReasons: null,
|
|
83
|
+
exploredFrom: null,
|
|
84
|
+
holdArm: null,
|
|
85
|
+
promptTokensSaved: 0,
|
|
86
|
+
...over,
|
|
87
|
+
} as unknown as LedgerEntry;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe("what the router will carry as a request id", () => {
|
|
91
|
+
test("the ordinary shapes in circulation are accepted verbatim", () => {
|
|
92
|
+
for (const id of [
|
|
93
|
+
"abc123",
|
|
94
|
+
crypto.randomUUID(),
|
|
95
|
+
"01JBQ9F6WQ2M8P7VJ5X3K4T0YZ", // a ULID
|
|
96
|
+
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", // a traceparent
|
|
97
|
+
"req_01HZ.9-x",
|
|
98
|
+
"c".repeat(REQUEST_ID_MAX_LENGTH),
|
|
99
|
+
]) {
|
|
100
|
+
expect(acceptRequestId(id)).toBe(id);
|
|
101
|
+
}
|
|
102
|
+
// Surrounding whitespace is a proxy's formatting, not part of the id.
|
|
103
|
+
expect(acceptRequestId(" abc123 ")).toBe("abc123");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("a hostile value is refused whole rather than repaired", () => {
|
|
107
|
+
const hostile: string[] = [
|
|
108
|
+
"", // absent
|
|
109
|
+
" ",
|
|
110
|
+
"x".repeat(REQUEST_ID_MAX_LENGTH + 1), // unbounded
|
|
111
|
+
`abc${NUL}def`, // a control character
|
|
112
|
+
"abc\ndef", // a newline: one log line must stay one log line
|
|
113
|
+
"abc\r\nX-Injected: 1",
|
|
114
|
+
"abc\tdef",
|
|
115
|
+
"id with spaces",
|
|
116
|
+
'"><script>alert(1)</script>',
|
|
117
|
+
"a'; DROP TABLE ledger;--",
|
|
118
|
+
"../../etc/passwd",
|
|
119
|
+
"héllo", // outside the ASCII set the rules allow
|
|
120
|
+
];
|
|
121
|
+
for (const value of hostile) {
|
|
122
|
+
expect(acceptRequestId(value), value).toBe("");
|
|
123
|
+
// Nothing is salvaged from it: a truncated or stripped id would look
|
|
124
|
+
// valid and match no row, which is worse than having none.
|
|
125
|
+
expect(requestIdFor(value).startsWith(MINTED_REQUEST_ID_PREFIX), value).toBe(true);
|
|
126
|
+
}
|
|
127
|
+
expect(acceptRequestId(null)).toBe("");
|
|
128
|
+
expect(acceptRequestId(undefined)).toBe("");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("a caller cannot pass off an id as one this router minted", () => {
|
|
132
|
+
const forged = `${MINTED_REQUEST_ID_PREFIX}deadbeef`;
|
|
133
|
+
expect(isRequestId(forged)).toBe(true); // well-shaped...
|
|
134
|
+
expect(acceptRequestId(forged)).toBe(""); // ...and still refused from outside
|
|
135
|
+
// The turn is recorded under a minted id of the router's own instead.
|
|
136
|
+
const minted = requestIdFor(forged);
|
|
137
|
+
expect(minted).not.toBe(forged);
|
|
138
|
+
expect(isMintedRequestId(minted)).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("a minted id is distinguishable, unique, and short enough for anyone's log", () => {
|
|
142
|
+
const a = mintRequestId();
|
|
143
|
+
const b = mintRequestId();
|
|
144
|
+
expect(a).not.toBe(b);
|
|
145
|
+
expect(isMintedRequestId(a)).toBe(true);
|
|
146
|
+
expect(isMintedRequestId("abc123")).toBe(false);
|
|
147
|
+
expect(a.length).toBeLessThanOrEqual(REQUEST_ID_MAX_LENGTH);
|
|
148
|
+
// It also passes the team edition's own id rule (letters, digits, dots,
|
|
149
|
+
// dashes, 8-64 characters), so a minted id pastes into support unchanged.
|
|
150
|
+
expect(/^[A-Za-z0-9._-]{8,64}$/.test(a)).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("every turn wire reads the header", () => {
|
|
155
|
+
const body = { model: "auto", messages: [{ role: "user", content: "hi" }] };
|
|
156
|
+
|
|
157
|
+
test("the chat, responses and messages wires all carry the caller's id", () => {
|
|
158
|
+
const h = () => new Headers({ "x-request-id": "abc123" });
|
|
159
|
+
expect(parseChatRequest(body, h()).requestId).toBe("abc123");
|
|
160
|
+
expect(parseResponsesRequest({ model: "auto", input: "hi" }, h()).requestId).toBe("abc123");
|
|
161
|
+
expect(parseMessagesRequest({ model: "claude-sonnet-4-5", max_tokens: 16, messages: [{ role: "user", content: "hi" }] }, h()).requestId).toBe("abc123");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("an older front door that sends no header behaves exactly as before, plus a minted id", () => {
|
|
165
|
+
const before = parseChatRequest(body, new Headers({ "x-omp-harness": "codex" }));
|
|
166
|
+
// Nothing else about the request changed: the id is additive.
|
|
167
|
+
expect(before.harnessId).toBe("codex");
|
|
168
|
+
expect(before.requestedModel).toBe("auto");
|
|
169
|
+
expect(isMintedRequestId(before.requestId ?? "")).toBe(true);
|
|
170
|
+
// And two turns are never filed under the same minted id.
|
|
171
|
+
expect(parseChatRequest(body, new Headers()).requestId).not.toBe(before.requestId);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("a header the rules refuse never reaches the request", () => {
|
|
175
|
+
for (const hostile of ["x".repeat(REQUEST_ID_MAX_LENGTH + 1), "id with spaces", `${MINTED_REQUEST_ID_PREFIX}forged`, '"><script>']) {
|
|
176
|
+
const req = parseChatRequest(body, new Headers({ "x-request-id": hostile }));
|
|
177
|
+
expect(req.requestId).not.toBe(hostile);
|
|
178
|
+
expect(isMintedRequestId(req.requestId ?? "")).toBe(true);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
for (const engine of engines) {
|
|
184
|
+
describe(`the ledger records the request id on ${engine.name}`, () => {
|
|
185
|
+
let db: SqlDb;
|
|
186
|
+
const cfg: RouterConfig = { ...structuredClone(DEFAULT_CONFIG), ledger: { ...DEFAULT_CONFIG.ledger, path: engine.url } };
|
|
187
|
+
let ledger: AsyncLedger;
|
|
188
|
+
|
|
189
|
+
beforeAll(async () => {
|
|
190
|
+
db = openSqlDb(engine.url);
|
|
191
|
+
if (db.dialect === "postgres") await db.sql.unsafe("DROP TABLE IF EXISTS ledger");
|
|
192
|
+
await migrateStore(db);
|
|
193
|
+
ledger = createSqlLedger(db, cfg, { findModel: () => null });
|
|
194
|
+
await db.sql.unsafe("DELETE FROM ledger");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
afterAll(async () => {
|
|
198
|
+
await db.sql.unsafe("DELETE FROM ledger");
|
|
199
|
+
await db.close();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("a turn's id round-trips, and a turn without one reads as absent", async () => {
|
|
203
|
+
await ledger.record(entry({ id: "w1", requestId: "abc123" }));
|
|
204
|
+
await ledger.record(entry({ id: "w2" }));
|
|
205
|
+
// An empty string is not an id: it stores as NULL, like an absent one.
|
|
206
|
+
await ledger.record(entry({ id: "w3", requestId: "" }));
|
|
207
|
+
|
|
208
|
+
const byId = new Map((await ledger.recentEntries(10)).map((e) => [e.id, e]));
|
|
209
|
+
expect(byId.get("w1")?.requestId).toBe("abc123");
|
|
210
|
+
expect(byId.get("w2")?.requestId).toBeUndefined();
|
|
211
|
+
expect(byId.get("w3")?.requestId).toBeUndefined();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("the decision shape carries it, and filters on it exactly", async () => {
|
|
215
|
+
await db.sql.unsafe("DELETE FROM ledger");
|
|
216
|
+
// One request, escalated: two rows, one id. And another member's turn
|
|
217
|
+
// in the same millisecond — the case the time-window join cannot call.
|
|
218
|
+
const atMs = Date.now() - 1000;
|
|
219
|
+
await ledger.record(entry({ id: "d1", createdAtMs: atMs, requestId: "req-one", attempt: 0, wasted: true }));
|
|
220
|
+
await ledger.record(entry({ id: "d2", createdAtMs: atMs, requestId: "req-one", attempt: 1 }));
|
|
221
|
+
await ledger.record(entry({ id: "d3", createdAtMs: atMs, requestId: "req-two", harnessId: "u_bob" }));
|
|
222
|
+
|
|
223
|
+
const all = await decisionEntries(db, { sinceMs: 0, harness: null });
|
|
224
|
+
expect(new Set(all.map((e) => e.requestId))).toEqual(new Set(["req-one", "req-two"]));
|
|
225
|
+
|
|
226
|
+
const one = await decisionEntries(db, { sinceMs: 0, harness: null, requestId: "req-one" });
|
|
227
|
+
expect(one.map((e) => e.id).sort()).toEqual(["d1", "d2"]);
|
|
228
|
+
// Exact, not nearest: an id nothing was recorded under finds nothing,
|
|
229
|
+
// even though a turn of that member sits at the same instant.
|
|
230
|
+
expect(await decisionEntries(db, { sinceMs: 0, harness: null, requestId: "req-three" })).toEqual([]);
|
|
231
|
+
// And it composes with the scoping a front door already applies.
|
|
232
|
+
expect((await decisionEntries(db, { sinceMs: 0, harness: ["u_bob"], requestId: "req-two" })).map((e) => e.id)).toEqual(["d3"]);
|
|
233
|
+
expect(await decisionEntries(db, { sinceMs: 0, harness: ["u_ada"], requestId: "req-two" })).toEqual([]);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("a row written before the column existed reads null rather than failing", async () => {
|
|
237
|
+
await db.sql.unsafe("DELETE FROM ledger");
|
|
238
|
+
await ledger.record(entry({ id: "old", requestId: "will-be-cleared" }));
|
|
239
|
+
// Exactly what an upgraded deployment holds: the column exists, the row
|
|
240
|
+
// predates it. (On SQLite the migration itself is covered by
|
|
241
|
+
// test/migrations.test.ts against every shipped fixture.)
|
|
242
|
+
await db.sql`UPDATE ledger SET request_id = NULL WHERE id = ${"old"}`;
|
|
243
|
+
expect((await ledger.recentEntries(1))[0]?.requestId).toBeUndefined();
|
|
244
|
+
expect((await decisionEntries(db, { sinceMs: 0, harness: null }))[0]?.requestId).toBeUndefined();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("an id far outside today still lands, so partition routing is unaffected", async () => {
|
|
248
|
+
await db.sql.unsafe("DELETE FROM ledger");
|
|
249
|
+
// 40 days back is outside every partition migrateStore provisioned: the
|
|
250
|
+
// insert is refused once, the day is created, and the row is written
|
|
251
|
+
// again — the retry path has to carry the id too.
|
|
252
|
+
const backdated = Date.now() - 40 * DAY;
|
|
253
|
+
await ledger.record(entry({ id: "late", createdAtMs: backdated, requestId: "req-late" }));
|
|
254
|
+
await ledger.record(entry({ id: "now", requestId: "req-now" }));
|
|
255
|
+
|
|
256
|
+
expect((await decisionEntries(db, { sinceMs: 0, harness: null, requestId: "req-late" })).map((e) => e.id)).toEqual(["late"]);
|
|
257
|
+
expect((await decisionEntries(db, { sinceMs: 0, harness: null, requestId: "req-now" })).map((e) => e.id)).toEqual(["now"]);
|
|
258
|
+
if (!engine.partitions) return;
|
|
259
|
+
expect(await ledgerLayout(db)).toBe("partitioned");
|
|
260
|
+
const rows = await db.query<{ name: string }>(
|
|
261
|
+
"SELECT c.relname AS name FROM pg_class c JOIN pg_inherits i ON i.inhrelid = c.oid WHERE i.inhparent = to_regclass('ledger')",
|
|
262
|
+
);
|
|
263
|
+
expect(rows.map((r) => r.name)).toContain(ledgerPartitionName(ledgerDayStart(backdated)));
|
|
264
|
+
// The column reached the day partition, not only the parent.
|
|
265
|
+
const cols = await db.query<{ name: string }>(
|
|
266
|
+
"SELECT column_name AS name FROM information_schema.columns WHERE table_name = $t",
|
|
267
|
+
{ t: ledgerPartitionName(ledgerDayStart(backdated)) },
|
|
268
|
+
);
|
|
269
|
+
expect(cols.map((c) => c.name)).toContain("request_id");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("a hostile string cannot poison the row, because it never becomes an id", async () => {
|
|
273
|
+
await db.sql.unsafe("DELETE FROM ledger");
|
|
274
|
+
// The turn path decides the id (the wire tests above); this is the
|
|
275
|
+
// storage end of the same fact: whatever a caller sent, what is stored
|
|
276
|
+
// is either a value that passed the rules or NULL, and the row beside
|
|
277
|
+
// it is untouched either way.
|
|
278
|
+
const hostile = `x'); DROP TABLE ledger;--${NUL}\n`;
|
|
279
|
+
await ledger.record(entry({ id: "poison", requestId: requestIdFor(hostile) }));
|
|
280
|
+
await ledger.record(entry({ id: "neighbour", requestId: "req-clean" }));
|
|
281
|
+
const rows = await ledger.recentEntries(10);
|
|
282
|
+
expect(rows).toHaveLength(2);
|
|
283
|
+
expect(isMintedRequestId(rows.find((e) => e.id === "poison")?.requestId ?? "")).toBe(true);
|
|
284
|
+
expect(rows.find((e) => e.id === "neighbour")?.requestId).toBe("req-clean");
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
describe("a router upgraded in place", () => {
|
|
290
|
+
test("an older database migrates, keeps its rows, and its old turns read null", () => {
|
|
291
|
+
const dir = mkdtempSync(join(tmpdir(), "amr-reqid-upgrade-"));
|
|
292
|
+
try {
|
|
293
|
+
const path = join(dir, "router.db");
|
|
294
|
+
// A ledger as v19 left it: every column of that release and no
|
|
295
|
+
// request_id. Built by hand rather than from a fixture, so the guard is
|
|
296
|
+
// tested on the exact shape the previous version shipped.
|
|
297
|
+
const old = openDb(path);
|
|
298
|
+
old.exec("PRAGMA user_version = 19");
|
|
299
|
+
// The index goes first: SQLite refuses to drop a column an index names.
|
|
300
|
+
old.exec("DROP INDEX IF EXISTS idx_ledger_request");
|
|
301
|
+
old.exec("ALTER TABLE ledger DROP COLUMN request_id");
|
|
302
|
+
old.exec(
|
|
303
|
+
`INSERT INTO ledger (id, created_at_ms, conversation_key, session_id, turn, requested_model, slug, tier,
|
|
304
|
+
classification_source, reasons, predicted_usd, usage, attempt, latency_ms, wasted)
|
|
305
|
+
VALUES ('v19row', ${Date.now()}, 'k', 's', 1, 'auto', 'x/model', 'simple', 'heuristic', '[]', 0.001, '{}', 0, 5, 0)`,
|
|
306
|
+
);
|
|
307
|
+
old.close();
|
|
308
|
+
|
|
309
|
+
// Reopening is the migration. Nothing is lost, and the old row is
|
|
310
|
+
// honestly id-less rather than carrying an invented one.
|
|
311
|
+
const db = openDb(path);
|
|
312
|
+
try {
|
|
313
|
+
expect((db.query("PRAGMA user_version").get() as { user_version: number }).user_version).toBe(20);
|
|
314
|
+
const cols = (db.query("PRAGMA table_info(ledger)").all() as { name: string }[]).map((c) => c.name);
|
|
315
|
+
expect(cols).toContain("request_id");
|
|
316
|
+
const row = db.query("SELECT id, request_id FROM ledger").get() as { id: string; request_id: string | null };
|
|
317
|
+
expect(row).toEqual({ id: "v19row", request_id: null });
|
|
318
|
+
// The lookup an operator will run is indexed, on both old and new rows.
|
|
319
|
+
const plan = db.query("EXPLAIN QUERY PLAN SELECT * FROM ledger WHERE request_id = 'abc'").all() as { detail: string }[];
|
|
320
|
+
expect(plan.map((p) => p.detail).join(" ")).toContain("idx_ledger_request");
|
|
321
|
+
} finally {
|
|
322
|
+
db.close();
|
|
323
|
+
}
|
|
324
|
+
} finally {
|
|
325
|
+
try {
|
|
326
|
+
rmSync(dir, { recursive: true, force: true });
|
|
327
|
+
} catch {
|
|
328
|
+
/* Windows may hold the WAL briefly */
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe("over HTTP", () => {
|
|
335
|
+
let handle: StartedServer;
|
|
336
|
+
let dir: string;
|
|
337
|
+
let base: string;
|
|
338
|
+
|
|
339
|
+
beforeAll(async () => {
|
|
340
|
+
dir = mkdtempSync(join(tmpdir(), "amr-reqid-http-"));
|
|
341
|
+
const cfg = structuredClone(DEFAULT_CONFIG);
|
|
342
|
+
cfg.server.host = "127.0.0.1";
|
|
343
|
+
cfg.server.port = 0;
|
|
344
|
+
cfg.server.apiKey = "k";
|
|
345
|
+
cfg.logLevel = "silent";
|
|
346
|
+
cfg.context = { ...cfg.context, enabled: false };
|
|
347
|
+
// A catalog that needs no network: one static upstream, pointed at a port
|
|
348
|
+
// nothing listens on. Routing then decides immediately and the dispatch
|
|
349
|
+
// fails at once, so this test measures the header and never the internet.
|
|
350
|
+
cfg.upstreams = [
|
|
351
|
+
completeUpstreamEntry({ id: "nowhere", kind: "openai", baseUrl: "http://127.0.0.1:1/v1", apiKey: "sk-x", models: [{ id: "m1", input: 0.1, output: 0.4 }] }),
|
|
352
|
+
];
|
|
353
|
+
cfg.ledger.path = join(dir, "router.db");
|
|
354
|
+
// Seeded through the same file before the server opens it, as a front
|
|
355
|
+
// door's own handle would be.
|
|
356
|
+
const db = openSqlDb(cfg.ledger.path);
|
|
357
|
+
await migrateStore(db);
|
|
358
|
+
const seed = createSqlLedger(db, cfg, { findModel: () => null });
|
|
359
|
+
await seed.record(entry({ id: "h1", createdAtMs: Date.now() - 1000, requestId: "quoted-by-a-customer" }));
|
|
360
|
+
await seed.record(entry({ id: "h2", createdAtMs: Date.now() - 900 }));
|
|
361
|
+
await db.close();
|
|
362
|
+
handle = startServer(cfg);
|
|
363
|
+
base = `http://127.0.0.1:${handle.server.port}`;
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
afterAll(async () => {
|
|
367
|
+
await handle.stop();
|
|
368
|
+
try {
|
|
369
|
+
rmSync(dir, { recursive: true, force: true });
|
|
370
|
+
} catch {
|
|
371
|
+
/* Windows may hold the WAL briefly */
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const get = (path: string) => fetch(`${base}${path}`, { headers: { authorization: "Bearer k" } });
|
|
376
|
+
|
|
377
|
+
test("the decisions route answers on an exact id, and refuses a mangled one", async () => {
|
|
378
|
+
const hit = (await (await get("/v1/router/decisions?requestId=quoted-by-a-customer")).json()) as { entries: { id: string; requestId?: string }[] };
|
|
379
|
+
expect(hit.entries.map((e) => e.id)).toEqual(["h1"]);
|
|
380
|
+
expect(hit.entries[0]?.requestId).toBe("quoted-by-a-customer");
|
|
381
|
+
// The row that carried no id is not swept in by an id lookup...
|
|
382
|
+
expect(((await (await get("/v1/router/decisions?requestId=nothing-recorded")).json()) as { entries: unknown[] }).entries).toEqual([]);
|
|
383
|
+
// ...and without the parameter the trail is exactly what it always was.
|
|
384
|
+
const all = (await (await get("/v1/router/decisions")).json()) as { entries: { id: string; requestId?: string }[] };
|
|
385
|
+
expect(all.entries.map((e) => e.id).sort()).toEqual(["h1", "h2"]);
|
|
386
|
+
expect(all.entries.find((e) => e.id === "h2")?.requestId).toBeUndefined();
|
|
387
|
+
// A paste that arrived mangled says so, rather than quietly answering
|
|
388
|
+
// with the whole trail as an ignored filter would.
|
|
389
|
+
expect((await get("/v1/router/decisions?requestId=not%20an%20id")).status).toBe(400);
|
|
390
|
+
// A minted id is looked up like any other: the header refuses the prefix,
|
|
391
|
+
// a QUESTION about one is the ordinary case.
|
|
392
|
+
expect((await get(`/v1/router/decisions?requestId=${MINTED_REQUEST_ID_PREFIX}0123456789abcdef`)).status).toBe(200);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("a turn's response carries the id back: the caller's, or the minted one", async () => {
|
|
396
|
+
// Buffered rather than streamed, so the assertion is about the header and
|
|
397
|
+
// not about how fast a stream is torn down. No upstream key is configured
|
|
398
|
+
// here, so the turn fails at routing — which is the point: the id is on
|
|
399
|
+
// the response either way, and a failed turn is exactly the one a customer
|
|
400
|
+
// opens a ticket about.
|
|
401
|
+
const turn = async (headers: Record<string, string>): Promise<Response> => {
|
|
402
|
+
const res = await fetch(`${base}/v1/chat/completions`, {
|
|
403
|
+
method: "POST",
|
|
404
|
+
headers: { "content-type": "application/json", authorization: "Bearer k", ...headers },
|
|
405
|
+
body: JSON.stringify({ model: "auto", stream: false, messages: [{ role: "user", content: "hi" }] }),
|
|
406
|
+
});
|
|
407
|
+
await res.text();
|
|
408
|
+
return res;
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
expect((await turn({ "x-request-id": "abc123" })).headers.get("x-request-id")).toBe("abc123");
|
|
412
|
+
expect(isMintedRequestId((await turn({})).headers.get("x-request-id") ?? "")).toBe(true);
|
|
413
|
+
|
|
414
|
+
// A hostile header is not echoed back either — nothing reflects it.
|
|
415
|
+
const echoed = (await turn({ "x-request-id": "x".repeat(REQUEST_ID_MAX_LENGTH + 1) })).headers.get("x-request-id") ?? "";
|
|
416
|
+
expect(echoed).not.toContain("xxxx");
|
|
417
|
+
expect(isMintedRequestId(echoed)).toBe(true);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("/health names the capability, so a front door can tell before it relies on it", async () => {
|
|
421
|
+
const health = (await (await get("/health")).json()) as { features: string[] };
|
|
422
|
+
expect(health.features).toContain("request-id");
|
|
423
|
+
});
|
|
424
|
+
});
|
package/test/schema.test.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The shim's schema against the SQLite bootstrap's.
|
|
3
3
|
*
|
|
4
4
|
* `util/schema.ts` declares the FINAL shape of every table; `util/sqlite.ts`
|
|
5
|
-
* reaches the same shape by replaying
|
|
5
|
+
* reaches the same shape by replaying twenty migrations. They are written
|
|
6
6
|
* twice, so they can drift — and drift here is silent: a missing column makes
|
|
7
7
|
* the real owner's statements fail only when that code path runs, which is how
|
|
8
8
|
* a live server refused to start earlier in this port.
|
|
@@ -43,7 +43,7 @@ describe("store schema", () => {
|
|
|
43
43
|
test("the shim creates every table the sqlite bootstrap does, with the same columns", async () => {
|
|
44
44
|
const dir = mkdtempSync(join(tmpdir(), "amr-schema-"));
|
|
45
45
|
try {
|
|
46
|
-
// The bootstrap's result, after all
|
|
46
|
+
// The bootstrap's result, after all twenty migrations.
|
|
47
47
|
const legacyPath = join(dir, "legacy.db");
|
|
48
48
|
openDb(legacyPath).close();
|
|
49
49
|
const legacy = await columnsOf(legacyPath);
|
package/test/tier-plan.test.ts
CHANGED
|
@@ -404,6 +404,57 @@ describe("adaptive price ceilings", () => {
|
|
|
404
404
|
});
|
|
405
405
|
});
|
|
406
406
|
|
|
407
|
+
describe("provider locks", () => {
|
|
408
|
+
const req = parseChatRequest(
|
|
409
|
+
{
|
|
410
|
+
model: "auto",
|
|
411
|
+
messages: [{ role: "user", content: "summarize the release notes" }],
|
|
412
|
+
},
|
|
413
|
+
new Headers(),
|
|
414
|
+
);
|
|
415
|
+
const features = extractFeatures(req, 100);
|
|
416
|
+
|
|
417
|
+
// claude/opus from the subscription upstream (cheap for a subscriber) and
|
|
418
|
+
// its expensive OpenRouter twin, priced as OpenRouter really bills.
|
|
419
|
+
const mk = (slug: string, provider: string, inPerMtok: number) => ({
|
|
420
|
+
slug, canonicalSlug: slug, name: slug, provider, vendor: provider,
|
|
421
|
+
contextLength: 200_000, maxCompletionTokens: 32_000, supportsTools: true, supportsReasoning: false,
|
|
422
|
+
reasoningMandatory: false, supportsToolChoice: true, inputModalities: ["text" as const],
|
|
423
|
+
price: { prompt: inPerMtok / 1e6, completion: (inPerMtok * 3) / 1e6 }, priceTiers: [],
|
|
424
|
+
quality: { intelligence: 80, coding: 80, agentic: 60 }, tokenizer: "Other",
|
|
425
|
+
isFree: false, createdAtMs: 0, author: "vendor",
|
|
426
|
+
});
|
|
427
|
+
const snap = { models: [mk("anthropic/claude-opus", "anthropic-subscription", 0.5), mk("anthropic/claude-opus-openrouter", "openrouter", 15)], fetchedAtMs: Date.now(), keyScoped: false };
|
|
428
|
+
|
|
429
|
+
const run = (locks: Record<string, string>) =>
|
|
430
|
+
buildCandidates({
|
|
431
|
+
req, features, tier: "moderate", task: "chat", snapshot: snap,
|
|
432
|
+
cfg: { ...BASE, filters: { ...BASE.filters, providerLocks: locks } },
|
|
433
|
+
expectedCompletionTokens: 512, warmSlug: null,
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("a model matching a lock key may only come from a provider matching the value", () => {
|
|
437
|
+
const { candidates, rejected } = run({ "anthropic/*": "anthropic-subscription" });
|
|
438
|
+
expect(candidates).toHaveLength(1);
|
|
439
|
+
expect(candidates[0]!.model.slug).toBe("anthropic/claude-opus");
|
|
440
|
+
expect(candidates[0]!.model.provider).toBe("anthropic-subscription");
|
|
441
|
+
expect(rejected.some((r) => r.slug === "anthropic/claude-opus-openrouter" && r.reason === "provider_locked")).toBe(true);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("a model no key matches is untouched, and a provider glob keeps other providers serving", () => {
|
|
445
|
+
const only = { models: [mk("xai/grok", "openrouter", 2), mk("anthropic/claude-opus", "openrouter", 15)], fetchedAtMs: Date.now(), keyScoped: false };
|
|
446
|
+
const out = buildCandidates({
|
|
447
|
+
req, features, tier: "moderate", task: "chat", snapshot: only,
|
|
448
|
+
cfg: { ...BASE, filters: { ...BASE.filters, providerLocks: { "anthropic/*": "anthropic-subscription" } } },
|
|
449
|
+
expectedCompletionTokens: 512, warmSlug: null,
|
|
450
|
+
});
|
|
451
|
+
// grok matches no key: served from openrouter as usual. The claude slug
|
|
452
|
+
// matches the lock and openrouter does not match the value: dropped.
|
|
453
|
+
expect(out.candidates.map((c) => c.model.slug)).toEqual(["xai/grok"]);
|
|
454
|
+
expect(out.rejected.some((r) => r.slug === "anthropic/claude-opus" && r.reason === "provider_locked")).toBe(true);
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
407
458
|
describe("quality normalization and capability floor (benchmark findings 4/6)", () => {
|
|
408
459
|
const req = parseChatRequest(
|
|
409
460
|
{
|