auto-model-router 0.20.0 → 0.21.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 +113 -3
- package/docs/data-governance.md +210 -0
- package/package.json +1 -1
- package/src/cli/config-wizard.ts +11 -1
- package/src/config/defaults.ts +7 -1
- package/src/config/hot-reload.ts +3 -2
- package/src/config/redaction.ts +265 -0
- package/src/config/schema.ts +25 -1
- package/src/config/types.ts +52 -5
- package/src/cost/ledger.ts +39 -5
- package/src/cost/report.ts +19 -0
- package/src/cost/retention.ts +58 -0
- package/src/cost/types.ts +26 -2
- package/src/lib.ts +8 -2
- package/src/server/http.ts +44 -14
- package/src/server/redact.ts +104 -0
- package/src/server/turn.ts +21 -0
- package/src/util/sqlite.ts +23 -1
- package/test/config-wizard.test.ts +1 -1
- package/test/failover.test.ts +1 -0
- package/test/migrations.test.ts +6 -3
- package/test/redaction.test.ts +300 -0
- package/test/report-hub.test.ts +2 -0
- package/test/report.test.ts +2 -0
- package/test/retention.test.ts +242 -0
- package/test/tokens.test.ts +3 -3
- package/test/trust-attribution.test.ts +2 -2
- package/test/turn.test.ts +107 -0
package/test/report.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/test/tokens.test.ts
CHANGED
|
@@ -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)).
|
|
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
|
|
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(
|
|
266
|
+
expect(row.user_version).toBe(19);
|
|
267
267
|
} finally {
|
|
268
268
|
db.close();
|
|
269
269
|
}
|
package/test/turn.test.ts
CHANGED
|
@@ -14,6 +14,8 @@ import type {
|
|
|
14
14
|
Tier,
|
|
15
15
|
} from "../src/router/types.ts";
|
|
16
16
|
import { runTurn } from "../src/server/turn.ts";
|
|
17
|
+
import { parseMessagesRequest } from "../src/wire/anthropic/messages.ts";
|
|
18
|
+
import { parseChatRequest } from "../src/wire/openai/request.ts";
|
|
17
19
|
import { UpstreamError, type DispatchOptions, type UpstreamClient } from "../src/upstream/types.ts";
|
|
18
20
|
import type {
|
|
19
21
|
FinishReason,
|
|
@@ -83,6 +85,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
83
85
|
digest: { enabled: false, minBytes: 12_000, maxBytes: 400_000, tools: ["read"], fromTier: "moderate", tier: "simple", model: "", maxOutputTokens: 700, maxCostUsd: 0.02, timeoutMs: 25_000, toolAliases: {} },
|
|
84
86
|
profiles: [],
|
|
85
87
|
ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 , retentionDays: 0,},
|
|
88
|
+
redaction: { enabled: false, rules: [], scanTools: false },
|
|
86
89
|
adaptiveTierFloors: true,
|
|
87
90
|
adaptivePriceCeilings: false,
|
|
88
91
|
logLevel: "silent",
|
|
@@ -1134,3 +1137,107 @@ describe("summarising compaction in a turn", () => {
|
|
|
1134
1137
|
expect(map.get("conv-test")!.compactionPlan![0]?.digest).toBeUndefined();
|
|
1135
1138
|
});
|
|
1136
1139
|
});
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Redaction sits on the turn path itself: between the rendered upstream body
|
|
1143
|
+
* and `upstream.dispatch`, which is the one place every wire in and every
|
|
1144
|
+
* provider out passes through. These tests assert on the bytes the fake
|
|
1145
|
+
* upstream was actually handed.
|
|
1146
|
+
*/
|
|
1147
|
+
describe("redaction on the turn path", () => {
|
|
1148
|
+
const SECRET = "sk-live-4f9a2b7c1d8e3f6a0b5c";
|
|
1149
|
+
const RULES = [{ name: "key", pattern: "sk-live-[a-z0-9]+" }];
|
|
1150
|
+
|
|
1151
|
+
function redactingConfig(over: Partial<RouterConfig["redaction"]> = {}): RouterConfig {
|
|
1152
|
+
const cfg = mkConfig();
|
|
1153
|
+
cfg.redaction = { enabled: true, rules: RULES, scanTools: false, ...over };
|
|
1154
|
+
return cfg;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/** A real chat-completions request, so the body under test is the real render. */
|
|
1158
|
+
function chatReq(messages: unknown[]): NormRequest {
|
|
1159
|
+
return parseChatRequest({ model: "auto", messages }, new Headers());
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
async function dispatchBody(req: NormRequest, config: RouterConfig): Promise<{ body: Record<string, unknown>; entries: LedgerEntry[] }> {
|
|
1163
|
+
const { router } = mkRouter([mkDecision("trivial", "cheap/model")]);
|
|
1164
|
+
const { upstream, calls } = mkUpstream([
|
|
1165
|
+
{ kind: "chunks", chunks: [startChunk("cheap/model"), textChunk("ok"), finishChunk("stop"), usageChunk({ promptTokens: 10, completionTokens: 2 }, 0.001)] },
|
|
1166
|
+
]);
|
|
1167
|
+
const { ledger, entries } = mkLedger();
|
|
1168
|
+
const { store } = mkConversations();
|
|
1169
|
+
const { sink, errors } = mkSink();
|
|
1170
|
+
await runTurn(req, sink, { config, router, upstream, ledger, conversations: store, catalog, context: createDisabledBridge() }, new AbortController().signal);
|
|
1171
|
+
expect(errors).toHaveLength(0);
|
|
1172
|
+
return { body: calls[0]!.body as Record<string, unknown>, entries };
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
test("the secret never reaches dispatch, and the ledger row carries the count", async () => {
|
|
1176
|
+
const { body, entries } = await dispatchBody(
|
|
1177
|
+
chatReq([{ role: "system", content: `never share ${SECRET}` }, { role: "user", content: `deploy with ${SECRET}` }]),
|
|
1178
|
+
redactingConfig(),
|
|
1179
|
+
);
|
|
1180
|
+
expect(JSON.stringify(body)).not.toContain(SECRET);
|
|
1181
|
+
expect(JSON.stringify(body)).toContain("[redacted:key]");
|
|
1182
|
+
expect(entries[0]!.redactions).toBe(2);
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
test("the Anthropic Messages wire is covered by the same pass", async () => {
|
|
1186
|
+
const req = parseMessagesRequest(
|
|
1187
|
+
{ model: "claude-opus-5", max_tokens: 64, system: `never share ${SECRET}`, messages: [{ role: "user", content: `deploy with ${SECRET}` }] },
|
|
1188
|
+
new Headers(),
|
|
1189
|
+
);
|
|
1190
|
+
const { body, entries } = await dispatchBody(req, redactingConfig());
|
|
1191
|
+
expect(JSON.stringify(body)).not.toContain(SECRET);
|
|
1192
|
+
expect(entries[0]!.redactions).toBe(2);
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
test("tool arguments and tool results go only when scanTools is on", async () => {
|
|
1196
|
+
const messages = [
|
|
1197
|
+
{ role: "user", content: "read it" },
|
|
1198
|
+
{ role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "read", arguments: `{"token":"${SECRET}"}` } }] },
|
|
1199
|
+
{ role: "tool", tool_call_id: "c1", content: `file says ${SECRET}` },
|
|
1200
|
+
];
|
|
1201
|
+
const off = await dispatchBody(chatReq(messages), redactingConfig());
|
|
1202
|
+
expect(JSON.stringify(off.body)).toContain(SECRET);
|
|
1203
|
+
expect(off.entries[0]!.redactions).toBe(0);
|
|
1204
|
+
|
|
1205
|
+
const on = await dispatchBody(chatReq(messages), redactingConfig({ scanTools: true }));
|
|
1206
|
+
expect(JSON.stringify(on.body)).not.toContain(SECRET);
|
|
1207
|
+
expect(on.entries[0]!.redactions).toBe(2);
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
test("a turn with redaction on is identical in every other respect", async () => {
|
|
1211
|
+
const messages = [{ role: "user", content: "nothing to hide" }];
|
|
1212
|
+
const plain = await dispatchBody(chatReq(messages), mkConfig());
|
|
1213
|
+
const guarded = await dispatchBody(chatReq(messages), redactingConfig());
|
|
1214
|
+
expect(guarded.body).toEqual(plain.body);
|
|
1215
|
+
// The count says the rules ran and matched nothing; off records nothing at all.
|
|
1216
|
+
expect(guarded.entries[0]!.redactions).toBe(0);
|
|
1217
|
+
expect(plain.entries[0]!.redactions).toBeUndefined();
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
test("no log line, at any level, carries the matched text", async () => {
|
|
1221
|
+
const written: string[] = [];
|
|
1222
|
+
const original = process.stderr.write.bind(process.stderr);
|
|
1223
|
+
// Test double for the stderr sink the logger writes to.
|
|
1224
|
+
process.stderr.write = ((chunk: string) => {
|
|
1225
|
+
written.push(String(chunk));
|
|
1226
|
+
return true;
|
|
1227
|
+
}) as typeof process.stderr.write;
|
|
1228
|
+
try {
|
|
1229
|
+
const config = redactingConfig({ scanTools: true });
|
|
1230
|
+
config.logLevel = "debug";
|
|
1231
|
+
const { body } = await dispatchBody(
|
|
1232
|
+
chatReq([{ role: "user", content: `deploy with ${SECRET}` }, { role: "tool", tool_call_id: "c1", content: SECRET }]),
|
|
1233
|
+
config,
|
|
1234
|
+
);
|
|
1235
|
+
expect(JSON.stringify(body)).not.toContain(SECRET);
|
|
1236
|
+
} finally {
|
|
1237
|
+
process.stderr.write = original;
|
|
1238
|
+
}
|
|
1239
|
+
expect(written.join("")).not.toContain(SECRET);
|
|
1240
|
+
// It does say the guard ran, in counts.
|
|
1241
|
+
expect(written.join("")).toContain("redacted outgoing request matches=2");
|
|
1242
|
+
});
|
|
1243
|
+
});
|