auto-model-router 0.19.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.
Files changed (39) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +207 -15
  3. package/docs/data-governance.md +210 -0
  4. package/package.json +1 -1
  5. package/src/cli/config-wizard.ts +11 -1
  6. package/src/cli/connect.ts +131 -2
  7. package/src/cli/harnesses.ts +277 -0
  8. package/src/config/defaults.ts +7 -1
  9. package/src/config/hot-reload.ts +3 -2
  10. package/src/config/redaction.ts +265 -0
  11. package/src/config/schema.ts +25 -1
  12. package/src/config/types.ts +52 -5
  13. package/src/cost/ledger.ts +39 -5
  14. package/src/cost/report.ts +19 -0
  15. package/src/cost/retention.ts +58 -0
  16. package/src/cost/types.ts +26 -2
  17. package/src/index.ts +1 -0
  18. package/src/lib.ts +8 -2
  19. package/src/server/http.ts +44 -14
  20. package/src/server/redact.ts +104 -0
  21. package/src/server/turn.ts +21 -0
  22. package/src/util/sqlite.ts +23 -1
  23. package/test/config-wizard.test.ts +1 -1
  24. package/test/connect-harnesses.test.ts +299 -0
  25. package/test/failover.test.ts +1 -0
  26. package/test/fixtures/connect/cline-providers.json +16 -0
  27. package/test/fixtures/connect/continue-config.yaml +14 -0
  28. package/test/fixtures/connect/opencode.json +15 -0
  29. package/test/fixtures/harness/cline-cli-connected.json +817 -0
  30. package/test/harness-requests.test.ts +13 -2
  31. package/test/migrations.test.ts +6 -3
  32. package/test/redaction.test.ts +300 -0
  33. package/test/report-hub.test.ts +2 -0
  34. package/test/report.test.ts +2 -0
  35. package/test/retention.test.ts +242 -0
  36. package/test/tokens.test.ts +3 -3
  37. package/test/trust-attribution.test.ts +2 -2
  38. package/test/turn.test.ts +107 -0
  39. package/tools/capture-proxy.ts +1 -1
@@ -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.19.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.19.0",
17
+ "version": "0.21.0",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -472,8 +472,17 @@ cline -P openai -m auto "your task"
472
472
  Verified live with cline 3.0 (captured request:
473
473
  `test/fixtures/harness/cline-cli.json`). The CLI sends native tool calls
474
474
  (`read_files`, `search_codebase`, `run_commands`, `fetch_web_content`, …),
475
- all in `digest.toolAliases`, and no custom headers, so its rows carry no
476
- harness id. No session id or hooks.
475
+ all in `digest.toolAliases`. No session id or hooks.
476
+
477
+ `auto-model-router connect` writes this itself, and writes one thing the
478
+ `auth` command cannot: Cline's provider store
479
+ (`~/.cline/data/settings/providers.json`) has a `settings.headers` map with no
480
+ CLI flag behind it, so a file written by hand is what finally gives Cline a
481
+ harness id. Verified live on cline 3.0.61 by pointing it at a recording
482
+ server — `test/fixtures/harness/cline-cli-connected.json` is that capture, with
483
+ `X-Omp-Harness: cline` on it. That one file also serves the **VS Code
484
+ extension**, which reads the same store since its settings migration, so the
485
+ extension needs no separate recipe (and neither does any editor that hosts it).
477
486
 
478
487
  ### Kilo Code CLI
479
488
 
@@ -534,10 +543,11 @@ id or hooks: the digest applies only through summarising compaction.
534
543
 
535
544
  ### Cline (VS Code)
536
545
 
537
- Choose the *OpenAI Compatible* provider in the extension's settings, set the
538
- base URL to `http://127.0.0.1:8788/v1`, any API key, and the model id `auto`
539
- (or `auto-cheap` / `auto-max`); add `X-Omp-Harness` under custom headers if
540
- offered. Not verified live here (the CLI above was); its tool names are in
546
+ Nothing extra: the extension reads the same provider store as the CLI
547
+ (`~/.cline/data/settings/providers.json`), so `connect` has already configured
548
+ it pick the *OpenAI Compatible* provider and the `auto` model. By hand, set
549
+ the base URL to `http://127.0.0.1:8788/v1`, any API key, and the model id
550
+ `auto` (or `auto-cheap` / `auto-max`). Its tool names are in
541
551
  `digest.toolAliases`, and the digest applies only through summarising
542
552
  compaction.
543
553
 
@@ -564,9 +574,16 @@ SSE frame, which is why the router's final summary frame is shaped as a
564
574
  chunk with no choices. Its tool names (`read`, `grep`, `glob`, `bash`,
565
575
  `webfetch`) match the router's canonical list.
566
576
 
