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.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +207 -15
- package/docs/data-governance.md +210 -0
- package/package.json +1 -1
- package/src/cli/config-wizard.ts +11 -1
- package/src/cli/connect.ts +131 -2
- package/src/cli/harnesses.ts +277 -0
- 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/index.ts +1 -0
- 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/connect-harnesses.test.ts +299 -0
- package/test/failover.test.ts +1 -0
- package/test/fixtures/connect/cline-providers.json +16 -0
- package/test/fixtures/connect/continue-config.yaml +14 -0
- package/test/fixtures/connect/opencode.json +15 -0
- package/test/fixtures/harness/cline-cli-connected.json +817 -0
- package/test/harness-requests.test.ts +13 -2
- package/test/migrations.test.ts +6 -3
- package/test/redaction.test.ts +300 -0
- package/test/report-hub.test.ts +2 -0
- package/test/report.test.ts +2 -0
- package/test/retention.test.ts +242 -0
- package/test/tokens.test.ts +3 -3
- package/test/trust-attribution.test.ts +2 -2
- package/test/turn.test.ts +107 -0
- package/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.
|
|
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
|
@@ -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
|
|
476
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
|
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,
|
|
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)
|
|
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` | `
|
|
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,
|
|
1317
|
-
the
|
|
1318
|
-
|
|
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 |
|
|
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
|
-
|
|
|
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
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
|
{
|