auto-model-router 0.20.0 → 0.22.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.
@@ -0,0 +1,300 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
7
+ import { loadConfig } from "../src/config/load.ts";
8
+ import { compileRedactionRules, redactionRulesFor, validateRedactionPattern, validateRedactionRule } from "../src/config/redaction.ts";
9
+ import { createLedger } from "../src/cost/ledger.ts";
10
+ import { buildUsageReport, renderUsageReport } from "../src/cost/report.ts";
11
+ import { EMPTY_USAGE, type LedgerEntry } from "../src/cost/types.ts";
12
+ import { redactUpstreamBody } from "../src/server/redact.ts";
13
+ import { openDb } from "../src/util/sqlite.ts";
14
+ import { parseMessagesRequest } from "../src/wire/anthropic/messages.ts";
15
+ import { parseChatRequest } from "../src/wire/openai/request.ts";
16
+ import { parseResponsesRequest } from "../src/wire/openai/responses.ts";
17
+
18
+ /**
19
+ * Redaction: what the guard refuses to run, what the walker actually removes,
20
+ * and the count the ledger keeps as evidence without keeping the secret.
21
+ */
22
+
23
+ const SECRET = "sk-live-4f9a2b7c1d8e3f6a0b5c";
24
+
25
+ function rules(...specs: { name: string; pattern: string; replacement?: string }[]) {
26
+ return compileRedactionRules(specs);
27
+ }
28
+
29
+ describe("pattern guard", () => {
30
+ test("the shapes a real rule uses all compile", () => {
31
+ for (const pattern of [
32
+ "sk-live-[a-z0-9]{16,}",
33
+ "AKIA[0-9A-Z]{16}",
34
+ "(?:\\d{1,3}\\.){3}\\d{1,3}", // a quantified group, but BOUNDED
35
+ "[\\w.+-]+@corp\\.example\\.com",
36
+ "-----BEGIN [A-Z ]+PRIVATE KEY-----",
37
+ "(?:acct|customer)-\\d{6}", // an alternation, but not under an unbounded quantifier
38
+ ]) {
39
+ expect(validateRedactionPattern(pattern)).toBeNull();
40
+ }
41
+ });
42
+
43
+ test("a pattern that can backtrack catastrophically is refused, with the reason", () => {
44
+ const nested = validateRedactionPattern("(a+)+$");
45
+ expect(nested).toContain("nested unbounded quantifiers");
46
+ expect(nested).toContain("exponential");
47
+ // The same shape spelled with a brace quantifier, and with `*`.
48
+ expect(validateRedactionPattern("(\\d{2,})*")).toContain("nested unbounded quantifiers");
49
+ expect(validateRedactionPattern("([a-z]*)+x")).toContain("nested unbounded quantifiers");
50
+ // The other classic: branches that can match the same input many ways.
51
+ expect(validateRedactionPattern("(?:a|a)*b")).toContain("alternation inside an unbounded quantifier");
52
+ // And the shape no linear-time strategy exists for at all.
53
+ expect(validateRedactionPattern("(secret)\\1")).toContain("backreferences");
54
+ expect(validateRedactionPattern("(?<k>x)\\k<k>")).toContain("backreferences");
55
+ });
56
+
57
+ test("a pattern that cannot compile, is empty, matches nothing, or is enormous is refused", () => {
58
+ expect(validateRedactionPattern("(unclosed")).toContain("does not compile");
59
+ expect(validateRedactionPattern("")).toBe("pattern must not be empty");
60
+ expect(validateRedactionPattern("x*")).toContain("matches the empty string");
61
+ expect(validateRedactionPattern(`a${"b".repeat(600)}`)).toContain("the limit is 512");
62
+ });
63
+
64
+ test("a legacy pattern the unicode flag alone rejects still loads", () => {
65
+ // `\d{1,2}` is fine either way; an unescaped `{` is a syntax error under
66
+ // `u` and a literal brace without it. An operator's working rule must not
67
+ // break on an upgrade, so `u` is preferred, not required.
68
+ expect(validateRedactionPattern("token{[0-9]+}")).toBeNull();
69
+ expect(rules({ name: "brace", pattern: "token{[0-9]+}" })[0]!.regex.flags).toBe("g");
70
+ expect(rules({ name: "unicode", pattern: "sk-[a-z0-9]+" })[0]!.regex.flags).toBe("gu");
71
+ });
72
+
73
+ test("a rule name is checked too, since it is echoed into the prompt", () => {
74
+ expect(validateRedactionRule({ name: "api key", pattern: "sk-\\w+" })).toBeNull();
75
+ expect(validateRedactionRule({ name: "", pattern: "sk-\\w+" })).toContain("name must be");
76
+ expect(validateRedactionRule({ name: "[redacted]\ninjected", pattern: "sk-\\w+" })).toContain("name must be");
77
+ });
78
+
79
+ test("compiling a rule set names the offending rule and refuses the whole set", () => {
80
+ expect(() => rules({ name: "ok", pattern: "sk-\\w+" }, { name: "greedy", pattern: "(x+)+" })).toThrow(
81
+ /redaction rule "greedy": nested unbounded quantifiers/,
82
+ );
83
+ });
84
+
85
+ test("the config file rejects a backtracking rule at load, naming its path", () => {
86
+ const dir = mkdtempSync(join(tmpdir(), "amr-redact-"));
87
+ try {
88
+ const path = join(dir, "config.yml");
89
+ writeFileSync(path, `redaction:\n enabled: true\n rules:\n - name: bad\n pattern: "(a+)+b"\n`, "utf8");
90
+ expect(() => loadConfig({ path })).toThrow(/redaction\.rules\.0.*nested unbounded quantifiers/s);
91
+ writeFileSync(path, `redaction:\n enabled: true\n rules:\n - name: key\n pattern: "sk-[a-z0-9-]{8,}"\n`, "utf8");
92
+ const cfg = loadConfig({ path });
93
+ expect(cfg.redaction.enabled).toBe(true);
94
+ expect(cfg.redaction.rules).toEqual([{ name: "key", pattern: "sk-[a-z0-9-]{8,}" }]);
95
+ // Unset by the file, so the default stands: tools are not scanned.
96
+ expect(cfg.redaction.scanTools).toBe(false);
97
+ } finally {
98
+ rmSync(dir, { recursive: true, force: true });
99
+ }
100
+ });
101
+
102
+ test("compiled rules are memoised on the rule text, and off means none", () => {
103
+ const cfg = { enabled: true, rules: [{ name: "key", pattern: "sk-\\w+" }], scanTools: false };
104
+ expect(redactionRulesFor(cfg)).toBe(redactionRulesFor({ ...cfg, rules: [{ name: "key", pattern: "sk-\\w+" }] }));
105
+ expect(redactionRulesFor({ ...cfg, enabled: false })).toEqual([]);
106
+ expect(redactionRulesFor({ ...cfg, rules: [] })).toEqual([]);
107
+ // An edited pattern is a different rule set, not a cache hit.
108
+ expect(redactionRulesFor({ ...cfg, rules: [{ name: "key", pattern: "sk-\\d+" }] })[0]!.regex.source).toBe("sk-\\d+");
109
+ });
110
+ });
111
+
112
+ describe("each rule shape", () => {
113
+ const body = () => ({ messages: [{ role: "user", content: `token ${SECRET} and 10.1.2.3` }] });
114
+
115
+ test("a rule with no replacement redacts to [redacted:<name>]", () => {
116
+ const b = body();
117
+ expect(redactUpstreamBody(b, rules({ name: "api key", pattern: "sk-live-[a-z0-9]+" }), { scanTools: false })).toBe(1);
118
+ expect(b.messages[0]!.content).toBe("token [redacted:api key] and 10.1.2.3");
119
+ });
120
+
121
+ test("a rule with a replacement uses it", () => {
122
+ const b = body();
123
+ redactUpstreamBody(b, rules({ name: "ip", pattern: "(?:\\d{1,3}\\.){3}\\d{1,3}", replacement: "0.0.0.0" }), { scanTools: false });
124
+ expect(b.messages[0]!.content).toBe(`token ${SECRET} and 0.0.0.0`);
125
+ });
126
+
127
+ test("several rules all apply, and every match counts", () => {
128
+ const b = { messages: [{ role: "user", content: `${SECRET} ${SECRET} 10.1.2.3` }] };
129
+ const n = redactUpstreamBody(
130
+ b,
131
+ rules({ name: "key", pattern: "sk-live-[a-z0-9]+" }, { name: "ip", pattern: "(?:\\d{1,3}\\.){3}\\d{1,3}" }),
132
+ { scanTools: false },
133
+ );
134
+ expect(n).toBe(3);
135
+ expect(b.messages[0]!.content).toBe("[redacted:key] [redacted:key] [redacted:ip]");
136
+ });
137
+
138
+ test("nothing to match leaves the body untouched and counts zero", () => {
139
+ const b = { messages: [{ role: "user", content: "nothing secret here" }] };
140
+ expect(redactUpstreamBody(b, rules({ name: "key", pattern: "sk-live-[a-z0-9]+" }), { scanTools: false })).toBe(0);
141
+ expect(b.messages[0]!.content).toBe("nothing secret here");
142
+ });
143
+ });
144
+
145
+ describe("what the walker reaches", () => {
146
+ const KEY = rules({ name: "key", pattern: "sk-live-[a-z0-9]+" });
147
+
148
+ function fullBody(): Record<string, unknown> {
149
+ return {
150
+ model: "sk-live-notreally/model", // a slug is not conversation content
151
+ messages: [
152
+ { role: "system", content: [{ type: "text", text: `system ${SECRET}`, cache_control: { type: "ephemeral" } }] },
153
+ { role: "user", content: `user ${SECRET}` },
154
+ { role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "read", arguments: `{"path":"${SECRET}"}` } }] },
155
+ { role: "tool", tool_call_id: "c1", content: `result ${SECRET}` },
156
+ ],
157
+ };
158
+ }
159
+
160
+ test("message text is redacted; tool arguments and tool results wait for scanTools", () => {
161
+ const b = fullBody();
162
+ expect(redactUpstreamBody(b, KEY, { scanTools: false })).toBe(2);
163
+ const messages = b.messages as Record<string, unknown>[];
164
+ expect((messages[0]!.content as { text: string }[])[0]!.text).toBe("system [redacted:key]");
165
+ // The cache breakpoint the router planted survives the rewrite.
166
+ expect((messages[0]!.content as { cache_control: unknown }[])[0]!.cache_control).toEqual({ type: "ephemeral" });
167
+ expect(messages[1]!.content).toBe("user [redacted:key]");
168
+ expect(JSON.stringify(messages[2])).toContain(SECRET);
169
+ expect(messages[3]!.content).toBe(`result ${SECRET}`);
170
+ // A model slug is the router's own vocabulary, never scanned.
171
+ expect(b.model).toBe("sk-live-notreally/model");
172
+ });
173
+
174
+ test("with scanTools, arguments and results go too and nothing else changes", () => {
175
+ const b = fullBody();
176
+ expect(redactUpstreamBody(b, KEY, { scanTools: true })).toBe(4);
177
+ const messages = b.messages as Record<string, unknown>[];
178
+ expect(JSON.stringify(b)).not.toContain(SECRET);
179
+ const call = (messages[2]!.tool_calls as { id: string; function: { name: string; arguments: string } }[])[0]!;
180
+ // The call/result pairing the model needs is untouched.
181
+ expect(call.id).toBe("c1");
182
+ expect(call.function.name).toBe("read");
183
+ expect(call.function.arguments).toBe('{"path":"[redacted:key]"}');
184
+ expect(messages[3]!.tool_call_id).toBe("c1");
185
+ });
186
+
187
+ test("no rules is a no-op, and a body without messages is ignored", () => {
188
+ const b = fullBody();
189
+ expect(redactUpstreamBody(b, [], { scanTools: true })).toBe(0);
190
+ expect(JSON.stringify(b)).toContain(SECRET);
191
+ expect(redactUpstreamBody({ model: "x" }, KEY, { scanTools: true })).toBe(0);
192
+ });
193
+ });
194
+
195
+ describe("every wire renders into the shape redaction reads", () => {
196
+ const KEY = rules({ name: "key", pattern: "sk-live-[a-z0-9]+" });
197
+ const mutations = {
198
+ slug: "vendor/model",
199
+ fallbacks: [],
200
+ sessionId: "s",
201
+ cacheBreakpointMessageIndices: [],
202
+ reasoning: undefined,
203
+ maxTokens: undefined,
204
+ stripAssistantReasoning: false,
205
+ };
206
+
207
+ test("chat completions, Responses and Anthropic Messages all lose the secret", () => {
208
+ const chat = parseChatRequest(
209
+ { model: "auto", messages: [{ role: "system", content: `rules ${SECRET}` }, { role: "user", content: `use ${SECRET}` }] },
210
+ new Headers(),
211
+ );
212
+ const responses = parseResponsesRequest(
213
+ { model: "auto", instructions: `rules ${SECRET}`, input: [{ type: "message", role: "user", content: [{ type: "input_text", text: `use ${SECRET}` }] }] },
214
+ new Headers(),
215
+ );
216
+ const messages = parseMessagesRequest(
217
+ { model: "claude-opus-5", max_tokens: 100, system: `rules ${SECRET}`, messages: [{ role: "user", content: [{ type: "text", text: `use ${SECRET}` }] }] },
218
+ new Headers(),
219
+ );
220
+ for (const req of [chat, responses, messages]) {
221
+ const body = req.renderUpstreamBody(mutations);
222
+ expect(JSON.stringify(body)).toContain(SECRET);
223
+ expect(redactUpstreamBody(body, KEY, { scanTools: false })).toBe(2);
224
+ expect(JSON.stringify(body)).not.toContain(SECRET);
225
+ }
226
+ });
227
+ });
228
+
229
+ describe("the ledger keeps the count and never the match", () => {
230
+ function entry(over: Partial<LedgerEntry>): LedgerEntry {
231
+ return {
232
+ id: crypto.randomUUID(),
233
+ createdAtMs: Date.now(),
234
+ conversationKey: "conv",
235
+ sessionId: "sess",
236
+ turn: 1,
237
+ requestedModel: "auto",
238
+ harnessId: "",
239
+ ompSessionId: "",
240
+ slug: "a/b",
241
+ servedSlug: "a/b",
242
+ tier: "simple",
243
+ classificationSource: "heuristic",
244
+ reasons: [],
245
+ features: null,
246
+ score: null,
247
+ confidence: null,
248
+ task: null,
249
+ classifierReasons: null,
250
+ exploredFrom: null,
251
+ holdArm: null,
252
+ predictedUsd: 0.001,
253
+ reportedUsd: 0.001,
254
+ usage: { ...EMPTY_USAGE, promptTokens: 100, completionTokens: 10 },
255
+ attempt: 0,
256
+ escalationSignal: null,
257
+ latencyMs: 10,
258
+ ttftMs: 5,
259
+ finishReason: "stop",
260
+ wasted: false,
261
+ upstreamGenerationId: null,
262
+ error: null,
263
+ promptTokensSaved: 0,
264
+ ...over,
265
+ };
266
+ }
267
+
268
+ test("the column round-trips, absent stays absent, and the report totals it", () => {
269
+ const db = openDb(":memory:");
270
+ try {
271
+ const ledger = createLedger(db, { ...DEFAULT_CONFIG, ledger: { ...DEFAULT_CONFIG.ledger, path: ":memory:" } });
272
+ const now = Date.now();
273
+ ledger.record(entry({ createdAtMs: now - 3, redactions: 3 }));
274
+ ledger.record(entry({ createdAtMs: now - 2, redactions: 0 }));
275
+ ledger.record(entry({ createdAtMs: now - 1 })); // redaction off: NULL, which is not 0
276
+ const recorded = ledger.recentEntries(10);
277
+ expect(recorded.map((e) => e.redactions)).toEqual([undefined, 0, 3]);
278
+ const totals = buildUsageReport(db, { windowDays: 7 }).totals;
279
+ expect(totals.redactions).toBe(3);
280
+ expect(totals.redactedTurns).toBe(1);
281
+ expect(renderUsageReport(buildUsageReport(db, { windowDays: 7 }))).toContain("redaction: 1 turns had something removed (3 strings)");
282
+ } finally {
283
+ db.close();
284
+ }
285
+ });
286
+
287
+ test("a window with no redaction says nothing about it", () => {
288
+ const db = openDb(":memory:");
289
+ try {
290
+ const ledger = createLedger(db, { ...DEFAULT_CONFIG, ledger: { ...DEFAULT_CONFIG.ledger, path: ":memory:" } });
291
+ ledger.record(entry({}));
292
+ const report = buildUsageReport(db, { windowDays: 7 });
293
+ expect(report.totals.redactions).toBe(0);
294
+ expect(report.totals.redactedTurns).toBe(0);
295
+ expect(renderUsageReport(report)).not.toContain("redaction:");
296
+ } finally {
297
+ db.close();
298
+ }
299
+ });
300
+ });
@@ -70,6 +70,8 @@ function report(over: Partial<UsageReport> = {}): UsageReport {
70
70
  cacheHitRate: 0.8,
71
71
  promptTokens: 20000,
72
72
  completionTokens: 2000,
73
+ redactions: 0,
74
+ redactedTurns: 0,
73
75
  escalations: 2,
74
76
  failovers: 0,
75
77
  errors: 0,
@@ -233,6 +233,8 @@ describe("buildUsageReport", () => {
233
233
  cacheHitRate: 0,
234
234
  promptTokens: 0,
235
235
  completionTokens: 0,
236
+ redactions: 0,
237
+ redactedTurns: 0,
236
238
  escalations: 0,
237
239
  failovers: 0,
238
240
  errors: 0,
@@ -0,0 +1,242 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
7
+ import type { RouterConfig } from "../src/config/types.ts";
8
+ import { createFeedbackStore } from "../src/cost/feedback.ts";
9
+ import { createLedger } from "../src/cost/ledger.ts";
10
+ import { createRetentionRunner, RETENTION_INTERVAL_MS } from "../src/cost/retention.ts";
11
+ import { EMPTY_USAGE, type Ledger, type LedgerEntry, type PruneResult } from "../src/cost/types.ts";
12
+ import { startServer, type StartedServer } from "../src/server/http.ts";
13
+ import { openDb } from "../src/util/sqlite.ts";
14
+
15
+ /**
16
+ * Ledger retention: how long turns are kept, what goes with them, and the
17
+ * route a front door asks through — the team edition holds a read-only handle
18
+ * on the ledger and must never delete from it itself.
19
+ */
20
+
21
+ const DAY = 86_400_000;
22
+ const NOW = 1_800_000_000_000;
23
+
24
+ function entry(over: Partial<LedgerEntry>): LedgerEntry {
25
+ return {
26
+ id: crypto.randomUUID(),
27
+ createdAtMs: NOW,
28
+ conversationKey: "conv",
29
+ sessionId: "sess",
30
+ turn: 1,
31
+ requestedModel: "auto",
32
+ harnessId: "",
33
+ ompSessionId: "omp-1",
34
+ slug: "a/b",
35
+ servedSlug: "a/b",
36
+ tier: "simple",
37
+ classificationSource: "heuristic",
38
+ reasons: [],
39
+ features: null,
40
+ score: null,
41
+ confidence: null,
42
+ task: null,
43
+ classifierReasons: null,
44
+ exploredFrom: null,
45
+ holdArm: null,
46
+ predictedUsd: 0.001,
47
+ reportedUsd: 0.001,
48
+ usage: { ...EMPTY_USAGE, promptTokens: 100, completionTokens: 10 },
49
+ attempt: 0,
50
+ escalationSignal: null,
51
+ latencyMs: 10,
52
+ ttftMs: 5,
53
+ finishReason: "stop",
54
+ wasted: false,
55
+ upstreamGenerationId: null,
56
+ error: null,
57
+ promptTokensSaved: 0,
58
+ ...over,
59
+ };
60
+ }
61
+
62
+ function seeded(path = ":memory:", base = NOW): { db: ReturnType<typeof openDb>; ledger: Ledger; ids: string[] } {
63
+ const db = openDb(path);
64
+ const cfg: RouterConfig = { ...DEFAULT_CONFIG, ledger: { ...DEFAULT_CONFIG.ledger, path } };
65
+ const ledger = createLedger(db, cfg);
66
+ // 400, 200 and 1 days old.
67
+ const ids = [400, 200, 1].map((age) => {
68
+ const e = entry({ createdAtMs: base - age * DAY });
69
+ ledger.record(e);
70
+ return e.id;
71
+ });
72
+ return { db, ledger, ids };
73
+ }
74
+
75
+ describe("prune", () => {
76
+ test("rows past the window go, newer ones stay, and the oldest kept is reported", () => {
77
+ const { db, ledger } = seeded();
78
+ try {
79
+ expect(ledger.prune?.(365, NOW)).toEqual({ deleted: 1, oldestKeptMs: NOW - 200 * DAY });
80
+ expect(ledger.recentEntries(10)).toHaveLength(2);
81
+ expect(ledger.prune?.(30, NOW)).toEqual({ deleted: 1, oldestKeptMs: NOW - DAY });
82
+ expect(ledger.recentEntries(10)).toHaveLength(1);
83
+ } finally {
84
+ db.close();
85
+ }
86
+ });
87
+
88
+ test("a null window (the default) deletes nothing, and neither does 0", () => {
89
+ const { db, ledger } = seeded();
90
+ try {
91
+ expect(DEFAULT_CONFIG.ledger.retentionDays).toBeNull();
92
+ expect(ledger.prune?.(null, NOW)).toEqual({ deleted: 0, oldestKeptMs: NOW - 400 * DAY });
93
+ expect(ledger.prune?.(0, NOW)).toEqual({ deleted: 0, oldestKeptMs: NOW - 400 * DAY });
94
+ expect(ledger.recentEntries(10)).toHaveLength(3);
95
+ } finally {
96
+ db.close();
97
+ }
98
+ });
99
+
100
+ test("feedback keyed to a deleted turn goes with it; a verdict on a kept turn stays", () => {
101
+ const { db, ledger, ids } = seeded();
102
+ try {
103
+ const feedback = createFeedbackStore(db);
104
+ const [old, mid, recent] = ids as [string, string, string];
105
+ feedback.record({ ledgerId: old, ompSessionId: "omp-1", slug: "a/b", tier: "simple", verdict: "bad", note: "" }, NOW - 400 * DAY);
106
+ feedback.record({ ledgerId: mid, ompSessionId: "omp-1", slug: "a/b", tier: "simple", verdict: "good", note: "" }, NOW - 200 * DAY);
107
+ feedback.record({ ledgerId: recent, ompSessionId: "omp-1", slug: "a/b", tier: "simple", verdict: "good", note: "" }, NOW - DAY);
108
+ expect(ledger.prune?.(365, NOW)?.deleted).toBe(1);
109
+ expect(feedback.forLedgerId(old)).toHaveLength(0);
110
+ expect(feedback.forLedgerId(mid)).toHaveLength(1);
111
+ expect(feedback.forLedgerId(recent)).toHaveLength(1);
112
+ } finally {
113
+ db.close();
114
+ }
115
+ });
116
+
117
+ test("ollama meter samples age out with the rows they calibrate", () => {
118
+ const { db, ledger } = seeded();
119
+ try {
120
+ db.run("INSERT INTO ollama_meter_samples (at_ms, meter_usd, ledger_usd) VALUES (?, 1, 1), (?, 2, 2)", [NOW - 400 * DAY, NOW - DAY]);
121
+ ledger.prune?.(365, NOW);
122
+ expect((db.query("SELECT COUNT(*) AS n FROM ollama_meter_samples").get() as { n: number }).n).toBe(1);
123
+ } finally {
124
+ db.close();
125
+ }
126
+ });
127
+
128
+ test("an empty ledger reports no oldest row rather than a cutoff of its own", () => {
129
+ const db = openDb(":memory:");
130
+ try {
131
+ const ledger = createLedger(db, { ...DEFAULT_CONFIG, ledger: { ...DEFAULT_CONFIG.ledger, path: ":memory:" } });
132
+ expect(ledger.prune?.(30, NOW)).toEqual({ deleted: 0, oldestKeptMs: null });
133
+ } finally {
134
+ db.close();
135
+ }
136
+ });
137
+ });
138
+
139
+ describe("the schedule", () => {
140
+ function counting(): { ledger: Ledger; runs: number[] } {
141
+ const runs: number[] = [];
142
+ const ledger = {
143
+ prune: (_days: number | null, nowMs?: number): PruneResult => {
144
+ runs.push(nowMs ?? 0);
145
+ return { deleted: 1, oldestKeptMs: null };
146
+ },
147
+ } as unknown as Ledger;
148
+ return { ledger, runs };
149
+ }
150
+
151
+ test("the scheduled path runs at most once an hour, however often it is asked", () => {
152
+ const { ledger, runs } = counting();
153
+ const runner = createRetentionRunner({ ledger, retentionDays: () => 30 });
154
+ // The first call is always due, so a lowered window applies at boot.
155
+ expect(runner.maybeRun(NOW)?.deleted).toBe(1);
156
+ // A minute timer ticking for an hour must not re-run it.
157
+ for (let t = 60_000; t < RETENTION_INTERVAL_MS; t += 60_000) expect(runner.maybeRun(NOW + t)).toBeNull();
158
+ expect(runs).toEqual([NOW]);
159
+ expect(runner.maybeRun(NOW + RETENTION_INTERVAL_MS)?.deleted).toBe(1);
160
+ expect(runs).toEqual([NOW, NOW + RETENTION_INTERVAL_MS]);
161
+ });
162
+
163
+ test("an explicit run always happens, and satisfies the schedule for the next hour", () => {
164
+ const { ledger, runs } = counting();
165
+ const runner = createRetentionRunner({ ledger, retentionDays: () => 30 });
166
+ runner.runNow(NOW);
167
+ runner.runNow(NOW + 1_000);
168
+ expect(runs).toEqual([NOW, NOW + 1_000]);
169
+ expect(runner.maybeRun(NOW + 2_000)).toBeNull();
170
+ expect(runner.maybeRun(NOW + 1_000 + RETENTION_INTERVAL_MS)).not.toBeNull();
171
+ });
172
+
173
+ test("the window is read live, so a hot reload applies on the next run", () => {
174
+ let days: number | null = null;
175
+ const { ledger, runs } = counting();
176
+ const runner = createRetentionRunner({ ledger, retentionDays: () => days });
177
+ expect(runner.retentionDays()).toBeNull();
178
+ days = 7;
179
+ runner.runNow(NOW);
180
+ expect(runner.retentionDays()).toBe(7);
181
+ expect(runs).toHaveLength(1);
182
+ });
183
+ });
184
+
185
+ describe("POST /v1/router/prune", () => {
186
+ let handle: StartedServer;
187
+ let baseUrl = "";
188
+ let dir = "";
189
+ let dbPath = "";
190
+ // The route prunes against the real clock, so the fixture rows are placed
191
+ // relative to it rather than to the fixed NOW the unit tests use.
192
+ const realNow = Date.now();
193
+
194
+ beforeAll(() => {
195
+ dir = mkdtempSync(join(tmpdir(), "amr-retention-"));
196
+ dbPath = join(dir, "router.db");
197
+ const { db } = seeded(dbPath, realNow);
198
+ db.close();
199
+ const cfg: RouterConfig = {
200
+ ...structuredClone(DEFAULT_CONFIG),
201
+ server: { host: "127.0.0.1", port: 0, maxConcurrentTurns: 24, subagentProfile: "auto-sub" },
202
+ ledger: { ...DEFAULT_CONFIG.ledger, path: dbPath, retentionDays: 365 },
203
+ logLevel: "silent",
204
+ };
205
+ handle = startServer(cfg);
206
+ baseUrl = `http://127.0.0.1:${handle.server.port}`;
207
+ });
208
+
209
+ afterAll(async () => {
210
+ await handle.stop();
211
+ try {
212
+ rmSync(dir, { recursive: true, force: true });
213
+ } catch {
214
+ // Windows holds the file until the statements are collected; the temp dir is disposable.
215
+ }
216
+ });
217
+
218
+ test("the route deletes past the configured window and answers the counts", async () => {
219
+ const res = await fetch(`${baseUrl}/v1/router/prune`, { method: "POST" });
220
+ expect(res.status).toBe(200);
221
+ const body = (await res.json()) as { deleted: number; oldestKeptMs: number | null; retentionDays: number | null };
222
+ expect(body.deleted).toBe(1); // the 400-day-old row
223
+ expect(body.oldestKeptMs).toBe(realNow - 200 * DAY);
224
+ expect(body.retentionDays).toBe(365);
225
+ // The rows are really gone from the file, not just from a view.
226
+ const db = openDb(dbPath);
227
+ try {
228
+ expect((db.query("SELECT COUNT(*) AS n FROM ledger").get() as { n: number }).n).toBe(2);
229
+ } finally {
230
+ db.close();
231
+ }
232
+ });
233
+
234
+ test("a second call is idempotent: nothing left to delete, same oldest row", async () => {
235
+ const body = (await (await fetch(`${baseUrl}/v1/router/prune`, { method: "POST" })).json()) as PruneResult;
236
+ expect(body).toMatchObject({ deleted: 0, oldestKeptMs: realNow - 200 * DAY });
237
+ });
238
+
239
+ test("GET is not the prune route", async () => {
240
+ expect((await fetch(`${baseUrl}/v1/router/prune`)).status).toBe(404);
241
+ });
242
+ });
@@ -290,13 +290,13 @@ describe("ledger.prune and markWasted", () => {
290
290
  const now = 1_800_000_000_000;
291
291
  const DAY = 86_400_000;
292
292
  for (let i = 0; i < 5; i++) ledger.record(entry({ createdAtMs: now - i * 100 * DAY }));
293
- expect(ledger.prune?.(0, now)).toBe(0);
293
+ expect(ledger.prune?.(0, now)).toEqual({ deleted: 0, oldestKeptMs: now - 400 * DAY });
294
294
  expect(ledger.recentEntries(10)).toHaveLength(5);
295
295
  db.run("INSERT INTO ollama_meter_samples (at_ms, meter_usd, ledger_usd) VALUES (?, 1, 1), (?, 2, 2)", [now - 400 * DAY, now - DAY]);
296
- expect(ledger.prune?.(365, now)).toBe(1); // only the 400-day-old row
296
+ expect(ledger.prune?.(365, now)?.deleted).toBe(1); // only the 400-day-old row
297
297
  expect((db.query("SELECT COUNT(*) AS n FROM ollama_meter_samples").get() as { n: number }).n).toBe(1);
298
298
  expect(ledger.recentEntries(10)).toHaveLength(4);
299
- expect(ledger.prune?.(150, now)).toBe(2); // 200 and 300 days old
299
+ expect(ledger.prune?.(150, now)?.deleted).toBe(2); // 200 and 300 days old
300
300
  const left = ledger.recentEntries(10);
301
301
  expect(left).toHaveLength(2);
302
302
  expect(left.every((e) => e.wasted === false)).toBe(true);
@@ -259,11 +259,11 @@ describe("v4 migration", () => {
259
259
  db.close();
260
260
  });
261
261
 
262
- test("schema is at user_version 18", () => {
262
+ test("schema is at user_version 19", () => {
263
263
  const db = openDb(":memory:");
264
264
  try {
265
265
  const row = db.query("PRAGMA user_version").get() as { user_version: number };
266
- expect(row.user_version).toBe(18);
266
+ expect(row.user_version).toBe(19);
267
267
  } finally {
268
268
  db.close();
269
269
  }