auto-model-router 0.33.0 → 0.35.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 +10 -0
- package/docs/data-governance.md +42 -1
- package/omp-extension/router-toast.ts +14 -4
- package/omp-extension/toast-logic.ts +20 -2
- package/package.json +1 -1
- package/src/catalog/composite.ts +6 -1
- package/src/cli/config-wizard.ts +2 -0
- package/src/config/defaults.ts +5 -0
- package/src/config/schema.ts +2 -0
- package/src/config/types.ts +9 -0
- package/src/cost/ledger-sql.ts +41 -3
- package/src/server/http.ts +1 -1
- package/src/server/providers.ts +12 -0
- package/src/upstream/anthropic.ts +2 -1
- package/src/upstream/compat.ts +4 -2
- package/src/upstream/openrouter-usage.ts +120 -0
- package/src/upstream/openrouter.ts +6 -1
- package/src/util/schema.ts +148 -19
- package/test/failover.test.ts +42 -1
- package/test/ledger-partitions.test.ts +321 -0
- package/test/openrouter-usage.test.ts +59 -0
- package/test/toast-logic.test.ts +55 -0
- package/test/turn.test.ts +1 -1
- package/test/upstreams.test.ts +26 -0
package/src/util/schema.ts
CHANGED
|
@@ -8,19 +8,137 @@
|
|
|
8
8
|
* has no history to migrate, and on an already-migrated file every statement
|
|
9
9
|
* here is a no-op.
|
|
10
10
|
*
|
|
11
|
+
* On Postgres the ledger is additionally PARTITIONED BY RANGE over
|
|
12
|
+
* `created_at_ms`, one partition per UTC day. Retention is then a metadata
|
|
13
|
+
* DROP rather than a bulk DELETE, which matters at the sizes this store is
|
|
14
|
+
* built for: a ledger row is ~2.6 kB, and 10k-50k users write 33-165 GB a day
|
|
15
|
+
* at 440-2200 writes a second. Deleting that competes for I/O with the inserts
|
|
16
|
+
* it is trying to make room for, and leaves bloat autovacuum has to chase.
|
|
17
|
+
*
|
|
18
|
+
* Every read is unchanged: the bounds ARE ms-epoch integers, so a
|
|
19
|
+
* `created_at_ms >= x` filter prunes partitions on its own and no query needs
|
|
20
|
+
* to know the table is partitioned. SQLite has no declarative partitioning and
|
|
21
|
+
* keeps exactly the shape it always had.
|
|
22
|
+
*
|
|
11
23
|
* The two must agree, so the definitions below are transcribed from
|
|
12
24
|
* `util/sqlite.ts` with its incremental columns folded in, and
|
|
13
25
|
* `test/schema.test.ts` compares the two engine-by-engine rather than trusting
|
|
14
26
|
* that they were copied correctly.
|
|
15
27
|
*/
|
|
16
28
|
|
|
29
|
+
import type { Logger } from "./log.ts";
|
|
17
30
|
import type { SqlDb } from "./sql.ts";
|
|
18
31
|
|
|
32
|
+
const DAY_MS = 86_400_000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Days of ledger partitions provisioned ahead of today. A turn at 23:59:59
|
|
36
|
+
* must not depend on a partition created at 00:00:00, and a process that runs
|
|
37
|
+
* for days without rebooting still has runway.
|
|
38
|
+
*/
|
|
39
|
+
const LEDGER_PARTITION_AHEAD_DAYS = 3;
|
|
40
|
+
|
|
41
|
+
/** The start of the UTC day an ms-epoch instant falls in — a partition bound. */
|
|
42
|
+
export function ledgerDayStart(ms: number): number {
|
|
43
|
+
return Math.floor(ms / DAY_MS) * DAY_MS;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The partition holding the UTC day that starts at `dayStartMs`, e.g. `ledger_p20260914`. */
|
|
47
|
+
export function ledgerPartitionName(dayStartMs: number): string {
|
|
48
|
+
return `ledger_p${new Date(dayStartMs).toISOString().slice(0, 10).replaceAll("-", "")}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `IF NOT EXISTS` is not atomic on Postgres: two replicas booting against a
|
|
53
|
+
* fresh database both pass the existence check and the loser fails on the
|
|
54
|
+
* unique index over pg_type (23505), or on the table name itself (42P07 /
|
|
55
|
+
* 42710). Measured: one of two replicas started together died with
|
|
56
|
+
* "duplicate key value violates unique constraint pg_type_typname_nsp_index".
|
|
57
|
+
* The condition those errors report is the condition the statement asked to
|
|
58
|
+
* tolerate, so they are the success case arriving from the other replica.
|
|
59
|
+
*/
|
|
60
|
+
const RACED = new Set(["23505", "42P07", "42710"]);
|
|
61
|
+
|
|
62
|
+
async function createIfAbsent(db: SqlDb, statement: string): Promise<void> {
|
|
63
|
+
try {
|
|
64
|
+
await db.sql.unsafe(statement);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
const code = err !== null && typeof err === "object" && "errno" in err ? String(err.errno) : "";
|
|
67
|
+
if (!RACED.has(code)) throw err;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* How this store holds the ledger.
|
|
73
|
+
*
|
|
74
|
+
* `plain` is SQLite, and also a Postgres ledger created before partitioning
|
|
75
|
+
* shipped: Postgres cannot convert a populated table to a partitioned one in
|
|
76
|
+
* place, so an existing deployment keeps the table it has (see `migrateStore`).
|
|
77
|
+
*/
|
|
78
|
+
export async function ledgerLayout(db: SqlDb): Promise<"partitioned" | "plain" | "absent"> {
|
|
79
|
+
if (db.dialect !== "postgres") return (await db.tableExists("ledger")) ? "plain" : "absent";
|
|
80
|
+
const rows = await db.query<{ relkind: string }>("SELECT relkind FROM pg_class WHERE oid = to_regclass('ledger')");
|
|
81
|
+
const kind = rows[0]?.relkind;
|
|
82
|
+
if (kind === "p") return "partitioned";
|
|
83
|
+
return kind === undefined ? "absent" : "plain";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Creates the day partitions around `aroundMs` — yesterday through
|
|
88
|
+
* `aheadDays` — so the write path never meets a day nobody provisioned.
|
|
89
|
+
* Idempotent and a no-op on any store whose ledger is not partitioned.
|
|
90
|
+
*
|
|
91
|
+
* Yesterday is included because a row's `created_at_ms` is the instant the turn
|
|
92
|
+
* STARTED: a boot just after midnight, or a replica whose clock trails the one
|
|
93
|
+
* that provisioned, can still present the previous day.
|
|
94
|
+
*/
|
|
95
|
+
export async function ensureLedgerPartitions(db: SqlDb, aroundMs = Date.now(), aheadDays = LEDGER_PARTITION_AHEAD_DAYS): Promise<void> {
|
|
96
|
+
if ((await ledgerLayout(db)) !== "partitioned") return;
|
|
97
|
+
const today = ledgerDayStart(aroundMs);
|
|
98
|
+
for (let start = today - DAY_MS; start <= today + aheadDays * DAY_MS; start += DAY_MS) {
|
|
99
|
+
await createIfAbsent(
|
|
100
|
+
db,
|
|
101
|
+
`CREATE TABLE IF NOT EXISTS ${ledgerPartitionName(start)} PARTITION OF ledger FOR VALUES FROM (${start}) TO (${start + DAY_MS})`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Ledger partitions whose whole range ends at or before `upToMs`, oldest
|
|
108
|
+
* first: the ones a retention cutoff covers completely.
|
|
109
|
+
*
|
|
110
|
+
* Bounds are read from the catalog rather than parsed out of the partition's
|
|
111
|
+
* name, so a partition an operator attached by hand is judged on what it
|
|
112
|
+
* actually holds, and one with an unreadable or DEFAULT bound is skipped
|
|
113
|
+
* rather than guessed at — the rows in it are then pruned by the row-wise
|
|
114
|
+
* DELETE, which is slower but never drops a day it did not verify.
|
|
115
|
+
*/
|
|
116
|
+
export async function droppableLedgerPartitions(db: SqlDb, upToMs: number): Promise<string[]> {
|
|
117
|
+
if ((await ledgerLayout(db)) !== "partitioned") return [];
|
|
118
|
+
const rows = await db.query<{ name: string; bound: string | null }>(
|
|
119
|
+
`SELECT c.relname AS name, pg_get_expr(c.relpartbound, c.oid) AS bound
|
|
120
|
+
FROM pg_class c JOIN pg_inherits i ON i.inhrelid = c.oid
|
|
121
|
+
WHERE i.inhparent = to_regclass('ledger')`,
|
|
122
|
+
);
|
|
123
|
+
const covered: { name: string; endMs: number }[] = [];
|
|
124
|
+
for (const row of rows) {
|
|
125
|
+
const upper = /TO \('?(-?\d+)'?\)/.exec(row.bound ?? "");
|
|
126
|
+
if (upper === null) continue;
|
|
127
|
+
const endMs = Number(upper[1]);
|
|
128
|
+
if (endMs <= upToMs) covered.push({ name: row.name, endMs });
|
|
129
|
+
}
|
|
130
|
+
covered.sort((a, b) => a.endMs - b.endMs);
|
|
131
|
+
return covered.map((p) => p.name);
|
|
132
|
+
}
|
|
133
|
+
|
|
19
134
|
/**
|
|
20
135
|
* Creates every table and index the router uses. Idempotent, so boot order
|
|
21
136
|
* never matters — the property the SQLite bootstrap has always had.
|
|
22
137
|
*/
|
|
23
|
-
export async function migrateStore(db: SqlDb): Promise<void> {
|
|
138
|
+
export async function migrateStore(db: SqlDb, log?: Logger): Promise<void> {
|
|
139
|
+
// Postgres partitions the ledger by day; SQLite has no declarative
|
|
140
|
+
// partitioning, so it keeps the single table it always had.
|
|
141
|
+
const partitioned = db.dialect === "postgres" && (await ledgerLayout(db)) !== "plain";
|
|
24
142
|
const json = db.type("json");
|
|
25
143
|
const float = db.type("float");
|
|
26
144
|
const big = db.type("bigint");
|
|
@@ -41,8 +159,14 @@ export async function migrateStore(db: SqlDb): Promise<void> {
|
|
|
41
159
|
|
|
42
160
|
// One row per dispatched upstream generation. The columns nineteen
|
|
43
161
|
// migrations added are declared here as they finally stand.
|
|
162
|
+
//
|
|
163
|
+
// Partitioned by day on Postgres, which forces the primary key to
|
|
164
|
+
// include the partition key — a unique index on a partitioned table has
|
|
165
|
+
// to. `id` alone stays unique in practice (it is a fresh UUID per entry),
|
|
166
|
+
// and a re-recorded entry carries the same `created_at_ms`, so the
|
|
167
|
+
// ON CONFLICT guard in `record` still collapses it.
|
|
44
168
|
`CREATE TABLE IF NOT EXISTS ledger (
|
|
45
|
-
id TEXT PRIMARY KEY,
|
|
169
|
+
id TEXT${partitioned ? "" : " PRIMARY KEY"},
|
|
46
170
|
created_at_ms ${big} NOT NULL,
|
|
47
171
|
conversation_key TEXT NOT NULL,
|
|
48
172
|
session_id TEXT NOT NULL,
|
|
@@ -77,8 +201,11 @@ export async function migrateStore(db: SqlDb): Promise<void> {
|
|
|
77
201
|
hold_arm INTEGER,
|
|
78
202
|
prompt_tokens_saved INTEGER,
|
|
79
203
|
scope TEXT,
|
|
80
|
-
redactions INTEGER
|
|
81
|
-
)`,
|
|
204
|
+
redactions INTEGER${partitioned ? ",\n\t\t\tPRIMARY KEY (id, created_at_ms)" : ""}
|
|
205
|
+
)${partitioned ? " PARTITION BY RANGE (created_at_ms)" : ""}`,
|
|
206
|
+
// The composite key above cannot serve a lookup by `id` alone, which is
|
|
207
|
+
// what the feedback join and `markWasted` do.
|
|
208
|
+
...(partitioned ? ["CREATE INDEX IF NOT EXISTS idx_ledger_id ON ledger (id)"] : []),
|
|
82
209
|
"CREATE INDEX IF NOT EXISTS idx_ledger_conversation ON ledger (conversation_key)",
|
|
83
210
|
"CREATE INDEX IF NOT EXISTS idx_ledger_created ON ledger (created_at_ms)",
|
|
84
211
|
"CREATE INDEX IF NOT EXISTS idx_ledger_slug ON ledger (slug)",
|
|
@@ -166,22 +293,24 @@ export async function migrateStore(db: SqlDb): Promise<void> {
|
|
|
166
293
|
)`,
|
|
167
294
|
];
|
|
168
295
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
for (const statement of statements) {
|
|
178
|
-
try {
|
|
179
|
-
await db.sql.unsafe(statement);
|
|
180
|
-
} catch (err) {
|
|
181
|
-
const code = (err as { errno?: unknown }).errno;
|
|
182
|
-
if (!RACED.has(String(code))) throw err;
|
|
183
|
-
}
|
|
296
|
+
for (const statement of statements) await createIfAbsent(db, statement);
|
|
297
|
+
|
|
298
|
+
if (db.dialect !== "postgres") return;
|
|
299
|
+
if (partitioned) {
|
|
300
|
+
// Ahead of need: the write path must never be the thing that discovers a
|
|
301
|
+
// day has no partition (it recovers, but a bill should not depend on that).
|
|
302
|
+
await ensureLedgerPartitions(db);
|
|
303
|
+
return;
|
|
184
304
|
}
|
|
305
|
+
// A Postgres ledger from before this shipped. Postgres cannot convert a
|
|
306
|
+
// populated table to a partitioned one in place, and copying a billing table
|
|
307
|
+
// at boot is the one failure mode the ledger must not have, so the existing
|
|
308
|
+
// table is left exactly as it is and retention keeps deleting rows. The
|
|
309
|
+
// conversion is an operator's decision, taken with the router stopped.
|
|
310
|
+
log?.warn("ledger is not partitioned by day, so retention will delete rows instead of dropping partitions; partitioning applies to new deployments", {
|
|
311
|
+
convert:
|
|
312
|
+
"stop every replica, then: ALTER TABLE ledger RENAME TO ledger_legacy; start the router (it recreates ledger partitioned); INSERT INTO ledger SELECT * FROM ledger_legacy; verify the counts match; DROP TABLE ledger_legacy",
|
|
313
|
+
});
|
|
185
314
|
}
|
|
186
315
|
|
|
187
316
|
/** Every table `migrateStore` creates, for tests and for teardown. */
|
package/test/failover.test.ts
CHANGED
|
@@ -31,7 +31,7 @@ import type {
|
|
|
31
31
|
function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
32
32
|
return {
|
|
33
33
|
server: { host: "127.0.0.1", port: 8787, maxConcurrentTurns: 24, subagentProfile: "auto-sub" },
|
|
34
|
-
openrouter: { baseUrl: "https://openrouter.ai/api/v1", apiKey: "", title: "test", timeoutMs: 30_000, catalogTtlMs: 3_600_000, catalogRefreshMs: 0 },
|
|
34
|
+
openrouter: { baseUrl: "https://openrouter.ai/api/v1", apiKey: "", title: "test", timeoutMs: 30_000, catalogTtlMs: 3_600_000, catalogRefreshMs: 0, minCreditsUsd: 0, usagePollMs: 0 },
|
|
35
35
|
ollama: { enabled: false, baseUrl: "http://127.0.0.1:11434/v1", apiKey: "", timeoutMs: 30_000, catalogTtlMs: 300_000, includeLocal: false, prices: {}, twins: {}, costBias: 1, biasUntilUsage: 0.9, usagePollMs: 0, quotaCooldownMs: 0, rateLimitCooldownMs: 0, planCreditsUsd: 0 },
|
|
36
36
|
upstreams: [],
|
|
37
37
|
benchmarks: { enabled: false, artificialAnalysisApiKey: "", benchlm: true, refreshMs: 86_400_000, timeoutMs: 30_000, useLocalScores: false },
|
|
@@ -380,6 +380,33 @@ describe("same-tier failover", () => {
|
|
|
380
380
|
expect(finishes[0]!.attempts).toBe(2);
|
|
381
381
|
});
|
|
382
382
|
|
|
383
|
+
test("a Kimi credit 429 fails over to another provider's model in the SAME turn", async () => {
|
|
384
|
+
// Moonshot's balance wording misclassified as rate_limit used to let the
|
|
385
|
+
// raw 429 (Retry-After 30 min) reach the client. Quota is retryable
|
|
386
|
+
// elsewhere: the retry re-routes with the failed slug excluded.
|
|
387
|
+
const { router, calls } = mkRouter([mkDecision("moderate", "kimi/kimi-k3"), mkDecision("moderate", "openrouter/grok-4")]);
|
|
388
|
+
const { upstream, calls: dispatches } = mkUpstream([
|
|
389
|
+
{ kind: "fail", error: new UpstreamError("quota", 429, "This request would exceed your available credits given your current in-flight requests", true) },
|
|
390
|
+
{ kind: "chunks", chunks: okChunks("openrouter/grok-4") },
|
|
391
|
+
]);
|
|
392
|
+
const { ledger, entries } = mkLedger();
|
|
393
|
+
const { store } = mkConversations();
|
|
394
|
+
const { sink, errors, finishes } = mkSink();
|
|
395
|
+
|
|
396
|
+
await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog, context: createDisabledBridge() }, new AbortController().signal);
|
|
397
|
+
|
|
398
|
+
expect(errors).toHaveLength(0);
|
|
399
|
+
expect(finishes).toHaveLength(1);
|
|
400
|
+
expect(calls).toHaveLength(2);
|
|
401
|
+
expect(calls[1]).toEqual({ attempt: 1, excludeSlugs: ["kimi/kimi-k3"] });
|
|
402
|
+
expect(dispatches.map((d) => d.body.model)).toEqual(["kimi/kimi-k3", "openrouter/grok-4"]);
|
|
403
|
+
expect(entries[0]!.error).toContain("available credits");
|
|
404
|
+
expect(entries[0]!.wasted).toBe(true);
|
|
405
|
+
expect(entries[1]!.wasted).toBe(false);
|
|
406
|
+
expect(finishes[0]!.servedSlug).toBe("openrouter/grok-4");
|
|
407
|
+
expect(finishes[0]!.escalated).toBe(false);
|
|
408
|
+
});
|
|
409
|
+
|
|
383
410
|
test("a 403 moderation block fails over to a different model in the same tier", async () => {
|
|
384
411
|
const { router, calls } = mkRouter([mkDecision("trivial", "a/model"), mkDecision("trivial", "b/model")]);
|
|
385
412
|
const { upstream, calls: dispatches } = mkUpstream([
|
|
@@ -664,6 +691,7 @@ describe("same-tier failover", () => {
|
|
|
664
691
|
|
|
665
692
|
});
|
|
666
693
|
|
|
694
|
+
|
|
667
695
|
describe("400 classification (review 2026-09-05 follow-up)", () => {
|
|
668
696
|
test("a 400 naming a model capability limit is retryable, so failover picks a sibling", async () => {
|
|
669
697
|
const e = classifyUpstreamStatus(400, { error: { message: "This model only supports single tool-calls at once!" } });
|
|
@@ -677,6 +705,19 @@ describe("400 classification (review 2026-09-05 follow-up)", () => {
|
|
|
677
705
|
expect(e.retryable).toBe(false);
|
|
678
706
|
});
|
|
679
707
|
|
|
708
|
+
test("an OpenRouter 402 naming in-flight requests is a concurrency throttle, retryable like a rate limit", async () => {
|
|
709
|
+
// OpenRouter reserves credits per in-flight request: a burst can exhaust
|
|
710
|
+
// the UNRESERVED balance on an account with plenty left. It clears when
|
|
711
|
+
// the streams settle — fail over, never surface to the client.
|
|
712
|
+
const e = classifyUpstreamStatus(402, { error: { message: "This request would exceed your available credits given your current in-flight requests. Retry after in-flight requests settle, or add credits." } });
|
|
713
|
+
expect(e.kind).toBe("rate_limit");
|
|
714
|
+
expect(e.retryable).toBe(true);
|
|
715
|
+
// A plain 402 (balance actually gone) stays final.
|
|
716
|
+
const broke = classifyUpstreamStatus(402, { error: { message: "Insufficient credits" } });
|
|
717
|
+
expect(broke.kind).toBe("auth");
|
|
718
|
+
expect(broke.retryable).toBe(false);
|
|
719
|
+
});
|
|
720
|
+
|
|
680
721
|
test("a 400 for context overflow is still context_length", async () => {
|
|
681
722
|
const e = classifyUpstreamStatus(400, { error: { message: "This endpoint's maximum context length is 131072 tokens" } });
|
|
682
723
|
expect(e.kind).toBe("context_length");
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createOpenRouterUsageSource, NO_OPENROUTER_USAGE, openRouterServing, type OpenRouterCredits } from "../src/upstream/openrouter-usage.ts";
|
|
3
|
+
import { createLogger } from "../src/util/log.ts";
|
|
4
|
+
|
|
5
|
+
const log = createLogger("error");
|
|
6
|
+
|
|
7
|
+
function credits(remaining: number | null): OpenRouterCredits | null {
|
|
8
|
+
return remaining === null ? null : { remainingUsd: remaining, totalCreditsUsd: 100, totalUsageUsd: 100 - remaining, fetchedAtMs: 1 };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("openRouterServing", () => {
|
|
12
|
+
test("serves above the floor and stops at/below it", () => {
|
|
13
|
+
expect(openRouterServing(credits(5.01), 5)).toBe(true);
|
|
14
|
+
expect(openRouterServing(credits(5), 5)).toBe(false);
|
|
15
|
+
expect(openRouterServing(credits(0), 5)).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("fails open on unknown balance and when the gate is off", () => {
|
|
19
|
+
expect(openRouterServing(null, 5)).toBe(true);
|
|
20
|
+
expect(openRouterServing(credits(0), 0)).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("createOpenRouterUsageSource", () => {
|
|
25
|
+
test("returns null without a key and parses the credits payload", async () => {
|
|
26
|
+
const unkeyed = createOpenRouterUsageSource({ apiKey: () => "", pollMs: 1000, timeoutMs: 100, log, root: "https://x/v1" });
|
|
27
|
+
expect(await unkeyed.get()).toBe(null);
|
|
28
|
+
expect(NO_OPENROUTER_USAGE.peek()).toBe(null);
|
|
29
|
+
|
|
30
|
+
let calls = 0;
|
|
31
|
+
const src = createOpenRouterUsageSource({
|
|
32
|
+
apiKey: () => "k",
|
|
33
|
+
pollMs: 1000,
|
|
34
|
+
timeoutMs: 100,
|
|
35
|
+
log,
|
|
36
|
+
root: "https://x/v1",
|
|
37
|
+
fetchImpl: async () => {
|
|
38
|
+
calls++;
|
|
39
|
+
return new Response(JSON.stringify({ data: { total_credits: 20, total_usage: 13.5 } }), { status: 200 });
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
const v = await src.get();
|
|
43
|
+
expect(calls).toBe(1);
|
|
44
|
+
expect(v?.remainingUsd).toBe(6.5);
|
|
45
|
+
expect(src.peek()?.totalUsageUsd).toBe(13.5);
|
|
46
|
+
|
|
47
|
+
// A failed poll keeps the last good reading instead of hiding the provider.
|
|
48
|
+
const failing = createOpenRouterUsageSource({
|
|
49
|
+
apiKey: () => "k",
|
|
50
|
+
pollMs: 0,
|
|
51
|
+
timeoutMs: 100,
|
|
52
|
+
log,
|
|
53
|
+
root: "https://x/v1",
|
|
54
|
+
fetchImpl: async () => new Response("nope", { status: 500 }),
|
|
55
|
+
});
|
|
56
|
+
await failing.get();
|
|
57
|
+
expect(failing.peek()).toBe(null); // never had a reading; gate stays open on unknown
|
|
58
|
+
});
|
|
59
|
+
});
|