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,265 @@
1
+ /**
2
+ * Redaction rules: the guard that decides which patterns are allowed to run,
3
+ * and the compiler that turns a rule set into regular expressions once.
4
+ *
5
+ * A rule is configuration meeting text from a user, which is exactly the shape
6
+ * that backtracks: a pattern an operator wrote once, run against megabytes of
7
+ * tool output on every turn of every conversation. A redaction rule that hangs
8
+ * a request is worse than no rule at all, so the pattern is compiled ONCE, at
9
+ * load, and the constructs with exponential worst cases are REFUSED rather
10
+ * than trusted — the same view `src/context/scope.ts` takes of regular
11
+ * expressions anywhere near the turn path.
12
+ *
13
+ * The refusal happens here, in config, so a bad rule is a startup error naming
14
+ * the rule and the reason, never a rule the router quietly skipped while the
15
+ * operator believed it was removing something. `src/server/redact.ts` applies
16
+ * what this compiles.
17
+ */
18
+
19
+ import type { RedactionConfig, RedactionRule } from "./types.ts";
20
+
21
+ /** One rule with its pattern already compiled. */
22
+ export interface CompiledRedactionRule {
23
+ name: string;
24
+ /** Global; `replace` owns `lastIndex`. */
25
+ regex: RegExp;
26
+ replacement: string;
27
+ }
28
+
29
+ /**
30
+ * Bounds on a rule, all deliberately small. A redaction rule describes the
31
+ * SHAPE OF A SECRET — an API key prefix, an account number, an internal
32
+ * hostname — and every real one is short and literal. A pattern that needs
33
+ * more than this is doing something a redaction rule should not.
34
+ */
35
+ const MAX_PATTERN_CHARS = 512;
36
+ export const MAX_REDACTION_RULES = 64;
37
+
38
+ /** A rule name: it is echoed into the prompt as `[redacted:<name>]`, so it stays boring. */
39
+ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$/;
40
+
41
+ /** The replacement a rule without one gets. Names the rule, never the match. */
42
+ export function defaultReplacement(name: string): string {
43
+ return `[redacted:${name}]`;
44
+ }
45
+
46
+ /** Is `source[i]` an unbounded quantifier (`*`, `+`, `{n,}`)? Returns its length, or 0. */
47
+ function unboundedQuantifierAt(source: string, i: number): number {
48
+ const c = source[i];
49
+ if (c === "*" || c === "+") return 1;
50
+ if (c !== "{") return 0;
51
+ const close = source.indexOf("}", i);
52
+ if (close < 0) return 0;
53
+ // `{n,}` is unbounded; `{n}` and `{n,m}` are not.
54
+ return /^\{\d+,\}$/.test(source.slice(i, close + 1)) ? close + 1 - i : 0;
55
+ }
56
+
57
+ /**
58
+ * Walks a pattern source once, character by character, honouring escapes and
59
+ * character classes, and reports the first construct we refuse to run.
60
+ *
61
+ * Two are refused, and they are the two that make a backtracking engine
62
+ * exponential rather than merely slow:
63
+ *
64
+ * - **A backreference** (`\1`, `\k<name>`). It takes the pattern outside the
65
+ * regular languages, so no linear-time strategy exists for it at all.
66
+ * - **An unbounded quantifier over a group that itself repeats without bound
67
+ * or offers a choice** — `(a+)+`, `(?:a|a)*`. These are the textbook
68
+ * catastrophic shapes: the number of ways to split the same input across
69
+ * the two quantifiers (or the two branches) grows exponentially with its
70
+ * length, so a single non-matching tool result can pin a core for minutes.
71
+ * A bounded outer quantifier is fine, which is why `(?:\d{1,3}\.){3}` — the
72
+ * shape real rules actually use — still loads.
73
+ */
74
+ function refusedConstruct(source: string): string | null {
75
+ const groupStarts: number[] = [];
76
+ let inClass = false;
77
+ for (let i = 0; i < source.length; i++) {
78
+ const c = source[i]!;
79
+ if (c === "\\") {
80
+ const next = source[i + 1];
81
+ if (!inClass && next !== undefined && (/[1-9]/.test(next) || next === "k")) {
82
+ return "backreferences (\\1, \\k<name>) are not allowed: they take the pattern outside the regular languages, where no bound on matching time exists";
83
+ }
84
+ i++;
85
+ continue;
86
+ }
87
+ if (inClass) {
88
+ if (c === "]") inClass = false;
89
+ continue;
90
+ }
91
+ if (c === "[") {
92
+ inClass = true;
93
+ continue;
94
+ }
95
+ if (c === "(") {
96
+ groupStarts.push(i);
97
+ continue;
98
+ }
99
+ if (c !== ")") continue;
100
+ const start = groupStarts.pop();
101
+ // Unbalanced: `new RegExp` reports it far better than we could.
102
+ if (start === undefined) continue;
103
+ if (unboundedQuantifierAt(source, i + 1) === 0) continue;
104
+ const body = source.slice(start + 1, i);
105
+ if (containsUnbounded(body)) {
106
+ return `nested unbounded quantifiers ("${source.slice(start, i + 2)}"): a group that repeats without bound must not repeat without bound inside, or matching can take exponential time`;
107
+ }
108
+ if (containsAlternation(body)) {
109
+ return `an alternation inside an unbounded quantifier ("${source.slice(start, i + 2)}"): the branches can match the same input many ways, so matching can take exponential time`;
110
+ }
111
+ }
112
+ return null;
113
+ }
114
+
115
+ /** Does this fragment contain an unbounded quantifier outside a class or escape? */
116
+ function containsUnbounded(fragment: string): boolean {
117
+ let inClass = false;
118
+ for (let i = 0; i < fragment.length; i++) {
119
+ const c = fragment[i]!;
120
+ if (c === "\\") {
121
+ i++;
122
+ continue;
123
+ }
124
+ if (inClass) {
125
+ if (c === "]") inClass = false;
126
+ continue;
127
+ }
128
+ if (c === "[") inClass = true;
129
+ else if (unboundedQuantifierAt(fragment, i) > 0) return true;
130
+ }
131
+ return false;
132
+ }
133
+
134
+ /** Does this fragment contain an alternation outside a class or escape? */
135
+ function containsAlternation(fragment: string): boolean {
136
+ let inClass = false;
137
+ for (let i = 0; i < fragment.length; i++) {
138
+ const c = fragment[i]!;
139
+ if (c === "\\") {
140
+ i++;
141
+ continue;
142
+ }
143
+ if (inClass) {
144
+ if (c === "]") inClass = false;
145
+ continue;
146
+ }
147
+ if (c === "[") inClass = true;
148
+ else if (c === "|") return true;
149
+ }
150
+ return false;
151
+ }
152
+
153
+ /**
154
+ * Compiles one pattern under the guard, or returns why it was refused.
155
+ *
156
+ * Unicode first: `u` rejects sloppy escapes and malformed quantifiers at load
157
+ * rather than silently meaning something else, and it makes `.` and classes
158
+ * operate on code points, so a rule cannot be defeated by an astral character
159
+ * splitting a surrogate pair. A legacy pattern that only `u` rejects (an
160
+ * unescaped `{`, an octal escape) still loads without it — an operator's
161
+ * working rule should not break on an upgrade — so `u` is a preference, not a
162
+ * requirement.
163
+ *
164
+ * Exported so a front door (the team edition's dashboard) can tell an operator
165
+ * a rule is bad while they are typing it, with the same message the router
166
+ * would refuse it with.
167
+ */
168
+ export function compileRedactionPattern(source: string): { regex: RegExp } | { error: string } {
169
+ if (source === "") return { error: "pattern must not be empty" };
170
+ if (source.length > MAX_PATTERN_CHARS) {
171
+ return { error: `pattern is ${source.length} characters; the limit is ${MAX_PATTERN_CHARS}` };
172
+ }
173
+ const refused = refusedConstruct(source);
174
+ if (refused !== null) return { error: refused };
175
+ let regex: RegExp | null = null;
176
+ let unicode = true;
177
+ try {
178
+ regex = new RegExp(source, "gu");
179
+ } catch {
180
+ unicode = false;
181
+ }
182
+ if (regex === null) {
183
+ try {
184
+ regex = new RegExp(source, "g");
185
+ } catch (err) {
186
+ return { error: `pattern does not compile: ${err instanceof Error ? err.message : String(err)}` };
187
+ }
188
+ }
189
+ // A pattern that matches the empty string would replace at every position,
190
+ // turning the prompt into replacement text. Cheaper to refuse than to
191
+ // special-case. Tested on a non-global copy so `lastIndex` stays untouched.
192
+ if (new RegExp(source, unicode ? "u" : "").test("")) {
193
+ return { error: "pattern matches the empty string, which would replace every position in the prompt" };
194
+ }
195
+ return { regex };
196
+ }
197
+
198
+ /** The reason a pattern is refused, or null when it is fine. */
199
+ export function validateRedactionPattern(source: string): string | null {
200
+ const result = compileRedactionPattern(source);
201
+ return "error" in result ? result.error : null;
202
+ }
203
+
204
+ /**
205
+ * The reason a rule is refused, or null. Checks the name too, since it is
206
+ * echoed into the prompt. Takes the two fields it reads rather than a whole
207
+ * `RedactionRule`, so the config schema can hand it a parsed input object.
208
+ */
209
+ export function validateRedactionRule(rule: { name: string; pattern: string }): string | null {
210
+ if (!NAME_RE.test(rule.name)) {
211
+ return "name must be 1-64 characters of letters, digits, spaces, dot, underscore or dash";
212
+ }
213
+ return validateRedactionPattern(rule.pattern);
214
+ }
215
+
216
+ /**
217
+ * Compiles a whole rule set. Throws on the first bad rule, naming it: a
218
+ * redaction rule that does not load is a hole in the guard, so the router
219
+ * refuses to start rather than quietly forwarding what the operator believed
220
+ * was being removed.
221
+ */
222
+ export function compileRedactionRules(rules: readonly RedactionRule[]): CompiledRedactionRule[] {
223
+ if (rules.length > MAX_REDACTION_RULES) {
224
+ throw new Error(`redaction.rules has ${rules.length} rules; the limit is ${MAX_REDACTION_RULES}`);
225
+ }
226
+ const out: CompiledRedactionRule[] = [];
227
+ for (const rule of rules) {
228
+ const compiled = compileRedactionPattern(rule.pattern);
229
+ if ("error" in compiled) throw new Error(`redaction rule "${rule.name}": ${compiled.error}`);
230
+ if (!NAME_RE.test(rule.name)) {
231
+ throw new Error(`redaction rule "${rule.name}": name must be 1-64 characters of letters, digits, spaces, dot, underscore or dash`);
232
+ }
233
+ out.push({
234
+ name: rule.name,
235
+ regex: compiled.regex,
236
+ replacement: rule.replacement ?? defaultReplacement(rule.name),
237
+ });
238
+ }
239
+ return out;
240
+ }
241
+
242
+ /**
243
+ * Compiled rules for a config, memoised on the rules themselves.
244
+ *
245
+ * The turn path must not recompile a pattern per turn, and the live config is
246
+ * MUTATED in place by hot reload and by an embedder's `reconfigure`, so a
247
+ * reference check would miss an edit. The signature is the rules' own text —
248
+ * a few short strings, joined — so an edited rule set compiles once more and
249
+ * an unedited one is a map lookup.
250
+ */
251
+ const compiledCache = new Map<string, CompiledRedactionRule[]>();
252
+
253
+ export function redactionRulesFor(cfg: RedactionConfig): CompiledRedactionRule[] {
254
+ if (!cfg.enabled || cfg.rules.length === 0) return [];
255
+ // NUL-separated so no rule text can forge another rule's boundary.
256
+ const signature = cfg.rules.map((r) => [r.name, r.pattern, r.replacement ?? ""].join("\u0000")).join("\u0001");
257
+ const hit = compiledCache.get(signature);
258
+ if (hit !== undefined) return hit;
259
+ const compiled = compileRedactionRules(cfg.rules);
260
+ // One process can host several routers (the team edition restarts an
261
+ // embedded one on the same port); keep the map from growing with them.
262
+ if (compiledCache.size >= 16) compiledCache.clear();
263
+ compiledCache.set(signature, compiled);
264
+ return compiled;
265
+ }
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { MAX_REDACTION_RULES, validateRedactionRule } from "./redaction.ts";
2
3
 
