auto-model-router 0.32.0 → 0.34.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 +39 -0
- package/docs/data-governance.md +42 -1
- package/package.json +1 -1
- package/src/cost/ledger-sql.ts +41 -3
- package/src/router/candidates.ts +9 -0
- package/src/router/types.ts +2 -0
- package/src/server/http.ts +7 -1
- package/src/server/turn.ts +3 -1
- package/src/upstream/anthropic.ts +5 -4
- package/src/upstream/compat.ts +13 -7
- package/src/upstream/ollama.ts +4 -3
- package/src/upstream/openrouter.ts +4 -3
- package/src/upstream/types.ts +7 -0
- package/src/util/schema.ts +148 -19
- package/src/wire/openai/request.ts +29 -0
- package/src/wire/types.ts +15 -0
- package/test/failover.test.ts +41 -0
- package/test/ledger-partitions.test.ts +321 -0
- package/test/upstream-keys.test.ts +240 -0
|
@@ -315,6 +315,31 @@ export function parsePolicyHeader(raw: string | null): RequestPolicy | undefined
|
|
|
315
315
|
return Object.keys(out).length === 0 ? undefined : out;
|
|
316
316
|
}
|
|
317
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Parses the X-Omp-Upstream-Keys header: `{ "<upstream id>": "<credential>" }`.
|
|
320
|
+
* Malformed, not an object, or empty ⇒ no overrides, exactly like a malformed
|
|
321
|
+
* X-Omp-Policy (never a rejected turn — the configured keys still serve).
|
|
322
|
+
* Non-string values are dropped; `""` is KEPT, meaning "this turn has no
|
|
323
|
+
* credential for that upstream", which excludes it from selection.
|
|
324
|
+
*/
|
|
325
|
+
export function parseUpstreamKeysHeader(raw: string | null): Record<string, string> | undefined {
|
|
326
|
+
if (raw === null || raw.trim() === "") return undefined;
|
|
327
|
+
let parsed: unknown;
|
|
328
|
+
try {
|
|
329
|
+
parsed = JSON.parse(raw);
|
|
330
|
+
} catch {
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
334
|
+
const out: Record<string, string> = {};
|
|
335
|
+
for (const [id, value] of Object.entries(parsed as Record<string, unknown>)) {
|
|
336
|
+
// The id is config-shaped, so trim it; the credential is copied verbatim.
|
|
337
|
+
const key = id.trim();
|
|
338
|
+
if (key !== "" && typeof value === "string") out[key] = value;
|
|
339
|
+
}
|
|
340
|
+
return Object.keys(out).length === 0 ? undefined : out;
|
|
341
|
+
}
|
|
342
|
+
|
|
318
343
|
export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
|
|
319
344
|
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
320
345
|
throw invalidRequest("Request body must be a JSON object");
|
|
@@ -352,6 +377,9 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
|
|
|
352
377
|
// Per-request routing policy (team edition): JSON in X-Omp-Policy.
|
|
353
378
|
const policy = parsePolicyHeader(headers.get("x-omp-policy"));
|
|
354
379
|
|
|
380
|
+
// Per-turn upstream credentials (team edition): JSON in X-Omp-Upstream-Keys.
|
|
381
|
+
const upstreamKeys = parseUpstreamKeysHeader(headers.get("x-omp-upstream-keys"));
|
|
382
|
+
|
|
355
383
|
if (typeof b.model !== "string" || b.model.length === 0) {
|
|
356
384
|
throw invalidRequest("model must be a non-empty string");
|
|
357
385
|
}
|
|
@@ -417,6 +445,7 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
|
|
|
417
445
|
agentdoxOrigin,
|
|
418
446
|
isSubagent,
|
|
419
447
|
...(policy === undefined ? {} : { policy }),
|
|
448
|
+
...(upstreamKeys === undefined ? {} : { upstreamKeys }),
|
|
420
449
|
requestedModel,
|
|
421
450
|
requestedModelFull: b.model,
|
|
422
451
|
messages,
|
package/src/wire/types.ts
CHANGED
|
@@ -126,6 +126,21 @@ export interface NormRequest {
|
|
|
126
126
|
* to. Absent ⇒ the configured profile and filters alone.
|
|
127
127
|
*/
|
|
128
128
|
policy?: RequestPolicy;
|
|
129
|
+
/**
|
|
130
|
+
* Per-turn upstream credentials from the `X-Omp-Upstream-Keys` header
|
|
131
|
+
* (JSON `{ "<upstream id>": "<credential>" }`), set by a front door whose
|
|
132
|
+
* callers bring their own keys: one router fleet then serves every tenant
|
|
133
|
+
* instead of one process per credential set. Keyed by upstream id —
|
|
134
|
+
* `openrouter`, `ollama`, or a named entry's `id`.
|
|
135
|
+
*
|
|
136
|
+
* An upstream named here dispatches with this credential for the whole
|
|
137
|
+
* turn, every retry and failover included; one not named keeps its
|
|
138
|
+
* configured `apiKey`; one named with `""` carries no credential and is
|
|
139
|
+
* excluded from candidate selection rather than dispatched keyless.
|
|
140
|
+
*
|
|
141
|
+
* A secret: never logged, recorded, or repeated in an error.
|
|
142
|
+
*/
|
|
143
|
+
upstreamKeys?: Readonly<Record<string, string>>;
|
|
129
144
|
/** Virtual model the client selected, e.g. `auto`, `auto-cheap`, `auto-max`. */
|
|
130
145
|
requestedModel: string;
|
|
131
146
|
/**
|
package/test/failover.test.ts
CHANGED
|
@@ -703,3 +703,44 @@ describe("400 classification (review 2026-09-05 follow-up)", () => {
|
|
|
703
703
|
});
|
|
704
704
|
});
|
|
705
705
|
|
|
706
|
+
describe("per-turn upstream credentials", () => {
|
|
707
|
+
test("the turn's credentials reach the first dispatch, the failover retry, and nothing else", async () => {
|
|
708
|
+
const { router } = mkRouter([mkDecision("moderate", "a/model"), mkDecision("moderate", "b/model")]);
|
|
709
|
+
const { upstream, calls: dispatches } = mkUpstream([
|
|
710
|
+
{ kind: "fail", error: new UpstreamError("model_unavailable", 404, "no endpoints found", true) },
|
|
711
|
+
{ kind: "chunks", chunks: okChunks("b/model") },
|
|
712
|
+
]);
|
|
713
|
+
const { ledger, entries } = mkLedger();
|
|
714
|
+
const { store } = mkConversations();
|
|
715
|
+
const { sink, errors, finishes } = mkSink();
|
|
716
|
+
const upstreamKeys = { "up-a": "sk-tenant-secret", "up-b": "sk-other-secret" };
|
|
717
|
+
|
|
718
|
+
await runTurn({ ...mkReq(), upstreamKeys }, sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog, context: createDisabledBridge() }, new AbortController().signal);
|
|
719
|
+
|
|
720
|
+
expect(errors).toHaveLength(0);
|
|
721
|
+
expect(finishes).toHaveLength(1);
|
|
722
|
+
// Both attempts carry them: a retry that dropped the credential would 401.
|
|
723
|
+
expect(dispatches).toHaveLength(2);
|
|
724
|
+
for (const d of dispatches) expect(d.upstreamKeys).toEqual(upstreamKeys);
|
|
725
|
+
|
|
726
|
+
// A secret: it is in no ledger row, no decision reason, and no error text.
|
|
727
|
+
const recorded = JSON.stringify(entries);
|
|
728
|
+
expect(recorded).not.toContain("sk-tenant-secret");
|
|
729
|
+
expect(recorded).not.toContain("sk-other-secret");
|
|
730
|
+
expect(JSON.stringify(finishes)).not.toContain("sk-tenant-secret");
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
test("a turn without the header dispatches with no override at all", async () => {
|
|
734
|
+
const { router } = mkRouter([mkDecision("moderate", "a/model")]);
|
|
735
|
+
const { upstream, calls: dispatches } = mkUpstream([{ kind: "chunks", chunks: okChunks("a/model") }]);
|
|
736
|
+
const { ledger } = mkLedger();
|
|
737
|
+
const { store } = mkConversations();
|
|
738
|
+
const { sink, errors } = mkSink();
|
|
739
|
+
|
|
740
|
+
await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog, context: createDisabledBridge() }, new AbortController().signal);
|
|
741
|
+
|
|
742
|
+
expect(errors).toHaveLength(0);
|
|
743
|
+
expect("upstreamKeys" in dispatches[0]!).toBe(false);
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ledger's day partitions, on every engine it claims to support.
|
|
3
|
+
*
|
|
4
|
+
* SQLite runs always (a temp file) and MUST be unchanged by any of this: it has
|
|
5
|
+
* no declarative partitioning, so every assertion below has to hold on the
|
|
6
|
+
* single table it always had. Postgres runs when AMR_ROUTER_TEST_PG points at
|
|
7
|
+
* one, following this repo's convention for store tests.
|
|
8
|
+
*
|
|
9
|
+
* What is actually being defended: retention on a store taking 33-165 GB a day
|
|
10
|
+
* cannot be a bulk DELETE, and the conversion to something cheaper must not
|
|
11
|
+
* change a single figure a bill is read from — nor lose a row on a deployment
|
|
12
|
+
* that upgrades into it.
|
|
13
|
+
*/
|
|
14
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
|
|
19
|
+
import type { RouterConfig } from "../src/config/types.ts";
|
|
20
|
+
import { createSqlLedger } from "../src/cost/ledger-sql.ts";
|
|
21
|
+
import { exportRows, spendUsdSince } from "../src/cost/views.ts";
|
|
22
|
+
import { EMPTY_USAGE, type AsyncLedger, type LedgerEntry } from "../src/cost/types.ts";
|
|
23
|
+
import type { Logger } from "../src/util/log.ts";
|
|
24
|
+
import {
|
|
25
|
+
droppableLedgerPartitions,
|
|
26
|
+
ensureLedgerPartitions,
|
|
27
|
+
ledgerDayStart,
|
|
28
|
+
ledgerLayout,
|
|
29
|
+
ledgerPartitionName,
|
|
30
|
+
migrateStore,
|
|
31
|
+
} from "../src/util/schema.ts";
|
|
32
|
+
import { num, openSqlDb, type SqlDb } from "../src/util/sql.ts";
|
|
33
|
+
|
|
34
|
+
const DAY = 86_400_000;
|
|
35
|
+
const PG = process.env.AMR_ROUTER_TEST_PG;
|
|
36
|
+
|
|
37
|
+
const engines: { name: string; url: string; partitions: boolean }[] = [
|
|
38
|
+
{ name: "sqlite", url: `sqlite://${join(tmpdir(), `ledger-part-${process.pid}-${Date.now()}.db`)}`, partitions: false },
|
|
39
|
+
...(PG === undefined ? [] : [{ name: "postgres", url: PG, partitions: true }]),
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function entry(over: Partial<LedgerEntry> & { id: string }): LedgerEntry {
|
|
43
|
+
return {
|
|
44
|
+
createdAtMs: Date.now(),
|
|
45
|
+
conversationKey: `conv-${over.id}`,
|
|
46
|
+
sessionId: `sess-${over.id}`,
|
|
47
|
+
turn: 1,
|
|
48
|
+
requestedModel: "auto",
|
|
49
|
+
harnessId: "hp",
|
|
50
|
+
ompSessionId: `omp-${over.id}`,
|
|
51
|
+
slug: "x/model",
|
|
52
|
+
servedSlug: "x/model",
|
|
53
|
+
tier: "simple",
|
|
54
|
+
classificationSource: "heuristic",
|
|
55
|
+
reasons: ["cheapest"],
|
|
56
|
+
predictedUsd: 0.001,
|
|
57
|
+
reportedUsd: 0.002,
|
|
58
|
+
usage: { ...EMPTY_USAGE, promptTokens: 100, completionTokens: 10 },
|
|
59
|
+
attempt: 0,
|
|
60
|
+
escalationSignal: null,
|
|
61
|
+
latencyMs: 10,
|
|
62
|
+
ttftMs: 5,
|
|
63
|
+
finishReason: "stop",
|
|
64
|
+
wasted: false,
|
|
65
|
+
upstreamGenerationId: null,
|
|
66
|
+
error: null,
|
|
67
|
+
features: null,
|
|
68
|
+
score: null,
|
|
69
|
+
confidence: null,
|
|
70
|
+
task: null,
|
|
71
|
+
classifierReasons: null,
|
|
72
|
+
exploredFrom: null,
|
|
73
|
+
holdArm: null,
|
|
74
|
+
promptTokensSaved: null,
|
|
75
|
+
scope: "team/proj",
|
|
76
|
+
redactions: null,
|
|
77
|
+
...over,
|
|
78
|
+
} as unknown as LedgerEntry;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Live day partitions of the ledger, by name; empty on an unpartitioned store. */
|
|
82
|
+
async function partitionsOf(db: SqlDb): Promise<string[]> {
|
|
83
|
+
if ((await ledgerLayout(db)) !== "partitioned") return [];
|
|
84
|
+
const rows = await db.query<{ name: string }>(
|
|
85
|
+
"SELECT c.relname AS name FROM pg_class c JOIN pg_inherits i ON i.inhrelid = c.oid WHERE i.inhparent = to_regclass('ledger') ORDER BY 1",
|
|
86
|
+
);
|
|
87
|
+
return rows.map((r) => r.name);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const engine of engines) {
|
|
91
|
+
describe(`ledger partitioning on ${engine.name}`, () => {
|
|
92
|
+
let db: SqlDb;
|
|
93
|
+
const cfg: RouterConfig = { ...structuredClone(DEFAULT_CONFIG), ledger: { ...DEFAULT_CONFIG.ledger, path: engine.url } };
|
|
94
|
+
let ledger: AsyncLedger;
|
|
95
|
+
|
|
96
|
+
/** A fresh store: what a NEW deployment boots into, not what an old one upgraded to. */
|
|
97
|
+
const fresh = async (): Promise<void> => {
|
|
98
|
+
if (db.dialect === "postgres") await db.sql.unsafe("DROP TABLE IF EXISTS ledger");
|
|
99
|
+
await migrateStore(db);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
beforeAll(async () => {
|
|
103
|
+
db = openSqlDb(engine.url);
|
|
104
|
+
await fresh();
|
|
105
|
+
ledger = createSqlLedger(db, cfg, { findModel: () => null });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
afterAll(async () => {
|
|
109
|
+
// Leave the shared database in the shape a fresh boot produces, whatever
|
|
110
|
+
// the legacy test below did to it.
|
|
111
|
+
await fresh();
|
|
112
|
+
await db.close();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
beforeEach(async () => {
|
|
116
|
+
await db.sql.unsafe("DELETE FROM ledger");
|
|
117
|
+
await db.sql.unsafe("DELETE FROM feedback");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("a fresh store partitions by day on postgres and not on sqlite, and a turn writes and reads back", async () => {
|
|
121
|
+
expect(await ledgerLayout(db)).toBe(engine.partitions ? "partitioned" : "plain");
|
|
122
|
+
// Ahead of need: today's partition exists before any turn arrives, and
|
|
123
|
+
// so do the next few days, so a turn at 23:59:59 cannot be the first to
|
|
124
|
+
// need tomorrow's.
|
|
125
|
+
const today = ledgerDayStart(Date.now());
|
|
126
|
+
const expected = engine.partitions
|
|
127
|
+
? [-1, 0, 1, 2, 3].map((d) => ledgerPartitionName(today + d * DAY))
|
|
128
|
+
: [];
|
|
129
|
+
expect(await partitionsOf(db)).toEqual(expected);
|
|
130
|
+
|
|
131
|
+
await ledger.record(entry({ id: "t1", reportedUsd: 0.5 }));
|
|
132
|
+
const recent = await ledger.recentEntries(10);
|
|
133
|
+
expect(recent.map((e) => e.id)).toEqual(["t1"]);
|
|
134
|
+
// The JSON columns and the money survive the trip through a partition.
|
|
135
|
+
expect(recent[0]?.usage.promptTokens).toBe(100);
|
|
136
|
+
expect(recent[0]?.reasons).toEqual(["cheapest"]);
|
|
137
|
+
expect(await ledger.spendSince(0, "hp")).toBeCloseTo(0.5, 9);
|
|
138
|
+
expect((await ledger.latestForSession("omp-t1"))?.id).toBe("t1");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("a turn for a day nobody provisioned still records, and the day exists afterwards", async () => {
|
|
142
|
+
// 40 days back is outside every partition `migrateStore` created. On
|
|
143
|
+
// Postgres the insert is refused once, the day is created, and the row
|
|
144
|
+
// lands — the caller never sees a failure, because a ledger row is a
|
|
145
|
+
// bill and there is no second copy of it.
|
|
146
|
+
const backdated = Date.now() - 40 * DAY;
|
|
147
|
+
await ledger.record(entry({ id: "late", createdAtMs: backdated, reportedUsd: 0.25 }));
|
|
148
|
+
// And forwards: a process that has been up for a week.
|
|
149
|
+
const ahead = Date.now() + 9 * DAY;
|
|
150
|
+
await ledger.record(entry({ id: "ahead", createdAtMs: ahead, reportedUsd: 0.75 }));
|
|
151
|
+
|
|
152
|
+
expect((await ledger.recentEntries(10)).map((e) => e.id).sort()).toEqual(["ahead", "late"]);
|
|
153
|
+
expect(await ledger.spendSince(0, "hp")).toBeCloseTo(1, 9);
|
|
154
|
+
if (!engine.partitions) return;
|
|
155
|
+
const live = await partitionsOf(db);
|
|
156
|
+
expect(live).toContain(ledgerPartitionName(ledgerDayStart(backdated)));
|
|
157
|
+
expect(live).toContain(ledgerPartitionName(ledgerDayStart(ahead)));
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("prune drops whole days, keeps the recent window, and counts rows rather than partitions", async () => {
|
|
161
|
+
// One instant for both the rows and the cutoff: `oldestKeptMs` is an
|
|
162
|
+
// exact row timestamp, so a second Date.now() would be milliseconds off.
|
|
163
|
+
const nowMs = Date.now();
|
|
164
|
+
for (const age of [400, 200, 1]) {
|
|
165
|
+
await ledger.record(entry({ id: `p${age}`, createdAtMs: nowMs - age * DAY }));
|
|
166
|
+
}
|
|
167
|
+
expect(await ledger.prune(365, nowMs)).toEqual({ deleted: 1, oldestKeptMs: expect.any(Number) });
|
|
168
|
+
expect((await ledger.recentEntries(10)).map((e) => e.id).sort()).toEqual(["p1", "p200"]);
|
|
169
|
+
if (engine.partitions) {
|
|
170
|
+
// The dropped day is gone as a table, not just as rows.
|
|
171
|
+
expect(await partitionsOf(db)).not.toContain(ledgerPartitionName(ledgerDayStart(nowMs - 400 * DAY)));
|
|
172
|
+
expect(await partitionsOf(db)).toContain(ledgerPartitionName(ledgerDayStart(nowMs - 200 * DAY)));
|
|
173
|
+
}
|
|
174
|
+
expect((await ledger.prune(30, nowMs)).deleted).toBe(1);
|
|
175
|
+
expect((await ledger.recentEntries(10)).map((e) => e.id)).toEqual(["p1"]);
|
|
176
|
+
// Nothing left to remove, and the window is reported from real rows.
|
|
177
|
+
expect(await ledger.prune(30, nowMs)).toEqual({ deleted: 0, oldestKeptMs: nowMs - DAY });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("the boundary day the cutoff falls inside is pruned row by row, so the count stays honest", async () => {
|
|
181
|
+
// The cutoff at noon splits a day: its partition holds rows on both
|
|
182
|
+
// sides of it and must not be dropped, while the day before it goes
|
|
183
|
+
// whole. `deleted` has to count both kinds of row and nothing else.
|
|
184
|
+
const cutoff = ledgerDayStart(Date.now()) + 12 * 3_600_000;
|
|
185
|
+
const nowMs = cutoff + DAY;
|
|
186
|
+
await ledger.record(entry({ id: "b_before", createdAtMs: cutoff - 60_000 }));
|
|
187
|
+
await ledger.record(entry({ id: "b_after", createdAtMs: cutoff + 60_000 }));
|
|
188
|
+
await ledger.record(entry({ id: "b_whole", createdAtMs: cutoff - 2 * DAY }));
|
|
189
|
+
|
|
190
|
+
expect((await ledger.prune(1, nowMs)).deleted).toBe(2);
|
|
191
|
+
expect((await ledger.recentEntries(10)).map((e) => e.id)).toEqual(["b_after"]);
|
|
192
|
+
if (!engine.partitions) return;
|
|
193
|
+
const live = await partitionsOf(db);
|
|
194
|
+
expect(live).toContain(ledgerPartitionName(ledgerDayStart(cutoff)));
|
|
195
|
+
expect(live).not.toContain(ledgerPartitionName(ledgerDayStart(cutoff - 2 * DAY)));
|
|
196
|
+
// And the partial day was not covered by a drop: the helper only ever
|
|
197
|
+
// offers partitions whose whole range is behind the cutoff.
|
|
198
|
+
expect(await droppableLedgerPartitions(db, cutoff)).not.toContain(ledgerPartitionName(ledgerDayStart(cutoff)));
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("export, spend and cap reads see exactly what they saw before partitioning", async () => {
|
|
202
|
+
const day = ledgerDayStart(Date.now()) + 3_600_000;
|
|
203
|
+
await ledger.record(entry({ id: "v1", createdAtMs: day, reportedUsd: 1 }));
|
|
204
|
+
await ledger.record(entry({ id: "v2", createdAtMs: day + 60_000, reportedUsd: 2, harnessId: "other" }));
|
|
205
|
+
await ledger.record(entry({ id: "v3", createdAtMs: day - 2 * DAY, reportedUsd: 4 }));
|
|
206
|
+
|
|
207
|
+
const rows = await exportRows(db, 0, null);
|
|
208
|
+
expect(rows.map((r) => [r.day, r.harnessId, r.dispatches, r.spendUsd])).toEqual([
|
|
209
|
+
[new Date(day - 2 * DAY).toISOString().slice(0, 10), "hp", 1, 4],
|
|
210
|
+
[new Date(day).toISOString().slice(0, 10), "hp", 1, 1],
|
|
211
|
+
[new Date(day).toISOString().slice(0, 10), "other", 1, 2],
|
|
212
|
+
]);
|
|
213
|
+
expect(rows.every((r) => r.scope === "team/proj")).toBe(true);
|
|
214
|
+
// The cap reads: whole ledger, one harness, one window, one project.
|
|
215
|
+
expect(await spendUsdSince(db, 0, null)).toBeCloseTo(7, 9);
|
|
216
|
+
expect(await spendUsdSince(db, 0, ["hp"])).toBeCloseTo(5, 9);
|
|
217
|
+
expect(await spendUsdSince(db, day, null)).toBeCloseTo(3, 9);
|
|
218
|
+
expect(await spendUsdSince(db, 0, null, "team/proj")).toBeCloseTo(7, 9);
|
|
219
|
+
expect(await ledger.spendSince(day - DAY, "hp")).toBeCloseTo(1, 9);
|
|
220
|
+
expect(await ledger.conversationSpend("conv-v3")).toBeCloseTo(4, 9);
|
|
221
|
+
expect((await ledger.trust("x/model", "hp"))?.attempts).toBe(2);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("ensuring partitions is idempotent and never touches an unpartitioned store", async () => {
|
|
225
|
+
const before = await partitionsOf(db);
|
|
226
|
+
await ensureLedgerPartitions(db);
|
|
227
|
+
await ensureLedgerPartitions(db);
|
|
228
|
+
expect(await partitionsOf(db)).toEqual(before);
|
|
229
|
+
expect(await ledgerLayout(db)).toBe(engine.partitions ? "partitioned" : "plain");
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The upgrade path. A deployment with a populated, unpartitioned `ledger` is
|
|
236
|
+
* holding billing history with no second copy, and Postgres cannot convert a
|
|
237
|
+
* table to a partitioned one in place — so the chosen behaviour is to leave it
|
|
238
|
+
* exactly as it is, say so once, and keep working.
|
|
239
|
+
*/
|
|
240
|
+
describe.skipIf(PG === undefined)("a legacy unpartitioned postgres ledger", () => {
|
|
241
|
+
let db: SqlDb;
|
|
242
|
+
const cfg: RouterConfig = { ...structuredClone(DEFAULT_CONFIG), ledger: { ...DEFAULT_CONFIG.ledger, path: PG ?? "" } };
|
|
243
|
+
const warnings: string[] = [];
|
|
244
|
+
const capture: Logger = {
|
|
245
|
+
error: () => {},
|
|
246
|
+
warn: (msg) => warnings.push(msg),
|
|
247
|
+
info: () => {},
|
|
248
|
+
debug: () => {},
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
beforeAll(async () => {
|
|
252
|
+
db = openSqlDb(PG as string);
|
|
253
|
+
await db.sql.unsafe("DROP TABLE IF EXISTS ledger");
|
|
254
|
+
await migrateStore(db);
|
|
255
|
+
// Reshape it into what an older release left behind: the same columns, one
|
|
256
|
+
// plain table. `LIKE` copies the column list off the partitioned parent,
|
|
257
|
+
// so this fixture cannot drift from the real schema.
|
|
258
|
+
await db.sql.unsafe("DROP TABLE IF EXISTS ledger_legacy_fixture");
|
|
259
|
+
await db.sql.unsafe("CREATE TABLE ledger_legacy_fixture (LIKE ledger INCLUDING DEFAULTS)");
|
|
260
|
+
await db.sql.unsafe("DROP TABLE ledger");
|
|
261
|
+
await db.sql.unsafe("ALTER TABLE ledger_legacy_fixture RENAME TO ledger");
|
|
262
|
+
await db.sql.unsafe("ALTER TABLE ledger ADD PRIMARY KEY (id)");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
afterAll(async () => {
|
|
266
|
+
await db.sql.unsafe("DROP TABLE IF EXISTS ledger");
|
|
267
|
+
await migrateStore(db);
|
|
268
|
+
await db.close();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("it is left alone, its rows are kept, and the operator is told once how to convert", async () => {
|
|
272
|
+
const ledger = createSqlLedger(db, cfg, { findModel: () => null });
|
|
273
|
+
await ledger.record(entry({ id: "legacy1", createdAtMs: Date.now() - 400 * DAY, reportedUsd: 3 }));
|
|
274
|
+
warnings.length = 0;
|
|
275
|
+
|
|
276
|
+
await migrateStore(db, capture);
|
|
277
|
+
|
|
278
|
+
expect(await ledgerLayout(db)).toBe("plain");
|
|
279
|
+
expect(num((await db.one<{ n: unknown }>("SELECT COUNT(*) AS n FROM ledger"))?.n)).toBe(1);
|
|
280
|
+
expect(warnings.filter((w) => w.includes("not partitioned"))).toHaveLength(1);
|
|
281
|
+
// Still a working ledger, and retention still applies — row-wise, which is
|
|
282
|
+
// the cost of not converting.
|
|
283
|
+
await ledger.record(entry({ id: "legacy2", reportedUsd: 5 }));
|
|
284
|
+
expect(await ledger.spendSince(0, "hp")).toBeCloseTo(8, 9);
|
|
285
|
+
expect(await droppableLedgerPartitions(db, Date.now())).toEqual([]);
|
|
286
|
+
expect((await ledger.prune(365)).deleted).toBe(1);
|
|
287
|
+
expect((await ledger.recentEntries(10)).map((e) => e.id)).toEqual(["legacy2"]);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("every read returns the same figures on the partitioned table as on the legacy one", async () => {
|
|
291
|
+
// The same three turns, read twice: once through the table a deployment
|
|
292
|
+
// already has, once through the partitioned one a fresh boot creates. Any
|
|
293
|
+
// difference here is a bill that changed because of a storage decision.
|
|
294
|
+
const rows = [
|
|
295
|
+
entry({ id: "same1", createdAtMs: ledgerDayStart(Date.now()) + 3_600_000, reportedUsd: 1 }),
|
|
296
|
+
entry({ id: "same2", createdAtMs: ledgerDayStart(Date.now()) + 7_200_000, reportedUsd: 2, harnessId: "other" }),
|
|
297
|
+
entry({ id: "same3", createdAtMs: ledgerDayStart(Date.now()) - 5 * DAY, reportedUsd: 4 }),
|
|
298
|
+
];
|
|
299
|
+
const read = async (): Promise<unknown> => {
|
|
300
|
+
const ledger = createSqlLedger(db, cfg, { findModel: () => null });
|
|
301
|
+
await db.sql.unsafe("DELETE FROM ledger");
|
|
302
|
+
for (const row of rows) await ledger.record(row);
|
|
303
|
+
return {
|
|
304
|
+
exported: await exportRows(db, 0, null),
|
|
305
|
+
all: await spendUsdSince(db, 0, null),
|
|
306
|
+
scoped: await spendUsdSince(db, 0, ["hp"]),
|
|
307
|
+
project: await spendUsdSince(db, 0, null, "team/proj"),
|
|
308
|
+
trust: await ledger.trust("x/model"),
|
|
309
|
+
entries: (await ledger.recentEntries(10)).map((e) => e.id),
|
|
310
|
+
};
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
expect(await ledgerLayout(db)).toBe("plain");
|
|
314
|
+
const legacy = await read();
|
|
315
|
+
|
|
316
|
+
await db.sql.unsafe("DROP TABLE ledger");
|
|
317
|
+
await migrateStore(db);
|
|
318
|
+
expect(await ledgerLayout(db)).toBe("partitioned");
|
|
319
|
+
expect(await read()).toEqual(legacy);
|
|
320
|
+
});
|
|
321
|
+
});
|