auto-model-router 0.36.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/schema.ts +22 -0
- package/src/config/types.ts +22 -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/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 +12 -0
- package/test/benchmark-feeds.test.ts +194 -0
- package/test/config.test.ts +26 -0
- package/test/context-token.test.ts +301 -0
- 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/trust-attribution.test.ts +2 -2
- package/test/turn.test.ts +55 -0
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);
|
|
@@ -271,7 +271,7 @@ describe("v4 migration", () => {
|
|
|
271
271
|
await db.close();
|
|
272
272
|
});
|
|
273
273
|
|
|
274
|
-
test("schema is at user_version
|
|
274
|
+
test("schema is at user_version 20", () => {
|
|
275
275
|
// `PRAGMA user_version` is SQLite's own migration marker, so this reads
|
|
276
276
|
// through the bootstrap handle rather than the engine-agnostic one.
|
|
277
277
|
const path = join(tmpdir(), `uv-${process.pid}-${Date.now()}.db`);
|
|
@@ -279,7 +279,7 @@ describe("v4 migration", () => {
|
|
|
279
279
|
try {
|
|
280
280
|
// Our own pragma against our own file; the shape is fixed by SQLite.
|
|
281
281
|
const row = db.query("PRAGMA user_version").get() as { user_version: number } | null;
|
|
282
|
-
expect(row?.user_version).toBe(
|
|
282
|
+
expect(row?.user_version).toBe(20);
|
|
283
283
|
} finally {
|
|
284
284
|
db.close();
|
|
285
285
|
}
|
package/test/turn.test.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { runTurn } from "../src/server/turn.ts";
|
|
|
18
18
|
import { BudgetExceededError } from "../src/router/select.ts";
|
|
19
19
|
import { parseMessagesRequest } from "../src/wire/anthropic/messages.ts";
|
|
20
20
|
import { parseChatRequest } from "../src/wire/openai/request.ts";
|
|
21
|
+
import { isMintedRequestId } from "../src/util/requestid.ts";
|
|
21
22
|
import { UpstreamError, type DispatchOptions, type UpstreamClient } from "../src/upstream/types.ts";
|
|
22
23
|
import type {
|
|
23
24
|
FinishReason,
|
|
@@ -603,6 +604,60 @@ describe("the context scope reaches the ledger", () => {
|
|
|
603
604
|
});
|
|
604
605
|
});
|
|
605
606
|
|
|
607
|
+
describe("the request id reaches the ledger", () => {
|
|
608
|
+
const runOne = async (req: NormRequest) => {
|
|
609
|
+
const { router } = mkRouter([mkDecision("trivial", "cheap/model", { escalateTo: "simple" })]);
|
|
610
|
+
const { upstream } = mkUpstream([{ kind: "chunks", chunks: [startChunk("cheap/model"), textChunk("hi"), finishChunk("stop"), usageChunk({ promptTokens: 10, completionTokens: 2 }, 0.0001)] }]);
|
|
611
|
+
const { ledger, entries } = mkLedger();
|
|
612
|
+
const { store } = mkConversations();
|
|
613
|
+
const { sink } = mkSink();
|
|
614
|
+
await runTurn(req, sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog, context: createDisabledBridge() }, new AbortController().signal);
|
|
615
|
+
return entries;
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
test("the front door's id is recorded verbatim, so a quoted id names this exact turn", async () => {
|
|
619
|
+
const req = parseChatRequest({ model: "auto", messages: [{ role: "user", content: "hi" }] }, new Headers({ "x-request-id": "abc123" }));
|
|
620
|
+
expect((await runOne(req))[0]!.requestId).toBe("abc123");
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
test("a caller that sends none still gets an addressable row, marked as minted", async () => {
|
|
624
|
+
const req = parseChatRequest({ model: "auto", messages: [{ role: "user", content: "hi" }] }, new Headers());
|
|
625
|
+
const id = (await runOne(req))[0]!.requestId ?? "";
|
|
626
|
+
expect(isMintedRequestId(id)).toBe(true);
|
|
627
|
+
// And it is the id the caller was handed back, not a second one invented
|
|
628
|
+
// for the row: the header the wire read is what the ledger stores.
|
|
629
|
+
expect(id).toBe(req.requestId ?? "");
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test("every attempt of an escalated turn files under the ONE id the caller holds", async () => {
|
|
633
|
+
// The customer made one request; support must land on the whole turn,
|
|
634
|
+
// including the attempt that was thrown away.
|
|
635
|
+
const { router } = mkRouter([mkDecision("trivial", "cheap/model", { escalateTo: "simple" }), mkDecision("simple", "better/model", { escalateTo: "moderate" })]);
|
|
636
|
+
const { upstream } = mkUpstream([
|
|
637
|
+
{ kind: "chunks", chunks: [startChunk("cheap/model"), textChunk("I'm sorry, but I can't help with that request."), finishChunk("stop")] },
|
|
638
|
+
{ kind: "chunks", chunks: [startChunk("better/model"), textChunk("Here is the answer."), finishChunk("stop"), usageChunk({ promptTokens: 130, completionTokens: 6 }, 0.0009)] },
|
|
639
|
+
]);
|
|
640
|
+
const { ledger, entries } = mkLedger();
|
|
641
|
+
const { store } = mkConversations();
|
|
642
|
+
const { sink } = mkSink();
|
|
643
|
+
const req = parseChatRequest({ model: "auto", messages: [{ role: "user", content: "hi" }] }, new Headers({ "x-request-id": "one-request" }));
|
|
644
|
+
|
|
645
|
+
await runTurn(req, sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog, context: createDisabledBridge() }, new AbortController().signal);
|
|
646
|
+
|
|
647
|
+
expect(entries).toHaveLength(2);
|
|
648
|
+
expect(entries.map((e) => e.requestId)).toEqual(["one-request", "one-request"]);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
test("a hostile header never reaches the row; the turn is recorded under a minted id instead", async () => {
|
|
652
|
+
for (const hostile of ["x".repeat(129), "id with spaces", "amr-forged0000", '"><script>', "tab\tseparated"]) {
|
|
653
|
+
const req = parseChatRequest({ model: "auto", messages: [{ role: "user", content: "hi" }] }, new Headers({ "x-request-id": hostile }));
|
|
654
|
+
const id = (await runOne(req))[0]!.requestId ?? "";
|
|
655
|
+
expect(id).not.toBe(hostile);
|
|
656
|
+
expect(isMintedRequestId(id)).toBe(true);
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
});
|
|
660
|
+
|
|
606
661
|
describe("exploration reaches the ledger", () => {
|
|
607
662
|
test("an explored turn records the tier it was dropped from", async () => {
|
|
608
663
|
const explored = { ...mkDecision("simple", "cheap/model"), explored: { from: "moderate" as Tier, to: "simple" as Tier } };
|