3
4
  /**
4
5
  * Input schema for `$AUTO_MODEL_ROUTER_HOME/config.yml`: a deep partial of
@@ -269,7 +270,29 @@ const ledger = z.strictObject({
269
270
  blendMinSamples: z.number().int().nonnegative().optional(),
270
271
  fallbackBlend: fallbackBlend.optional(),
271
272
  conversationTtlMs: z.number().positive().optional(),
272
- retentionDays: z.number().int().nonnegative().optional(),
273
+ // `null` and 0 both mean "keep everything"; null is the default.
274
+ retentionDays: z.number().int().nonnegative().nullable().optional(),
275
+ });
276
+
277
+ /**
278
+ * Redaction rules are compiled at load, and a pattern that cannot be run
279
+ * safely is rejected HERE, with the reason, rather than at the first turn:
280
+ * a rule an operator believes is removing something must never be a rule the
281
+ * router quietly skipped.
282
+ */
283
+ const redactionRule = z.strictObject({
284
+ name: z.string().min(1),
285
+ pattern: z.string().min(1),
286
+ replacement: z.string().optional(),
287
+ }).superRefine((rule, ctx) => {
288
+ const reason = validateRedactionRule(rule);
289
+ if (reason !== null) ctx.addIssue({ code: "custom", message: reason });
290
+ });
291
+
292
+ const redaction = z.strictObject({
293
+ enabled: z.boolean().optional(),
294
+ rules: z.array(redactionRule).max(MAX_REDACTION_RULES).optional(),
295
+ scanTools: z.boolean().optional(),
273
296
  });
