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
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.21.0",
|
|
11
11
|
"pluginRoot": "."
|
|
12
12
|
},
|
|
13
13
|
"plugins": [
|
|
14
14
|
{
|
|
15
15
|
"name": "auto-model-router",
|
|
16
16
|
"description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
|
|
17
|
-
"version": "0.
|
|
17
|
+
"version": "0.21.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -762,9 +762,10 @@ virtual profile it picked. Every routed response carries
|
|
|
762
762
|
|
|
763
763
|
The ledger records every dispatch: model decided and served, tier, provider,
|
|
764
764
|
tokens (including cached), reported cost, time to first token, total latency,
|
|
765
|
-
escalation signal, error,
|
|
765
|
+
escalation signal, error, the agentdox context scope the turn carried (the
|
|
766
766
|
project it belongs to; NULL for a turn that carried none, and for every row
|
|
767
|
-
written before v0.19.0)
|
|
767
|
+
written before v0.19.0) and how many strings redaction removed from the request
|
|
768
|
+
(NULL when redaction was off, and for every row written before v0.21.0). Three views aggregate it, all from the same
|
|
768
769
|
`buildUsageReport` in `src/cost/report.ts`:
|
|
769
770
|
|
|
770
771
|
- `/router report` in omp — a fullscreen hub with the `/models` look: views
|
|
@@ -802,6 +803,8 @@ written before v0.19.0). Three views aggregate it, all from the same
|
|
|
802
803
|
a comma-separated set of ids, for a group).
|
|
803
804
|
- `GET /v1/router/summary?harness=<id>` — the daily summary as JSON (`auto=1`
|
|
804
805
|
applies the once-a-day gate and returns `due: false` when nothing is due).
|
|
806
|
+
- `POST /v1/router/prune` — applies `ledger.retentionDays` now and answers
|
|
807
|
+
`{ deleted, oldestKeptMs, retentionDays }`. See [Data governance](#data-governance).
|
|
805
808
|
|
|
806
809
|
What it shows, for the window:
|
|
807
810
|
|
|
@@ -1160,7 +1163,28 @@ task needed, and `digest.maxOutputTokens` or `digest.model` is the lever.
|
|
|
1160
1163
|
| `blendMinSamples` | `25` | Turns before the measured blend replaces the fallback. |
|
|
1161
1164
|
| `fallbackBlend` | input `1.5`, output `7.5` | Pre-measurement blend (USD/Mtok) for omp's cost display. |
|
|
1162
1165
|
| `conversationTtlMs` | `604800000` (7 d) | Drop conversation state untouched this long. |
|
|
1163
|
-
| `retentionDays` | `
|
|
1166
|
+
| `retentionDays` | `null` | Delete ledger rows — and the feedback keyed to them — older than this many days, checked at most hourly; `null` (the default) and `0` keep everything. The ledger grows about 2.5 MB a day under steady use. See [Data governance](#data-governance). |
|
|
1167
|
+
|
|
1168
|
+
### `redaction` — keep configured strings out of every request
|
|
1169
|
+
|
|
1170
|
+
| Key | Default | Meaning |
|
|
1171
|
+
| --- | --- | --- |
|
|
1172
|
+
| `enabled` | `false` | Apply the rules to every outgoing request. Off means the router never touches the prompt. |
|
|
1173
|
+
| `rules` | `[]` | `{ name, pattern, replacement? }` entries. `pattern` is a regular-expression source compiled once at load under a guard (see [Data governance](#data-governance)); `replacement` defaults to `[redacted:<name>]`. At most 64 rules. |
|
|
1174
|
+
| `scanTools` | `false` | Also scan tool-call arguments and tool results, not just message text. |
|
|
1175
|
+
|
|
1176
|
+
```yaml
|
|
1177
|
+
# ~/.auto-model-router/config.yml
|
|
1178
|
+
redaction:
|
|
1179
|
+
enabled: true
|
|
1180
|
+
scanTools: true
|
|
1181
|
+
rules:
|
|
1182
|
+
- name: api key
|
|
1183
|
+
pattern: "sk-live-[A-Za-z0-9]{16,}"
|
|
1184
|
+
- name: customer id
|
|
1185
|
+
pattern: "(?:acct|customer)-\\d{6}"
|
|
1186
|
+
replacement: "<customer>"
|
|
1187
|
+
```
|
|
1164
1188
|
|
|
1165
1189
|
### Top-level
|
|
1166
1190
|
|
|
@@ -1372,6 +1396,92 @@ ignored rather than failing the turn. The decision trail records what the
|
|
|
1372
1396
|
policy changed (`policy: …`), and `GET /v1/router/catalog?policy=…` shows what a
|
|
1373
1397
|
policy admits, model by model, without routing a turn.
|
|
1374
1398
|
|
|
1399
|
+
## Data governance
|
|
1400
|
+
|
|
1401
|
+
Two things an operator with a compliance obligation needs from a router: that
|
|
1402
|
+
certain strings never reach a provider, and that they can say how long a record
|
|
1403
|
+
of the traffic is kept. Both are off by default — the router does not touch a
|
|
1404
|
+
prompt or delete a row unless it is told to. Design notes:
|
|
1405
|
+
[`docs/data-governance.md`](docs/data-governance.md).
|
|
1406
|
+
|
|
1407
|
+
### Redaction, before a provider sees the prompt
|
|
1408
|
+
|
|
1409
|
+
With `redaction.enabled`, every rule is applied to the outgoing request
|
|
1410
|
+
**immediately after it is rendered and before anything can dispatch it**. That
|
|
1411
|
+
is one place, deliberately: all three front ends (chat completions, Responses,
|
|
1412
|
+
Anthropic Messages) normalise to the same request shape, and every upstream
|
|
1413
|
+
client — OpenRouter, Ollama, a named OpenAI/Anthropic/vLLM upstream — renders
|
|
1414
|
+
its own protocol from it. A provider added later is covered without being told.
|
|
1415
|
+
|
|
1416
|
+
Message text is always scanned, including the system prompt and the injected
|
|
1417
|
+
agentdox block. With `scanTools`, tool-call arguments and tool results are too
|
|
1418
|
+
— which is where the bytes are (a turn's prompt is mostly file content), and
|
|
1419
|
+
also where the secret an agent just read from disk actually is. Tool names, ids
|
|
1420
|
+
and schemas are never rewritten: they are the harness's vocabulary, and editing
|
|
1421
|
+
one would break the call/result pairing the model needs. A match becomes
|
|
1422
|
+
`[redacted:<name>]`, or the rule's own `replacement`. Nothing else about the
|
|
1423
|
+
turn changes — same routing, same cache breakpoints, same bytes otherwise.
|
|
1424
|
+
|
|
1425
|
+
**The pattern guard.** A rule is configuration meeting text from a user, run
|
|
1426
|
+
against megabytes of tool output on every turn, which is exactly the shape that
|
|
1427
|
+
backtracks. So a pattern is compiled ONCE at load and these are refused, with
|
|
1428
|
+
the reason and the rule's name, rather than trusted:
|
|
1429
|
+
|
|
1430
|
+
| Refused | Why |
|
|
1431
|
+
| --- | --- |
|
|
1432
|
+
| A nested unbounded quantifier — `(a+)+`, `(\d{2,})*` | The classic catastrophic shape: the ways to split one input across both quantifiers grow exponentially with its length. A **bounded** outer quantifier is fine, so `(?:\d{1,3}\.){3}\d{1,3}` still loads. |
|
|
1433
|
+
| An alternation under an unbounded quantifier — `(?:a\|a)*` | The other one: branches that can match the same input many ways. An alternation not under one (`(?:acct\|customer)-\d{6}`) is fine. |
|
|
1434
|
+
| A backreference — `\1`, `\k<name>` | Takes the pattern outside the regular languages, where no bound on matching time exists at all. |
|
|
1435
|
+
| A pattern that matches the empty string | It would replace at every position, turning the prompt into replacement text. |
|
|
1436
|
+
| A pattern over 512 characters, or a set over 64 rules | A rule describes the shape of a secret. Every real one is short. |
|
|
1437
|
+
| A pattern that does not compile | Reported with the engine's own message. |
|
|
1438
|
+
|
|
1439
|
+
Patterns compile with the `u` flag where possible — it rejects sloppy escapes
|
|
1440
|
+
at load rather than letting them mean something else, and makes matching work
|
|
1441
|
+
on code points — falling back to no flag for a legacy pattern that only `u`
|
|
1442
|
+
rejects, so an operator's working rule does not break on an upgrade. A refused
|
|
1443
|
+
rule is a **startup error**, not a warning: a rule the operator believes is
|
|
1444
|
+
removing something must never be a rule the router quietly skipped. The same
|
|
1445
|
+
guard is exported as `validateRedactionPattern`, so a front door can reject a
|
|
1446
|
+
rule while an operator is typing it, with the message the router would use.
|
|
1447
|
+
|
|
1448
|
+
**The evidence, without the secret.** Every ledger row carries `redactions`,
|
|
1449
|
+
the number of strings removed from that turn's request — a count and nothing
|
|
1450
|
+
else, because the matched text is precisely what must not exist outside the
|
|
1451
|
+
client. Nothing logs a match at any level. `GET /v1/router/report` totals it as
|
|
1452
|
+
`redactions` with `redactedTurns` beside it, so a front door can show "N turns
|
|
1453
|
+
had something removed", and the rendered report prints the same line. A row
|
|
1454
|
+
written before v0.21.0, or a turn with redaction off, stores NULL, which is a
|
|
1455
|
+
different fact from `0` (the rules ran and matched nothing).
|
|
1456
|
+
|
|
1457
|
+
### Ledger retention
|
|
1458
|
+
|
|
1459
|
+
`ledger.retentionDays` says how long turns are kept. `null` — the default — and
|
|
1460
|
+
`0` keep everything; a number deletes ledger rows older than that many days,
|
|
1461
|
+
along with the user verdicts (`/router good|bad`) keyed to them and the Ollama
|
|
1462
|
+
meter samples that calibrate them. Keeping is the default because deleting is
|
|
1463
|
+
the direction that cannot be undone, and how long a record of what people asked
|
|
1464
|
+
a model lives is an operator's decision, not a default's.
|
|
1465
|
+
|
|
1466
|
+
The prune runs on the router's own housekeeping schedule, **at most once an
|
|
1467
|
+
hour** however often it is asked, and once shortly after boot so a lowered
|
|
1468
|
+
window applies without waiting. Freed pages are handed back to the filesystem
|
|
1469
|
+
where the engine can (a ledger created at v0.21.0 or later is
|
|
1470
|
+
`auto_vacuum=INCREMENTAL`; an older file reuses them instead), and the WAL is
|
|
1471
|
+
folded back so the space is real on disk.
|
|
1472
|
+
|
|
1473
|
+
```
|
|
1474
|
+
POST /v1/router/prune → { "deleted": 12043, "oldestKeptMs": 1782720000000, "retentionDays": 365 }
|
|
1475
|
+
```
|
|
1476
|
+
|
|
1477
|
+
`oldestKeptMs` is the timestamp of the oldest row still in the ledger — how far
|
|
1478
|
+
back it now goes, which is what the question was actually about; `null` when it
|
|
1479
|
+
is empty. The route exists because a front door of the team edition holds a
|
|
1480
|
+
**read-only** handle on the ledger file by design and must never delete from it
|
|
1481
|
+
itself: it triggers the router's own prune and reads the counts back. It obeys
|
|
1482
|
+
the same hourly floor, so calling it in a loop is harmless, and it needs
|
|
1483
|
+
`server.apiKey` like every other route.
|
|
1484
|
+
|
|
1375
1485
|
## Using a remote router
|
|
1376
1486
|
|
|
1377
1487
|
`auto-model-router connect --url <router> --key <key>` points this machine at a router
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# Data governance: redaction and ledger retention
|
|
2
|
+
|
|
3
|
+
Design notes for the two controls an operator with a compliance obligation
|
|
4
|
+
needs from the router — keeping strings out of every request, and saying how
|
|
5
|
+
long the record of a turn is kept. The README section
|
|
6
|
+
([Data governance](../README.md#data-governance)) is the operator's view; this
|
|
7
|
+
is where the decisions and their reasons live.
|
|
8
|
+
|
|
9
|
+
Both ship **off**. A router that has not been told otherwise does not touch a
|
|
10
|
+
prompt and does not delete a row.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 1. Redaction
|
|
15
|
+
|
|
16
|
+
### Where it sits, and why there
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
wire in core wire out
|
|
20
|
+
─────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
POST /v1/chat/completions ─┐
|
|
22
|
+
POST /v1/responses ────────┼─► parseChatRequest ─► route ─► renderUpstreamBody
|
|
23
|
+
POST /v1/messages ─────────┘ (NormRequest) │
|
|
24
|
+
▼
|
|
25
|
+
┌──── REDACTION ────┐
|
|
26
|
+
│ redactUpstreamBody│
|
|
27
|
+
└─────────┬─────────┘
|
|
28
|
+
▼
|
|
29
|
+
upstream.dispatch
|
|
30
|
+
┌──────────────┼──────────────┐
|
|
31
|
+
OpenRouter Ollama named upstream
|
|
32
|
+
(openai/anthropic/vllm)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`src/server/turn.ts` calls `redactUpstreamBody` between
|
|
36
|
+
`req.renderUpstreamBody(...)` and `upstream.dispatch({ body, ... })`. That is
|
|
37
|
+
the only choke point that covers everything, and it covers it by construction:
|
|
38
|
+
|
|
39
|
+
- **Every wire in** ends up in the same shape. The Responses wire
|
|
40
|
+
(`responsesToChatBody`) and the Anthropic Messages wire (`messagesToChatBody`)
|
|
41
|
+
both translate into the chat-completions body before `parseChatRequest` sees
|
|
42
|
+
it, and `renderUpstreamBody` has exactly one implementation
|
|
43
|
+
(`src/wire/openai/request.ts`).
|
|
44
|
+
- **Every provider out** renders its own protocol *from* that body —
|
|
45
|
+
`src/upstream/openrouter.ts`, `ollama.ts`, `anthropic.ts`, `compat.ts`. A
|
|
46
|
+
provider added later inherits the guard without being told about it.
|
|
47
|
+
|
|
48
|
+
Redacting the `NormRequest` instead would not work: `NormMessage.text` is a
|
|
49
|
+
lossy concatenation used only for classification, and nothing is ever
|
|
50
|
+
dispatched from it. Redacting inside each upstream client would work and would
|
|
51
|
+
be wrong — it is four places today and five tomorrow.
|
|
52
|
+
|
|
53
|
+
Ordering inside the turn also matters. Redaction runs *after* compaction edits,
|
|
54
|
+
the agentdox context block and the cache breakpoints have been applied, so the
|
|
55
|
+
injected project context is scanned too and a rewritten text part keeps its
|
|
56
|
+
`cache_control` marker.
|
|
57
|
+
|
|
58
|
+
### What is scanned
|
|
59
|
+
|
|
60
|
+
| Field | Scanned |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| `messages[].content` (string, and `type: "text"` parts) for every non-tool role | always |
|
|
63
|
+
| `messages[].content` for `role: "tool"` (tool results) | `scanTools` |
|
|
64
|
+
| `messages[].tool_calls[].function.arguments` | `scanTools` |
|
|
65
|
+
| tool names, tool-call ids, `tools[]` schemas, `model` | never |
|
|
66
|
+
|
|
67
|
+
Tool results and arguments are gated because they are most of a turn's prompt
|
|
68
|
+
bytes, so scanning them is most of the CPU — and, for an operator who cares
|
|
69
|
+
about a secret in a file the agent just read, most of the point. Names and ids
|
|
70
|
+
are never touched because rewriting one breaks the call/result pairing the
|
|
71
|
+
model needs; a model slug is the router's own vocabulary, not conversation
|
|
72
|
+
content.
|
|
73
|
+
|
|
74
|
+
An `image_url` part is skipped: it carries a data URI that no redaction rule
|
|
75
|
+
can meaningfully read and that every rule would be slow over.
|
|
76
|
+
|
|
77
|
+
### The pattern guard
|
|
78
|
+
|
|
79
|
+
`src/config/redaction.ts`. A rule is configuration meeting text from a user,
|
|
80
|
+
which is the exact shape that backtracks — a pattern an operator wrote once,
|
|
81
|
+
run against megabytes of tool output on every turn of every conversation. A
|
|
82
|
+
redaction rule that hangs a request is worse than no rule at all, so the
|
|
83
|
+
pattern is compiled once at load and these are refused:
|
|
84
|
+
|
|
85
|
+
1. **A nested unbounded quantifier.** `(a+)+`, `(\d{2,})*`, `([a-z]*)+`. The
|
|
86
|
+
number of ways to split one input across two unbounded quantifiers grows
|
|
87
|
+
exponentially with its length, so a single non-matching tool result can pin
|
|
88
|
+
a core for minutes. Detected by walking the source once, tracking group
|
|
89
|
+
spans (honouring escapes and character classes) and checking the body of any
|
|
90
|
+
group that carries `*`, `+` or `{n,}`. A **bounded** outer quantifier is
|
|
91
|
+
fine, which is why `(?:\d{1,3}\.){3}\d{1,3}` — the shape real rules use —
|
|
92
|
+
still loads.
|
|
93
|
+
2. **An alternation under an unbounded quantifier.** `(?:a|a)*`. The other
|
|
94
|
+
textbook exponential shape. An alternation that is not under one is fine.
|
|
95
|
+
3. **A backreference** (`\1`, `\k<name>`). It takes the pattern outside the
|
|
96
|
+
regular languages, so no bound on matching time exists for it at all.
|
|
97
|
+
4. **A pattern that matches the empty string.** It would replace at every
|
|
98
|
+
position and turn the prompt into replacement text.
|
|
99
|
+
5. **A pattern over 512 characters, or a set over 64 rules.** A rule describes
|
|
100
|
+
the shape of a secret; every real one is short and literal.
|
|
101
|
+
6. **A pattern that does not compile**, reported with the engine's message.
|
|
102
|
+
|
|
103
|
+
Compilation prefers the `u` flag: it rejects sloppy escapes and malformed
|
|
104
|
+
quantifiers at load rather than letting them silently mean something else, and
|
|
105
|
+
it makes matching operate on code points, so a rule cannot be defeated by an
|
|
106
|
+
astral character splitting a surrogate pair. A pattern that only `u` rejects
|
|
107
|
+
(an unescaped `{`, an octal escape) falls back to no flag — an operator's
|
|
108
|
+
working rule must not break on an upgrade — so `u` is a preference, not a
|
|
109
|
+
requirement.
|
|
110
|
+
|
|
111
|
+
Refusal happens in two places, deliberately. The config schema
|
|
112
|
+
(`configInputSchema`) rejects a file rule with the path and reason, so
|
|
113
|
+
`config.yml` fails at load; `startServer` compiles the rule set again before
|
|
114
|
+
the listener exists, which is where an embedder's programmatic overrides — they
|
|
115
|
+
never pass through the schema — are caught. Either way a bad rule is an error,
|
|
116
|
+
never a warning: a rule the operator believes is removing something must never
|
|
117
|
+
be a rule the router quietly skipped.
|
|
118
|
+
|
|
119
|
+
Compiled rules are memoised on the rules' own text (`redactionRulesFor`), not
|
|
120
|
+
on the config object's identity, because hot reload and `reconfigure` mutate
|
|
121
|
+
the live config **in place** — a reference check would miss an edit. So an
|
|
122
|
+
edited rule set compiles once more and an unedited one is a map lookup on the
|
|
123
|
+
turn path.
|
|
124
|
+
|
|
125
|
+
### The evidence
|
|
126
|
+
|
|
127
|
+
`LedgerEntry.redactions` → `ledger.redactions INTEGER`, schema **v19**, added
|
|
128
|
+
the way `scope` was at v18: a guarded `ALTER TABLE ... ADD COLUMN` on open plus
|
|
129
|
+
a `USER_VERSION` bump, so an old ledger opens and gains the column with its
|
|
130
|
+
rows NULL. Three states, all meaningful:
|
|
131
|
+
|
|
132
|
+
| Value | Means |
|
|
133
|
+
| --- | --- |
|
|
134
|
+
| `NULL` | redaction was off for this turn (or the row predates v19) |
|
|
135
|
+
| `0` | the rules ran and matched nothing |
|
|
136
|
+
| `n` | `n` strings were removed from this turn's request |
|
|
137
|
+
|
|
138
|
+
`buildUsageReport` totals it as `redactions` with `redactedTurns`
|
|
139
|
+
(`COUNT(redactions > 0)`) beside it, and the rendered report prints one line
|
|
140
|
+
when there is something to say. Nothing anywhere records the matched text: the
|
|
141
|
+
turn logs a debug line with a count, and the startup line lists rule *names*
|
|
142
|
+
only. A redaction log that quotes the secret is just a second copy of the
|
|
143
|
+
secret.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## 2. Ledger retention
|
|
148
|
+
|
|
149
|
+
### The window
|
|
150
|
+
|
|
151
|
+
`ledger.retentionDays: number | null`. `null` is the default and `0` means the
|
|
152
|
+
same thing: keep everything. Keeping is the default because deleting is the
|
|
153
|
+
direction that cannot be undone, and because how long a record of what people
|
|
154
|
+
asked a model lives is a decision an operator makes for their deployment, not
|
|
155
|
+
one a library default should make for them. (Before v0.21.0 the default was
|
|
156
|
+
365 days.)
|
|
157
|
+
|
|
158
|
+
### What goes
|
|
159
|
+
|
|
160
|
+
`createLedger(...).prune(retentionDays, nowMs)` deletes, in this order:
|
|
161
|
+
|
|
162
|
+
1. `feedback` rows — the user verdicts from `/router good|bad` — matched by
|
|
163
|
+
*both* their own age and the ledger rows about to go
|
|
164
|
+
(`ledger_id IN (SELECT id FROM ledger WHERE created_at_ms < ?)`), so
|
|
165
|
+
verdicts orphaned by a prune from an older version are swept up too. First,
|
|
166
|
+
because the subquery needs the rows that are about to be deleted.
|
|
167
|
+
2. `ollama_meter_samples` past the cutoff. They only calibrate the ledger's own
|
|
168
|
+
Ollama estimate, so they age out with the rows they calibrate.
|
|
169
|
+
3. `ledger` rows past the cutoff.
|
|
170
|
+
|
|
171
|
+
Then, when anything was deleted, `PRAGMA incremental_vacuum` hands freed pages
|
|
172
|
+
back to the filesystem and `PRAGMA wal_checkpoint(TRUNCATE)` folds the WAL so
|
|
173
|
+
the space is real on disk. Both are best-effort inside a `try`: a ledger that
|
|
174
|
+
could not shrink is a far smaller problem than a prune that throws.
|
|
175
|
+
`openDb` sets `PRAGMA auto_vacuum = INCREMENTAL` before the journal mode, which
|
|
176
|
+
SQLite honours only for a **new** database — an existing ledger keeps its mode
|
|
177
|
+
and reuses freed pages instead of releasing them, which is the pre-v0.21.0
|
|
178
|
+
behaviour and is fine.
|
|
179
|
+
|
|
180
|
+
`PruneResult` is `{ deleted, oldestKeptMs }`. `oldestKeptMs` is
|
|
181
|
+
`MIN(created_at_ms)` over what remains (null when the ledger is empty) — the
|
|
182
|
+
honest answer to "how far back does this ledger go now", which is what the
|
|
183
|
+
question was actually about, and it is reported even when nothing was deleted.
|
|
184
|
+
|
|
185
|
+
### The schedule, and the route
|
|
186
|
+
|
|
187
|
+
`createRetentionRunner` (`src/cost/retention.ts`) owns the once-an-hour floor.
|
|
188
|
+
Three callers share it and none of them can bypass it:
|
|
189
|
+
|
|
190
|
+
- the server's one-minute housekeeping timer, which calls `maybeRun()`;
|
|
191
|
+
- a `setTimeout` five seconds after boot, so a lowered window applies without
|
|
192
|
+
waiting out an hour (the first call is always due);
|
|
193
|
+
- `POST /v1/router/prune`, which calls `runNow()` — always doing the work,
|
|
194
|
+
because the caller wants the counts, and marking the schedule satisfied for
|
|
195
|
+
the next hour, because a prune that just ran is a prune that just ran.
|
|
196
|
+
|
|
197
|
+
The window is read through a function rather than captured, so a hot reload or
|
|
198
|
+
an embedder's `reconfigure` changes it without restarting anything.
|
|
199
|
+
|
|
200
|
+
The route exists for one reason: a front door of the team edition holds a
|
|
201
|
+
**read-only** handle on the ledger file by design and must never delete from
|
|
202
|
+
it. It asks the router, and gets back:
|
|
203
|
+
|
|
204
|
+
```json
|
|
205
|
+
{ "deleted": 12043, "oldestKeptMs": 1782720000000, "retentionDays": 365 }
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
It is guarded by `server.apiKey` like every other route, and it is safe to call
|
|
209
|
+
in a loop: the second call inside the hour still returns the counts, having
|
|
210
|
+
found nothing left to delete.
|
package/package.json
CHANGED
package/src/cli/config-wizard.ts
CHANGED
|
@@ -362,7 +362,17 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
|
|
|
362
362
|
{ path: "ledger.fallbackBlend.inputPerMtok", label: "Fallback blend input $/Mtok", kind: "number", min: 0 },
|
|
363
363
|
{ path: "ledger.fallbackBlend.outputPerMtok", label: "Fallback blend output $/Mtok", kind: "number", min: 0 },
|
|
364
364
|
{ path: "ledger.conversationTtlMs", label: "Conversation TTL", kind: "number", min: 1, hint: "ms" },
|
|
365
|
-
{ path: "ledger.retentionDays", label: "Ledger retention", kind: "number", min: 0, hint: "days; 0 keeps everything" },
|
|
365
|
+
{ path: "ledger.retentionDays", label: "Ledger retention", kind: "number", min: 0, optional: true, hint: "days; 0 or cleared keeps everything" },
|
|
366
|
+
],
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
title: "Redaction",
|
|
370
|
+
fields: [
|
|
371
|
+
{ path: "redaction.enabled", label: "Remove configured strings from outgoing requests", kind: "boolean" },
|
|
372
|
+
{ path: "redaction.scanTools", label: "Also scan tool arguments and tool results", kind: "boolean" },
|
|
373
|
+
// `redaction.rules` is a list of name/pattern records, edited in the
|
|
374
|
+
// config file (or by a front door) rather than one prompt at a time —
|
|
375
|
+
// the same treatment `profiles` and `upstreams` get.
|
|
366
376
|
],
|
|
367
377
|
},
|
|
368
378
|
{
|
package/src/config/defaults.ts
CHANGED
|
@@ -385,8 +385,14 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
385
385
|
// so early cost reporting never underreports.
|
|
386
386
|
fallbackBlend: { inputPerMtok: 1.5, outputPerMtok: 7.5 },
|
|
387
387
|
conversationTtlMs: 7 * 24 * 60 * 60 * 1000,
|
|
388
|
-
|
|
388
|
+
// Keep everything until an operator says otherwise: how long a record of
|
|
389
|
+
// what people asked a model lives is their decision, and deleting is the
|
|
390
|
+
// direction that cannot be undone. `POST /v1/router/prune` and the hourly
|
|
391
|
+
// scheduler both do nothing while this is null.
|
|
392
|
+
retentionDays: null,
|
|
389
393
|
},
|
|
394
|
+
// Off, with no rules: redaction only ever removes what an operator names.
|
|
395
|
+
redaction: { enabled: false, rules: [], scanTools: false },
|
|
390
396
|
// On by default: an absolute floor that no available model meets is how the
|
|
391
397
|
// router ends up serving every turn from the cheapest tier.
|
|
392
398
|
adaptiveTierFloors: true,
|
package/src/config/hot-reload.ts
CHANGED
|
@@ -95,8 +95,9 @@ export interface WatchConfigOptions {
|
|
|
95
95
|
* Config paths captured at construction, so a file edit cannot reach the
|
|
96
96
|
* running process: the socket, the upstream clients, the agentdox bridge, the
|
|
97
97
|
* ledger file. Everything else, including `ollama.costBias`,
|
|
98
|
-
* `ollama.biasUntilUsage`, `server.subagentProfile
|
|
99
|
-
*
|
|
98
|
+
* `ollama.biasUntilUsage`, `server.subagentProfile`, `ledger.retentionDays` and
|
|
99
|
+
* the whole `redaction` block (rules recompile when their text changes), is
|
|
100
|
+
* read at call time and hot-reloads. A bare block name pins the whole
|
|
100
101
|
* block; `block.key` pins one key and lets its siblings through.
|
|
101
102
|
*/
|
|
102
103
|
/**
|