577
+ `auto-model-router connect` writes both halves of this: the provider block
578
+ (other providers, MCP servers and `$schema` untouched) and the plugin below.
579
+ The config directory is `~/.config/opencode` on **every** platform, Windows
580
+ included — OpenCode uses the XDG layout there rather than `%APPDATA%`.
581
+
567
582
  **Native features (OpenCode plugin API).** Copy
568
583
  `opencode-plugin/auto-model-router.ts` to `~/.config/opencode/plugin/` (or a
569
- project's `.opencode/plugin/`); OpenCode loads it on start. It adds:
584
+ project's `.opencode/plugin/`); OpenCode loads it on start (`plugins/`, the
585
+ name current docs use, is loaded too — both were confirmed on opencode 1.18).
586
+ It adds:
570
587
 
571
588
  - **Session identity** — `X-Omp-Session`, `X-Omp-Harness` (`opencode`, or
572
589
  `OMP_HARNESS_ID`) and `X-Omp-Subagent` for sessions with a parent, through
@@ -581,6 +598,57 @@ No `/router` command (OpenCode commands are markdown files, not plugin
581
598
  hooks): use `auto-model-router report` on the terminal, or the router's
582
599
  HTTP endpoints.
583
600
 
601
+ ### Continue
602
+
603
+ ```yaml
604
+ # ~/.continue/config.yaml
605
+ name: auto-model-router
606
+ version: 0.0.1
607
+ schema: v1
608
+ models:
609
+ - name: auto-model-router
610
+ provider: openai
611
+ model: auto
612
+ apiBase: http://127.0.0.1:8788/v1
613
+ apiKey: local
614
+ roles: [chat, edit, apply, summarize]
615
+ capabilities: [tool_use, image_input]
616
+ requestOptions:
617
+ headers:
618
+ X-Omp-Harness: continue
619
+ ```
620
+
621
+ `connect` writes all three profiles as entries like this one, editing the YAML
622
+ *document* rather than re-serialising it, so a hand-written config keeps its
623
+ comments, key order and other models; entries are matched by `name`, so a later
624
+ connect replaces them in place. Pick `auto-model-router` in the model dropdown.
625
+ This is Continue's documented assistant schema rather than a shape captured
626
+ here — Continue was not run against the router — and `config.yaml` takes
627
+ precedence over the older `config.json` if you still have one.
628
+
629
+ ### Cursor
630
+
631
+ Cursor has no provider file to write: the OpenAI override is an application
632
+ setting in the editor's own state. `connect` prints the values instead —
633
+ Cursor Settings → Models → OpenAI API Key, enable the base-URL override, then
634
+ the base URL, the key and `auto` as a custom model.
635
+
636
+ Two limits worth knowing before you try. Cursor proxies chat through **its own
637
+ servers** with your key attached, so the base URL has to be reachable from the
638
+ internet — a `127.0.0.1` router or a LAN team edition will never be called. And
639
+ the override carries no custom header, so those turns arrive with no
640
+ `X-Omp-Harness` id and share the unnamed budget.
641
+
642
+ ### Windsurf
643
+
644
+ Windsurf has no custom base-URL field at all: its bring-your-own-key page takes
645
+ first-party provider keys, and the files under `~/.codeium/<channel>` are MCP
646
+ servers, rules and skills. So the route in is an extension. Windsurf is a VS
647
+ Code fork, and **Cline** reads the provider store `connect` already wrote
648
+ (`~/.cline/data/settings/providers.json`) — installing the extension is the
649
+ whole configuration. Continue works the same way against `~/.continue/config.yaml`.
650
+ `connect` says exactly this when it finds a Windsurf install.
651
+
584
652
  ### The OpenRouter key
585
653
 
586
654
  **omp does not need to be authenticated to OpenRouter.** On a routed turn omp
@@ -694,9 +762,10 @@ virtual profile it picked. Every routed response carries
694
762
 
695
763
  The ledger records every dispatch: model decided and served, tier, provider,
696
764
  tokens (including cached), reported cost, time to first token, total latency,
697
- escalation signal, error, and the agentdox context scope the turn carried (the
765
+ escalation signal, error, the agentdox context scope the turn carried (the
698
766
  project it belongs to; NULL for a turn that carried none, and for every row
699
- written before v0.19.0). Three views aggregate it, all from the same
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
700
769
  `buildUsageReport` in `src/cost/report.ts`:
701
770
 
702
771
  - `/router report` in omp — a fullscreen hub with the `/models` look: views
@@ -734,6 +803,8 @@ written before v0.19.0). Three views aggregate it, all from the same
734
803
  a comma-separated set of ids, for a group).
735
804
  - `GET /v1/router/summary?harness=<id>` — the daily summary as JSON (`auto=1`
736
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).
737
808
 
738
809
  What it shows, for the window:
739
810
 
@@ -1092,7 +1163,28 @@ task needed, and `digest.maxOutputTokens` or `digest.model` is the lever.
1092
1163
  | `blendMinSamples` | `25` | Turns before the measured blend replaces the fallback. |
