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/src/server/http.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { createProviders } from "./providers.ts";
|
|
|
5
5
|
import { createBridgeFromConfig } from "../context/index.ts";
|
|
6
6
|
import { createFeedbackStore, type Verdict } from "../cost/feedback.ts";
|
|
7
7
|
import { createLedger } from "../cost/ledger.ts";
|
|
8
|
+
import { createRetentionRunner } from "../cost/retention.ts";
|
|
9
|
+
import { redactionRulesFor } from "../config/redaction.ts";
|
|
8
10
|
import { createSessionOverrides } from "./overrides.ts";
|
|
9
11
|
import { catalogView } from "./catalog-view.ts";
|
|
10
12
|
import { buildUpstreamModels } from "../catalog/static-catalog.ts";
|
|
@@ -265,6 +267,18 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
265
267
|
},
|
|
266
268
|
);
|
|
267
269
|
|
|
270
|
+
// Compile the redaction rules before the listener exists: a rule that does
|
|
271
|
+
// not load is a hole in the guard, and an operator who configured redaction
|
|
272
|
+
// must not get a router that started and forwarded anyway. Programmatic
|
|
273
|
+
// overrides (an embedder's) never pass through the config schema, so this is
|
|
274
|
+
// the only place their rules are checked.
|
|
275
|
+
const redactionRules = redactionRulesFor(cfg.redaction);
|
|
276
|
+
if (redactionRules.length > 0) {
|
|
277
|
+
// Names only. The patterns describe the secrets and the matches are the
|
|
278
|
+
// secrets; neither belongs in a log line.
|
|
279
|
+
log.info("redaction enabled", { rules: redactionRules.map((r) => r.name).join(","), scanTools: cfg.redaction.scanTools });
|
|
280
|
+
}
|
|
281
|
+
|
|
268
282
|
if (context.enabled) {
|
|
269
283
|
log.info("agentdox context bridge enabled", {
|
|
270
284
|
url: cfg.context.baseUrl,
|
|
@@ -296,8 +310,24 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
296
310
|
log.warn("initial catalog fetch failed", { error: err instanceof Error ? err.message : String(err) });
|
|
297
311
|
});
|
|
298
312
|
|
|
299
|
-
//
|
|
300
|
-
//
|
|
313
|
+
// Ledger retention. The runner owns the once-an-hour floor, so the minute
|
|
314
|
+
// timer below, the boot run and `POST /v1/router/prune` cannot between them
|
|
315
|
+
// run a whole-ledger delete more often than that. It reads the window live,
|
|
316
|
+
// so a hot reload that lowers it applies on the next tick.
|
|
317
|
+
const retention = createRetentionRunner({ ledger, retentionDays: () => cfg.ledger.retentionDays });
|
|
318
|
+
const retain = (): void => {
|
|
319
|
+
try {
|
|
320
|
+
const result = retention.maybeRun();
|
|
321
|
+
if (result !== null && result.deleted > 0) {
|
|
322
|
+
log.info("pruned ledger rows past retention", { deleted: result.deleted, retentionDays: cfg.ledger.retentionDays });
|
|
323
|
+
}
|
|
324
|
+
} catch (err) {
|
|
325
|
+
log.warn("ledger retention prune failed", { error: err instanceof Error ? err.message : String(err) });
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
// One housekeeping timer for all three tables. `unref`'d so it never holds
|
|
330
|
+
// the process open.
|
|
301
331
|
const pruneTimer = setInterval(() => {
|
|
302
332
|
try {
|
|
303
333
|
const dropped = conversations.prune(cfg.ledger.conversationTtlMs);
|
|
@@ -315,21 +345,12 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
315
345
|
} catch (err) {
|
|
316
346
|
log.warn("context block prune failed", { error: err instanceof Error ? err.message : String(err) });
|
|
317
347
|
}
|
|
348
|
+
retain();
|
|
318
349
|
}, 60_000);
|
|
319
350
|
pruneTimer.unref();
|
|
320
351
|
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
const retain = (): void => {
|
|
324
|
-
try {
|
|
325
|
-
const dropped = ledger.prune?.(cfg.ledger.retentionDays) ?? 0;
|
|
326
|
-
if (dropped > 0) log.info("pruned ledger rows past retention", { dropped, retentionDays: cfg.ledger.retentionDays });
|
|
327
|
-
} catch (err) {
|
|
328
|
-
log.warn("ledger retention prune failed", { error: err instanceof Error ? err.message : String(err) });
|
|
329
|
-
}
|
|
330
|
-
};
|
|
331
|
-
const retentionTimer = setInterval(retain, 3_600_000);
|
|
332
|
-
retentionTimer.unref();
|
|
352
|
+
// Once shortly after boot, so a lowered window takes effect without waiting
|
|
353
|
+
// out an hour; the runner's own floor governs everything after that.
|
|
333
354
|
setTimeout(retain, 5_000).unref();
|
|
334
355
|
|
|
335
356
|
// Periodically refetch the (key-scoped) catalog in the background so
|
|
@@ -665,6 +686,15 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
665
686
|
}),
|
|
666
687
|
);
|
|
667
688
|
}
|
|
689
|
+
if (req.method === "POST" && url.pathname === "/v1/router/prune") {
|
|
690
|
+
// A front door of the team edition holds a READ-ONLY handle on the
|
|
691
|
+
// ledger file by design, so this route is the only way it can act
|
|
692
|
+
// on its own retention policy — and the once-an-hour floor is the
|
|
693
|
+
// runner's, not the caller's, so calling it in a loop is harmless.
|
|
694
|
+
const result = retention.runNow();
|
|
695
|
+
if (result.deleted > 0) log.info("pruned ledger rows past retention", { deleted: result.deleted, retentionDays: cfg.ledger.retentionDays });
|
|
696
|
+
return json({ ...result, retentionDays: cfg.ledger.retentionDays });
|
|
697
|
+
}
|
|
668
698
|
if (req.method === "POST" && url.pathname === "/v1/router/feedback") {
|
|
669
699
|
// A user verdict on the newest routed turn of an omp session.
|
|
670
700
|
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redaction: strings an operator forbids from leaving the process.
|
|
3
|
+
*
|
|
4
|
+
* An operator with a compliance obligation needs two things a router can
|
|
5
|
+
* actually give: certainty that certain shapes of text never reach a provider,
|
|
6
|
+
* and evidence that the guard ran. This module is the first half; the ledger's
|
|
7
|
+
* `redactions` count is the second. Neither ever records WHAT was matched — a
|
|
8
|
+
* redaction log that quotes the secret is just a second copy of the secret.
|
|
9
|
+
*
|
|
10
|
+
* `runTurn` applies this to the rendered upstream body, which is the
|
|
11
|
+
* chat-completions shape every front end normalises to and every provider
|
|
12
|
+
* client renders from, so the guard sits between the router and ALL of them:
|
|
13
|
+
* a new upstream cannot bypass it by construction.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { CompiledRedactionRule } from "../config/redaction.ts";
|
|
17
|
+
|
|
18
|
+
/** Applies every rule to one string. Returns the text and how many matches were replaced. */
|
|
19
|
+
export function redactText(text: string, rules: readonly CompiledRedactionRule[]): { text: string; count: number } {
|
|
20
|
+
let out = text;
|
|
21
|
+
let count = 0;
|
|
22
|
+
for (const rule of rules) {
|
|
23
|
+
// `replace` with a global regex resets and advances `lastIndex` itself,
|
|
24
|
+
// including over a zero-width position — which compilation refuses anyway.
|
|
25
|
+
out = out.replace(rule.regex, () => {
|
|
26
|
+
count++;
|
|
27
|
+
return rule.replacement;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return { text: out, count };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type Rec = Record<string, unknown>;
|
|
34
|
+
const isRec = (v: unknown): v is Rec => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
35
|
+
|
|
36
|
+
/** Redacts a message's `content`, whether it is a string or an array of parts. Returns matches replaced. */
|
|
37
|
+
function redactContent(message: Rec, rules: readonly CompiledRedactionRule[]): number {
|
|
38
|
+
const content = message.content;
|
|
39
|
+
if (typeof content === "string") {
|
|
40
|
+
const r = redactText(content, rules);
|
|
41
|
+
if (r.count > 0) message.content = r.text;
|
|
42
|
+
return r.count;
|
|
43
|
+
}
|
|
44
|
+
if (!Array.isArray(content)) return 0;
|
|
45
|
+
let count = 0;
|
|
46
|
+
for (const part of content) {
|
|
47
|
+
// Text parts only: an `image_url` part carries a data URI, which no
|
|
48
|
+
// redaction rule can meaningfully read and every rule would be slow over.
|
|
49
|
+
if (!isRec(part) || part.type !== "text" || typeof part.text !== "string") continue;
|
|
50
|
+
const r = redactText(part.text, rules);
|
|
51
|
+
if (r.count > 0) part.text = r.text;
|
|
52
|
+
count += r.count;
|
|
53
|
+
}
|
|
54
|
+
return count;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Redacts the rendered upstream body in place and returns how many matches
|
|
59
|
+
* were replaced.
|
|
60
|
+
*
|
|
61
|
+
* The body is the chat-completions shape: every front end (chat completions,
|
|
62
|
+
* Responses, Anthropic Messages) translates into it before parsing, and every
|
|
63
|
+
* upstream client renders its own protocol FROM it, so this one pass covers
|
|
64
|
+
* every wire in and every provider out.
|
|
65
|
+
*
|
|
66
|
+
* What is scanned:
|
|
67
|
+
* - the text content of every message that is not a tool result — the system
|
|
68
|
+
* prompt (including the injected agentdox block), the user's words, the
|
|
69
|
+
* assistant's replay;
|
|
70
|
+
* - with `scanTools`, tool-call arguments and tool-result content as well.
|
|
71
|
+
* Off by default because tool results are where the bytes are: a turn's
|
|
72
|
+
* prompt is mostly file content, so scanning them is most of the cost — and
|
|
73
|
+
* also, for an operator who cares about a secret in a file the agent read,
|
|
74
|
+
* most of the point.
|
|
75
|
+
*
|
|
76
|
+
* Tool NAMES, ids and the tool schemas are left alone: they are the harness's
|
|
77
|
+
* own vocabulary, not conversation content, and rewriting one breaks the
|
|
78
|
+
* call/result pairing the model needs.
|
|
79
|
+
*/
|
|
80
|
+
export function redactUpstreamBody(
|
|
81
|
+
body: Rec,
|
|
82
|
+
rules: readonly CompiledRedactionRule[],
|
|
83
|
+
opts: { scanTools: boolean },
|
|
84
|
+
): number {
|
|
85
|
+
if (rules.length === 0) return 0;
|
|
86
|
+
const messages = body.messages;
|
|
87
|
+
if (!Array.isArray(messages)) return 0;
|
|
88
|
+
let count = 0;
|
|
89
|
+
for (const message of messages) {
|
|
90
|
+
if (!isRec(message)) continue;
|
|
91
|
+
const isToolResult = message.role === "tool";
|
|
92
|
+
if (!isToolResult || opts.scanTools) count += redactContent(message, rules);
|
|
93
|
+
if (!opts.scanTools || !Array.isArray(message.tool_calls)) continue;
|
|
94
|
+
for (const call of message.tool_calls) {
|
|
95
|
+
if (!isRec(call) || !isRec(call.function)) continue;
|
|
96
|
+
const args = call.function.arguments;
|
|
97
|
+
if (typeof args !== "string") continue;
|
|
98
|
+
const r = redactText(args, rules);
|
|
99
|
+
if (r.count > 0) call.function.arguments = r.text;
|
|
100
|
+
count += r.count;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return count;
|
|
104
|
+
}
|
package/src/server/turn.ts
CHANGED
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
import { UpstreamError, type Dispatch, type UpstreamClient } from "../upstream/types.ts";
|
|
30
30
|
import type { SessionOverrides } from "./overrides.ts";
|
|
31
31
|
import { digestCompactionEdits, type CompactionDigester } from "./compaction-digest.ts";
|
|
32
|
+
import { redactUpstreamBody } from "./redact.ts";
|
|
33
|
+
import { redactionRulesFor } from "../config/redaction.ts";
|
|
32
34
|
import { createLogger } from "../util/log.ts";
|
|
33
35
|
import type { NormRequest, ResponseSink, TurnSummary, UpstreamChunk } from "../wire/types.ts";
|
|
34
36
|
|
|
@@ -165,6 +167,10 @@ export async function runTurn(
|
|
|
165
167
|
let pendingDecision: Decision | null = null;
|
|
166
168
|
// Digests made this turn, by edit; a retry re-plans and must not pay twice.
|
|
167
169
|
const digestMemo = new Map<string, string>();
|
|
170
|
+
// Compiled once per turn (and memoised across turns on the rules' own text),
|
|
171
|
+
// never per attempt: a retry renders the same request again and would
|
|
172
|
+
// otherwise recompile every pattern.
|
|
173
|
+
const redactionRules = redactionRulesFor(config.redaction);
|
|
168
174
|
|
|
169
175
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
170
176
|
// Client disconnected before anything was dispatched: spend nothing.
|
|
@@ -261,6 +267,18 @@ export async function runTurn(
|
|
|
261
267
|
...(decision.compactionPlan.length > 0 ? { compactionPlan: decision.compactionPlan } : {}),
|
|
262
268
|
});
|
|
263
269
|
|
|
270
|
+
// Redaction, immediately after the body is rendered and before ANYTHING
|
|
271
|
+
// can dispatch it. This is the only choke point that covers every path:
|
|
272
|
+
// all three front ends normalise to this shape, and every upstream client
|
|
273
|
+
// renders its own protocol from it, so a provider added later is covered
|
|
274
|
+
// without being told. The count is recorded on the ledger row below; the
|
|
275
|
+
// matched text is never logged, at any level.
|
|
276
|
+
let redactions = 0;
|
|
277
|
+
if (redactionRules.length > 0) {
|
|
278
|
+
redactions = redactUpstreamBody(body, redactionRules, { scanTools: config.redaction.scanTools });
|
|
279
|
+
if (redactions > 0) log.debug("redacted outgoing request", { matches: redactions, rules: redactionRules.length });
|
|
280
|
+
}
|
|
281
|
+
|
|
264
282
|
// Calibrate the token estimate against the bytes that actually go out —
|
|
265
283
|
// after compaction shrank the prompt and the context block was appended
|
|
266
284
|
// — not the raw request the estimate was taken from.
|
|
@@ -333,6 +351,9 @@ export async function runTurn(
|
|
|
333
351
|
upstreamGenerationId: generationId,
|
|
334
352
|
error: fields.error,
|
|
335
353
|
promptTokensSaved: decision.promptTokensSaved,
|
|
354
|
+
// Evidence that the guard ran, as a count and nothing more. Absent
|
|
355
|
+
// while redaction is off, so an untouched row stays NULL.
|
|
356
|
+
...(redactionRules.length === 0 ? {} : { redactions }),
|
|
336
357
|
// The ledger prices OpenRouter slugs from its own cached payload; any
|
|
337
358
|
// other provider's model exists only in the live catalog.
|
|
338
359
|
...(priceModel === undefined ? {} : { priceModel }),
|
package/src/util/sqlite.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { mkdirSync } from "node:fs";
|
|
|
18
18
|
import { dirname } from "node:path";
|
|
19
19
|
|
|
20
20
|
/** Bump when a migration is added; guarded below so reopening never regresses it. */
|
|
21
|
-
const USER_VERSION =
|
|
21
|
+
const USER_VERSION = 19;
|
|
22
22
|
|
|
23
23
|
const MIGRATIONS = `
|
|
24
24
|
CREATE TABLE IF NOT EXISTS catalog_cache (
|
|
@@ -296,6 +296,16 @@ const MIGRATE_V18 = `
|
|
|
296
296
|
ALTER TABLE ledger ADD COLUMN scope TEXT;
|
|
297
297
|
`;
|
|
298
298
|
|
|
299
|
+
// v19: ledger records how many strings redaction removed from the turn's
|
|
300
|
+
// outgoing request (see `redaction` in the config). A COUNT and nothing else:
|
|
301
|
+
// the whole point of redaction is that the matched text does not exist outside
|
|
302
|
+
// the client, so the evidence that it ran must not reintroduce it. NULL on
|
|
303
|
+
// every row written before this and on every turn with redaction off; 0 means
|
|
304
|
+
// the rules ran and matched nothing.
|
|
305
|
+
const MIGRATE_V19 = `
|
|
306
|
+
ALTER TABLE ledger ADD COLUMN redactions INTEGER;
|
|
307
|
+
`;
|
|
308
|
+
|
|
299
309
|
// v9: benchmark_cache holds the external benchmark feeds (Artificial Analysis,
|
|
300
310
|
// BenchLM) that backfill quality scores OpenRouter leaves unpublished. It is a
|
|
301
311
|
// whole new table, created idempotently by the MIGRATIONS block above, so there
|
|
@@ -320,6 +330,17 @@ export function openDb(path: string): Database {
|
|
|
320
330
|
// ":memory:" has no parent directory to create.
|
|
321
331
|
if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
|
|
322
332
|
const db = new Database(path);
|
|
333
|
+
// Incremental auto-vacuum so retention can hand freed pages back to the
|
|
334
|
+
// filesystem (`PRAGMA incremental_vacuum` after a prune). SQLite only honours
|
|
335
|
+
// a change of vacuum mode on an empty database or across a full VACUUM, so
|
|
336
|
+
// this takes effect for NEW ledgers; an existing one keeps its mode and
|
|
337
|
+
// simply reuses freed pages instead of releasing them. Must precede the
|
|
338
|
+
// journal-mode change, and is best-effort: never fail an open over it.
|
|
339
|
+
try {
|
|
340
|
+
db.exec("PRAGMA auto_vacuum = INCREMENTAL");
|
|
341
|
+
} catch {
|
|
342
|
+
/* an existing database in another mode; freed pages are reused instead */
|
|
343
|
+
}
|
|
323
344
|
// WAL + NORMAL: single-writer local service; favours read latency on the turn hot path.
|
|
324
345
|
db.exec("PRAGMA journal_mode = WAL");
|
|
325
346
|
db.exec("PRAGMA synchronous = NORMAL");
|
|
@@ -338,6 +359,7 @@ export function openDb(path: string): Database {
|
|
|
338
359
|
if (!ledgerCols.some((c) => c.name === "hold_arm")) db.exec(MIGRATE_V8);
|
|
339
360
|
if (!ledgerCols.some((c) => c.name === "prompt_tokens_saved")) db.exec(MIGRATE_V12);
|
|
340
361
|
if (!ledgerCols.some((c) => c.name === "scope")) db.exec(MIGRATE_V18);
|
|
362
|
+
if (!ledgerCols.some((c) => c.name === "redactions")) db.exec(MIGRATE_V19);
|
|
341
363
|
const convCols = db.query("PRAGMA table_info(conversations)").all() as { name: string }[];
|
|
342
364
|
if (!convCols.some((c) => c.name === "context_version")) db.exec(MIGRATE_V11);
|
|
343
365
|
if (!convCols.some((c) => c.name === "compaction_plan")) db.exec(MIGRATE_V13);
|
|
@@ -142,7 +142,7 @@ describe("validateField", () => {
|
|
|
142
142
|
|
|
143
143
|
describe("WIZARD_SECTIONS coverage", () => {
|
|
144
144
|
/** Leaves that are edited as whole records/arrays rather than fields. */
|
|
145
|
-
const RECORD_PATHS = new Set(["ollama.prices", "ollama.twins", "digest.toolAliases", "profiles", "upstreams"]);
|
|
145
|
+
const RECORD_PATHS = new Set(["ollama.prices", "ollama.twins", "digest.toolAliases", "profiles", "upstreams", "redaction.rules"]);
|
|
146
146
|
|
|
147
147
|
function leaves(obj: unknown, prefix = ""): string[] {
|
|
148
148
|
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) return [prefix];
|
package/test/failover.test.ts
CHANGED
|
@@ -83,6 +83,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
83
83
|
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
84
|
profiles: [],
|
|
85
85
|
ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 , retentionDays: 0,},
|
|
86
|
+
redaction: { enabled: false, rules: [], scanTools: false },
|
|
86
87
|
adaptiveTierFloors: true,
|
|
87
88
|
adaptivePriceCeilings: false,
|
|
88
89
|
logLevel: "silent",
|
package/test/migrations.test.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { openDb } from "../src/util/sqlite.ts";
|
|
|
24
24
|
|
|
25
25
|
const FIXTURES = join(import.meta.dir, "fixtures", "migrations");
|
|
26
26
|
const files = readdirSync(FIXTURES).filter((f) => /^router-v\d+\.db$/.test(f)).sort((a, b) => Number(/\d+/.exec(a)![0]) - Number(/\d+/.exec(b)![0]));
|
|
27
|
-
const CURRENT_VERSION =
|
|
27
|
+
const CURRENT_VERSION = 19;
|
|
28
28
|
|
|
29
29
|
describe("schema migrations from every shipped version", () => {
|
|
30
30
|
test("fixtures exist for the versions that shipped", () => {
|
|
@@ -44,7 +44,7 @@ describe("schema migrations from every shipped version", () => {
|
|
|
44
44
|
expect((db.query("PRAGMA user_version").get() as { user_version: number }).user_version).toBe(CURRENT_VERSION);
|
|
45
45
|
// Every column the current code writes exists after migration.
|
|
46
46
|
const ledgerCols = new Set((db.query("PRAGMA table_info(ledger)").all() as { name: string }[]).map((c) => c.name));
|
|
47
|
-
for (const c of ["harness_id", "error_kind", "omp_session_id", "features", "explored_from", "hold_arm", "prompt_tokens_saved", "scope"]) expect(ledgerCols.has(c)).toBe(true);
|
|
47
|
+
for (const c of ["harness_id", "error_kind", "omp_session_id", "features", "explored_from", "hold_arm", "prompt_tokens_saved", "scope", "redactions"]) expect(ledgerCols.has(c)).toBe(true);
|
|
48
48
|
const convCols = new Set((db.query("PRAGMA table_info(conversations)").all() as { name: string }[]).map((c) => c.name));
|
|
49
49
|
for (const c of ["context_version", "compaction_plan", "compaction_plan_tokens", "upgrade_deferred_tier"]) expect(convCols.has(c)).toBe(true);
|
|
50
50
|
// The fixture's ledger row survived the ALTERs with its values.
|
|
@@ -67,7 +67,10 @@ describe("schema migrations from every shipped version", () => {
|
|
|
67
67
|
expect(exportRows(db, 0, null).map((r) => r.scope)).toEqual([""]);
|
|
68
68
|
expect(spendUsdSince(db, 0, null, "acme.api")).toBe(0);
|
|
69
69
|
expect(spendUsdSince(db, 0, null)).toBeGreaterThanOrEqual(0);
|
|
70
|
-
expect(ledger.prune?.(0)).toBe(0);
|
|
70
|
+
expect(ledger.prune?.(0)?.deleted).toBe(0);
|
|
71
|
+
// v19: the fixture's row predates `redactions`, so nothing claims a
|
|
72
|
+
// redaction happened on it and the report totals it as zero.
|
|
73
|
+
expect(buildUsageReport(db, { windowDays: 3650 }).totals.redactions).toBe(0);
|
|
71
74
|
} finally {
|
|
72
75
|
db.close();
|
|
73
76
|
try {
|
|
@@ -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
|
+
});
|
package/test/report-hub.test.ts
CHANGED