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.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +126 -3
- package/docs/data-governance.md +210 -0
- package/package.json +1 -1
- package/src/catalog/benchmark-feeds.ts +19 -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 +58 -17
- package/src/server/redact.ts +104 -0
- package/src/server/turn.ts +21 -0
- package/src/util/sqlite.ts +23 -1
- package/test/benchmark-feeds.test.ts +35 -0
- package/test/config-wizard.test.ts +1 -1
- package/test/failover.test.ts +1 -0
- package/test/migrations.test.ts +6 -3
- package/test/reconfigure.test.ts +53 -0
- 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
|
@@ -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
|
+
}
|
package/src/config/schema.ts
CHANGED
|
@@ -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
|
-
|
|
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(),
|
package/src/config/types.ts
CHANGED
|
@@ -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
|
|
714
|
-
*
|
|
715
|
-
*
|
|
716
|
-
*
|
|
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
|
-
|
|
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).
|
package/src/cost/ledger.ts
CHANGED
|
@@ -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()):
|
|
639
|
-
|
|
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
|
-
|
|
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);
|
package/src/cost/report.ts
CHANGED
|
@@ -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
|
-
/**
|
|
314
|
-
|
|
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";
|