274
297
 
275
298
  // Complete entries: arrays replace wholesale, so a partial profile would
@@ -342,6 +365,7 @@ export const configInputSchema = z.strictObject({
342
365
  })
343
366
  .optional(),
344
367
  ledger: ledger.optional(),
368
+ redaction: redaction.optional(),
345
369
  adaptiveTierFloors: z.boolean().optional(),
346
370
  adaptivePriceCeilings: z.boolean().optional(),
347
371
  logLevel: logLevel.optional(),
@@ -710,12 +710,58 @@ export interface LedgerConfig {
710
710
  /** Drop conversation state untouched for longer than this, ms. */
711
711
  conversationTtlMs: number;
712
712
  /**
713
- * Delete ledger rows older than this many days (checked hourly). 0 keeps
714
- * everything. The ledger grows ~2.5 MB a day under steady use; trust,
715
- * reports and replay only read windows well inside a year. Freed pages
716
- * are reused, so the file stops growing rather than shrinking.
713
+ * Delete ledger rows and the feedback keyed to them older than this
714
+ * many days, checked at most hourly. `null` (the default) and 0 both keep
715
+ * everything.
716
+ *
717
+ * The default is to keep, because how long a record of what people asked a
718
+ * model is retained is a decision an operator makes, not one a default
719
+ * should make for them: deleting is the irreversible direction. The ledger
720
+ * grows ~2.5 MB a day under steady use, and trust, reports and replay only
721
+ * read windows well inside a year, so a deployment that wants a window
722
+ * loses nothing by setting one. Freed pages are reused (and released where
723
+ * the engine can), so the file mostly stops growing rather than shrinking.
724
+ */
725
+ retentionDays: number | null;
726
+ }
727
+
728
+ /** One redaction rule: a name, the pattern it matches, and what replaces a match. */
729
+ export interface RedactionRule {
730
+ /**
731
+ * Identifies the rule in errors and in the default replacement. Echoed into
732
+ * the prompt as `[redacted:<name>]`, so it must not itself be a secret.
733
+ */
734
+ name: string;
735
+ /**
736
+ * Regular-expression SOURCE (no delimiters, no flags), compiled once at
737
+ * load under a guard that refuses the shapes with exponential worst cases —
738
+ * see `validateRedactionPattern` in `src/server/redact.ts` for exactly what
739
+ * is refused and why.
740
+ */
741
+ pattern: string;
742
+ /** What a match becomes. Defaults to `[redacted:<name>]`. */
743
+ replacement?: string;
744
+ }
745
+
746
+ /**
747
+ * Keep strings out of every request that leaves the process.
748
+ *
749
+ * Off by default. Enabled, each rule is applied to the rendered upstream body
750
+ * just before dispatch — the one shape every front end normalises to and every
751
+ * provider client renders from — so a new upstream cannot bypass it. The
752
+ * ledger row records HOW MANY matches were removed and never what they were;
753
+ * nothing logs the matched text at any level.
754
+ */
755
+ export interface RedactionConfig {
756
+ enabled: boolean;
757
+ rules: RedactionRule[];
758
+ /**
759
+ * Also scan tool-call arguments and tool results. Off by default: tool
760
+ * results are most of a turn's prompt bytes, so this is most of the cost —
761
+ * and, for an operator worried about a secret in a file the agent read,
762
+ * most of the point.
717
763
  */
718
- retentionDays: number;
764
+ scanTools: boolean;
719
765
  }