1093
1164
  | `fallbackBlend` | input `1.5`, output `7.5` | Pre-measurement blend (USD/Mtok) for omp's cost display. |
1094
1165
  | `conversationTtlMs` | `604800000` (7 d) | Drop conversation state untouched this long. |
1095
- | `retentionDays` | `365` | Delete ledger rows older than this, checked hourly; `0` keeps everything. The ledger grows about 2.5 MB a day under steady use. Freed pages are reused, so the file stops growing rather than shrinking. |
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
+ ```
1096
1188
 
1097
1189
  ### Top-level
1098
1190
 
@@ -1304,6 +1396,92 @@ ignored rather than failing the turn. The decision trail records what the
1304
1396
  policy changed (`policy: …`), and `GET /v1/router/catalog?policy=…` shows what a
1305
1397
  policy admits, model by model, without routing a turn.
1306
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
+
1307
1485
  ## Using a remote router
1308
1486
 
1309
1487
  `auto-model-router connect --url <router> --key <key>` points this machine at a router
@@ -1313,9 +1491,21 @@ embed extension registers the remote router as omp's provider with that key inst
1313
1491
  binding a local one, and the toast, `/router` hub and digest extensions talk to it. Nothing
1314
1492
  is classified or selected locally; the remote router is the router. The same command adds
1315
1493
  the extensions to omp's config, installs the Hermes plugins and points them at the remote,
1316
- adds the Codex provider and the Aider settings, and prints (or with `--profile` persists)
1317
- the environment lines for Claude Code. `--harness omp,hermes` restricts it; `--dry-run`
1318
- shows the changes. Delete `remote.json` to go back to a local router. (`join` is an alias.)
1494
+ adds the Codex provider and the Aider settings, writes Claude Code's settings file, merges
1495
+ the provider into OpenCode's `opencode.json` (and copies its plugin), Cline's
1496
+ `providers.json` and Continue's `config.yaml`, and prints what to set by hand for Cursor
1497
+ and Windsurf. `--harness omp,hermes` restricts it (`omp`, `hermes`, `codex`, `aider`,
1498
+ `claude`, `opencode`, `cline`, `continue`, `cursor`, `windsurf`); `--dry-run` shows the
1499
+ changes. Delete `remote.json` to go back to a local router. (`join` is an alias.)
1500
+
1501
+ **What gets written and what gets printed.** A harness is configured only where its file
1502
+ format is documented and, wherever the harness could be run here, seen to be read: omp,
1503
+ Hermes, Codex, Aider, Claude Code, OpenCode, Cline and Continue. Cursor and Windsurf keep
1504
+ their provider settings in application state — an editor's own database, a vendor's
1505
+ account page — so `connect` reports them as `manual` and prints the base URL, key and
1506
+ model to paste. That is deliberate: a config key invented for them would write a file that
1507
+ silently does nothing while the run reported success. Every write is idempotent, backs the
1508
+ previous file up, and touches only its own keys.
1319
1509
 
1320
1510
  `connect` also writes omp's `models.yml` (a managed block, other providers untouched, the
1321
1511
  previous file backed up). That entry is what makes `auto-model-router/auto` resolvable at
@@ -1474,10 +1664,12 @@ plus a harness header; the rest needs the harness's own hook API.
1474
1664
  | Hermes | provider plugin | yes | yes (native plugin) | yes (native plugin) | no | text | yes (native plugin) | on demand | no |
1475
1665
  | Codex CLI | Responses API wire | yes | yes (from body) | yes (from body) | no | no | compaction only | no | no |
1476
1666
  | Aider | config only | via model settings | no | no | no | no | no tools | no | no |
1477
- | Cline CLI | config only | no | no | no | no | no | compaction only | no | no |
1667
+ | Cline (CLI + VS Code) | config only | yes | no | no | no | no | compaction only | no | no |
1478
1668
  | Kilo Code CLI | config only | yes | no | no | no | no | compaction only | no | no |
1479
1669
  | Roo Code (VS Code) | config only | yes | no | no | no | no | compaction only | no | no |
1480
- | Cline (VS Code) | config only, unverified | if headers supported | no | no | no | no | compaction only | no | no |
1670
+ | Continue | config only | yes | no | no | no | no | compaction only | no | no |
1671
+ | Cursor | manual (no config file; needs a public URL) | no | no | no | no | no | compaction only | no | no |
1672
+ | Windsurf | via the Cline or Continue extension | as that extension | no | no | no | no | compaction only | no | no |
1481
1673
  | OpenCode | config + plugin | yes | yes (plugin) | yes (plugin) | yes (plugin) | no | yes (plugin) | no | no |
1482
1674
  | Claude Code | needs an Anthropic Messages wire module | — | — | — | — | — | — | — | — |
1483
1675
 
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -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
  {