720
766
 
721
767
  /**
@@ -929,6 +975,7 @@ export interface RouterConfig {
929
975
  anthropic: AnthropicConfig;
930
976
  profiles: ProfileConfig[];
931
977
  ledger: LedgerConfig;
978
+ redaction: RedactionConfig;
932
979
  /**
933
980
  * Relax a tier's quality floor to a catalog-derived band when the configured
934
981
  * floor is met by fewer than three available models (never tightening it).
@@ -27,6 +27,7 @@ import type {
27
27
  ModelCacheReliability,
28
28
  ModelLatency,
29
29
  ModelTrust,
30
+ PruneResult,
30
31
  SoftFailureSpike,
31
32
  UsageCounts,
32
33
  } from "./types.ts";
@@ -100,6 +101,7 @@ export interface LedgerRow {
100
101
  error: string | null;
101
102
  prompt_tokens_saved: number | null;
102
103
  scope: string | null;
104
+ redactions: number | null;
103
105
  }
104
106
 
105
107
  interface TrustRow {
@@ -278,6 +280,9 @@ export function toEntry(row: LedgerRow): LedgerEntry {
278
280
  // Optional under exactOptionalPropertyTypes: an old row (or a scopeless
279
281
  // turn) simply has no `scope`, rather than an explicit undefined.
280
282
  ...(row.scope === null || row.scope === undefined ? {} : { scope: row.scope }),
283
+ // Likewise a row from before v19, or a turn with redaction off: absent,
284
+ // which is a different fact from 0 (the rules ran and matched nothing).
285
+ ...(row.redactions === null || row.redactions === undefined ? {} : { redactions: row.redactions }),
281
286
  };
282
287
  }
283
288
 
@@ -323,8 +328,8 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
323
328
  id, created_at_ms, conversation_key, session_id, turn, requested_model, harness_id, omp_session_id, slug, served_slug,
324
329
  tier, classification_source, reasons, predicted_usd, reported_usd, usage, cost_breakdown,
325
330
  attempt, escalation_signal, latency_ms, ttft_ms, finish_reason, wasted, upstream_generation_id, error,
326
- error_kind, features, score, confidence, task, classifier_reasons, explored_from, hold_arm, prompt_tokens_saved, scope
327
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
331
+ error_kind, features, score, confidence, task, classifier_reasons, explored_from, hold_arm, prompt_tokens_saved, scope, redactions
332
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
328
333
  );
329
334
  const calibrationStmt = db.query(
330
335
  `INSERT INTO token_calibration (tokenizer, est_bytes, actual_tokens, samples) VALUES (?, ?, ?, 1)
@@ -380,9 +385,17 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
380
385
  const ratioStmt = db.query("SELECT est_bytes, actual_tokens, samples FROM token_calibration WHERE tokenizer = ?");
381
386
  const recentStmt = db.query("SELECT * FROM ledger ORDER BY created_at_ms DESC LIMIT ?");
382
387
  const pruneStmt = db.query("DELETE FROM ledger WHERE created_at_ms < ?");
388
+ // Feedback is a verdict ON a ledger row; keeping it past the turn it judges
389
+ // would leave a note about a conversation the operator asked us to forget.
390
+ // Matched by the row it points at AND by its own age, so verdicts orphaned
391
+ // by a prune that ran before v0.21.0 are swept up too.
392
+ const pruneFeedbackStmt = db.query(
393
+ "DELETE FROM feedback WHERE created_at_ms < ? OR ledger_id IN (SELECT id FROM ledger WHERE created_at_ms < ?)",
394
+ );
383
395
  // Ollama meter samples (one per usage poll) only matter for the current
384
396
  // billing cycle's calibration; they age out with the ledger rows.
385
397
  const pruneMeterStmt = db.query("DELETE FROM ollama_meter_samples WHERE at_ms < ?");
398
+ const oldestStmt = db.query("SELECT MIN(created_at_ms) AS oldest FROM ledger");
386
399
  const wasteStmt = db.query("UPDATE ledger SET wasted = 1 WHERE id = ?");
387
400
  const providerSpendStmt = db.query(
388
401
  "SELECT COALESCE(SUM(COALESCE(reported_usd, predicted_usd)), 0) AS total FROM ledger WHERE created_at_ms >= ? AND COALESCE(served_slug, slug) LIKE ?",
@@ -480,6 +493,8 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
480
493
  // A turn that carried no scope stores NULL, exactly as every row
481
494
  // written before v18 did; "" and absent are the same fact.
482
495
  entry.scope === undefined || entry.scope === "" ? null : entry.scope,
496
+ // NULL when redaction was off for this turn; 0 says the rules ran.
497
+ entry.redactions ?? null,
483
498
  );
484
499
  // Always consume the pending estimate, even when the turn failed, so a
485
500
  // dead turn's bytes can never pair with a later turn's tokens. Only
@@ -635,11 +650,30 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
635
650
  const row = providerSpendStmt.get(sinceMs, `${slugPrefix}%`) as { total: number } | null;
636
651
  return row?.total ?? 0;
637
652
  },
638
- prune(retentionDays: number, nowMs = Date.now()): number {
639
- if (retentionDays <= 0) return 0;
653
+ prune(retentionDays: number | null, nowMs = Date.now()): PruneResult {
654
+ const oldestKeptMs = (): number | null => (oldestStmt.get() as { oldest: number | null } | null)?.oldest ?? null;
655
+ // null and 0 are the same instruction: keep everything. Still reports
656
+ // how far back the ledger goes, which is what the caller asked.
657
+ if (retentionDays === null || retentionDays <= 0) return { deleted: 0, oldestKeptMs: oldestKeptMs() };
640
658
  const cutoff = nowMs - retentionDays * DAY_MS;
659
+ // Dependants first: the feedback statement reads the rows being deleted.
660
+ pruneFeedbackStmt.run(cutoff, cutoff);
641
661
  pruneMeterStmt.run(cutoff);
642
- return pruneStmt.run(cutoff).changes;
662
+ const deleted = pruneStmt.run(cutoff).changes;
663
+ // Hand the freed pages back where the engine can (a ledger created at
664
+ // v0.21.0 or later is auto_vacuum=INCREMENTAL; an older file reuses
665
+ // them instead), then fold the WAL back so the space is real on disk.
666
+ // Best-effort by design: a full ledger that could not shrink is a far
667
+ // smaller problem than a prune that throws.
668
+ if (deleted > 0) {
669
+ try {
670
+ db.exec("PRAGMA incremental_vacuum");
671
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
672
+ } catch {
673
+ /* freed pages stay in the file, to be reused by later rows */
674
+ }
675
+ }
676
+ return { deleted, oldestKeptMs: oldestKeptMs() };
643
677
  },
644
678
  markWasted(id: string): void {
645
679
  wasteStmt.run(id);
@@ -36,6 +36,14 @@ export interface ReportTotals {
36
36
  digestInputTokens: number;
37
37
  /** Digests the agent went back on: the same tool re-run with the same primary argument afterwards (row marked wasted). */
38
38
  digestReruns: number;
39
+ /**
40
+ * Strings redaction removed from outgoing requests in the window, and how
41
+ * many turns at least one was removed from — "N turns had something
42
+ * removed", which is the sentence an operator has to be able to say. Both 0
43
+ * when redaction is off, and rows written before v0.21.0 count as 0.
44
+ */
45
+ redactions: number;
46
+ redactedTurns: number;
39
47
  /** Forecast accuracy over clean kept rows with a reported cost: mean |predicted − reported| ÷ reported, and the share over-predicted. */
40
48
  forecastSamples: number;
41
49
  forecastMeanError: number;
@@ -253,6 +261,8 @@ export function buildUsageReport(
253
261
  COALESCE(SUM(CASE WHEN requested_model = 'digest' THEN ${USD} ELSE 0 END), 0) AS digest_spend,
254
262
  COALESCE(SUM(CASE WHEN requested_model = 'digest' THEN ${PT} ELSE 0 END), 0) AS digest_input,
255
263
  SUM(CASE WHEN requested_model = 'digest' AND wasted = 1 THEN 1 ELSE 0 END) AS digest_reruns,
264
+ COALESCE(SUM(redactions), 0) AS redactions,
265
+ SUM(CASE WHEN redactions > 0 THEN 1 ELSE 0 END) AS redacted_rows,
256
266
  SUM(CASE WHEN ${FORECASTABLE} THEN 1 ELSE 0 END) AS fc_n,
257
267
  COALESCE(SUM(CASE WHEN ${FORECASTABLE} THEN ABS(predicted_usd - reported_usd) / reported_usd END), 0) AS fc_err,
258
268
  SUM(CASE WHEN ${FORECASTABLE} AND predicted_usd > reported_usd THEN 1 ELSE 0 END) AS fc_over,
@@ -276,6 +286,8 @@ export function buildUsageReport(
276
286
  digest_spend: number;
277
287
  digest_input: number;
278
288
  digest_reruns: number | null;
289
+ redactions: number;
290
+ redacted_rows: number | null;
279
291
  fc_n: number | null;
280
292
  fc_err: number;
281
293
  fc_over: number | null;
@@ -402,6 +414,8 @@ export function buildUsageReport(
402
414
  digestSpendUsd: t.digest_spend,
403
415
  digestInputTokens: t.digest_input,
404
416
  digestReruns: t.digest_reruns ?? 0,
417
+ redactions: t.redactions,
418
+ redactedTurns: t.redacted_rows ?? 0,
405
419
  forecastSamples: t.fc_n ?? 0,
406
420
  forecastMeanError: (t.fc_n ?? 0) > 0 ? t.fc_err / (t.fc_n ?? 1) : 0,
407
421
  forecastOverShare: (t.fc_n ?? 0) > 0 ? (t.fc_over ?? 0) / (t.fc_n ?? 1) : 0,
@@ -477,6 +491,11 @@ export function reportView(r: UsageReport, opts: { maxModels?: number } = {}): R
477
491
  if (t.forecastSamples > 0) {
478
492
  summary.push(`forecast: mean error ${pct(t.forecastMeanError)} of reported cost over ${num(t.forecastSamples)} turns · ${pct(t.forecastOverShare)} over-predicted`);
479
493
  }
494
+ if (t.redactedTurns > 0) {
495
+ // Counts only: the report is read out loud in front of people, and the
496
+ // whole point of the feature is that the matched strings are gone.
497
+ summary.push(`redaction: ${num(t.redactedTurns)} turns had something removed (${num(t.redactions)} strings)`);
498
+ }
480
499
  if (t.subagentDispatches > 0) {
481
500
  summary.push(`subagents: ${num(t.subagentDispatches)} dispatches, ${usd(t.subagentSpendUsd)} (${pct(t.spendUsd > 0 ? t.subagentSpendUsd / t.spendUsd : 0)} of spend)`);
482
501
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Ledger retention: how long turns are kept, and who is allowed to say so.
3
+ *
4
+ * The rule lives here rather than in the server's timer callback for two
5
+ * reasons. The interval has to be enforced in ONE place — the housekeeping
6
+ * timer ticks every minute, the boot path runs early, and `POST
7
+ * /v1/router/prune` can arrive at any moment, and a delete over the whole
8
+ * ledger is not something that should be able to run on every one of those.
9
+ * And a front door of the team edition must never delete from the ledger
10
+ * itself (it holds a read-only handle by design), so the route is the only way
11
+ * it can ask, which makes the once-an-hour gate part of the contract rather
12
+ * than a detail of the caller.
13
+ */
14
+
15
+ import type { Ledger, PruneResult } from "./types.ts";
16
+
17
+ /** Floor between two scheduled prunes. A retention window is measured in days; an hour is fine grain for it. */
18
+ export const RETENTION_INTERVAL_MS = 3_600_000;
19
+
20
+ export interface RetentionRunner {
21
+ /** Prunes when the interval has elapsed since the last run; null when it was skipped. */
22
+ maybeRun(nowMs?: number): PruneResult | null;
23
+ /** Prunes regardless (the route), and satisfies the schedule for the next hour. */
24
+ runNow(nowMs?: number): PruneResult;
25
+ /** The configured window this runner would apply, for a caller that reports it. */
26
+ retentionDays(): number | null;
27
+ }
28
+
29
+ /**
30
+ * Builds the runner over a live config read.
31
+ *
32
+ * `retentionDays` is read through a function, not captured, so a hot reload or
33
+ * an embedder's `reconfigure` changes the window without restarting anything —
34
+ * and so lowering it takes effect on the next tick rather than the next boot.
35
+ */
36
+ export function createRetentionRunner(opts: {
37
+ ledger: Ledger;
38
+ retentionDays: () => number | null;
39
+ intervalMs?: number;
40
+ }): RetentionRunner {
41
+ const intervalMs = opts.intervalMs ?? RETENTION_INTERVAL_MS;
42
+ // Never run: the first call is always due, so a lowered window applies at boot.
43
+ let lastRunMs: number | null = null;
44
+ const run = (nowMs: number): PruneResult => {
45
+ lastRunMs = nowMs;
46
+ return opts.ledger.prune?.(opts.retentionDays(), nowMs) ?? { deleted: 0, oldestKeptMs: null };
47
+ };
48
+ return {
49
+ maybeRun(nowMs = Date.now()) {
50
+ if (lastRunMs !== null && nowMs - lastRunMs < intervalMs) return null;
51
+ return run(nowMs);
52
+ },
53
+ runNow(nowMs = Date.now()) {
54
+ return run(nowMs);
55
+ },
56
+ retentionDays: opts.retentionDays,
57
+ };
58
+ }
package/src/cost/types.ts CHANGED
@@ -153,6 +153,14 @@ export interface LedgerEntry {
153
153
  * door charges the row's spend back to that project with it. NULL before v18.
154
154
  */
155
155
  scope?: string;
156
+ /**
157
+ * How many strings redaction removed from this turn's outgoing request
158
+ * (`redaction` in the config). A COUNT and nothing else — the matched text
159
+ * is precisely what must not exist outside the client, so the evidence that
160
+ * the guard ran must not reintroduce it. Absent when redaction is off, and
161
+ * on every row written before v19; 0 means the rules ran and matched nothing.
162
+ */
163
+ redactions?: number;
156
164
  /**
157
165
  * The catalog model that served, for the cost split. The ledger can price
158
166
  * OpenRouter slugs from its own cached catalog payload; a model from another
@@ -161,6 +169,18 @@ export interface LedgerEntry {
161
169
  priceModel?: CatalogModel;
162
170
  }
163
171
 
172
+ /**
173
+ * What one retention prune did. `oldestKeptMs` is the timestamp of the oldest
174
+ * row still in the ledger afterwards (null when it is empty) — the honest
175
+ * answer to "how far back does this ledger go now", which is what an operator
176
+ * asked the question for, and what a front door shows instead of computing a
177
+ * cutoff of its own.
178
+ */
179
+ export interface PruneResult {
180
+ deleted: number;
181
+ oldestKeptMs: number | null;
182
+ }
183
+
164
184
  /** Rolling blended rate used to keep omp's cost display honest. */
165
185
  export interface BlendedRate {
166
186
  /** USD per million prompt tokens, spend-weighted over the window. */
@@ -310,8 +330,12 @@ export interface Ledger {
310
330
  latestForSession?(ompSessionId: string): LedgerEntry | null;
311
331
  /** Newest entries for an omp session, newest first. Optional. */
312
332
  entriesForSession?(ompSessionId: string, limit: number): LedgerEntry[];
313
- /** Deletes rows older than `retentionDays` (0 ⇒ none); returns how many. Optional. */
314
- prune?(retentionDays: number, nowMs?: number): number;
333
+ /**
334
+ * Deletes ledger rows past the retention window, and the feedback keyed to
335
+ * them (`null` or 0 ⇒ nothing is deleted). Optional so fakes need not
336
+ * implement it.
337
+ */
338
+ prune?(retentionDays: number | null, nowMs?: number): PruneResult;
315
339
  /** Marks one row wasted after the fact (a digest the agent went back on). Optional. */
316
340
  markWasted?(id: string): void;
317
341
  }
package/src/lib.ts CHANGED
@@ -15,7 +15,10 @@
15
15
  export { startServer, type ReconfigureResult, type StartedServer } from "./server/http.ts";
16
16
  export { loadConfig, apiKeySource } from "./config/load.ts";
17
17
  export { DEFAULT_CONFIG } from "./config/defaults.ts";
18
- export type { RouterConfig, UpstreamEntry, UpstreamKind, UpstreamModelConfig } from "./config/types.ts";
18
+ export type { RedactionConfig, RedactionRule, RouterConfig, UpstreamEntry, UpstreamKind, UpstreamModelConfig } from "./config/types.ts";
19
+ // A front door that lets an operator type a redaction rule validates it with
20
+ // the same guard the router refuses it with, before the rule is ever saved.
21
+ export { validateRedactionPattern, validateRedactionRule, defaultReplacement, MAX_REDACTION_RULES } from "./config/redaction.ts";
19
22
  export { RESERVED_UPSTREAM_IDS } from "./config/schema.ts";
20
23
  export { setKnownUpstreamIds, providerOfSlug } from "./cost/report.ts";
21
24
  export type { DeepPartial } from "./config/load.ts";
@@ -29,4 +32,7 @@ export { buildExecutable, collectPackageFiles, executableFileName, hostTarget, i
29
32
  export { parseSkillsBundle, type SkillsBundle } from "./cli/skills.ts";
30
33
  export type { RequestPolicy } from "./wire/types.ts";
31
34
  export type { CatalogView, CatalogViewModel } from "./server/catalog-view.ts";
32
- export type { Ledger, LedgerEntry } from "./cost/types.ts";
35
+ export type { Ledger, LedgerEntry, PruneResult } from "./cost/types.ts";
36
+ // Retention: a front door asks through `POST /v1/router/prune` rather than
37
+ // deleting from the ledger itself. The interval is exported so it can say when.
38
+ export { RETENTION_INTERVAL_MS } from "./cost/retention.ts";