@alexeiled/claude-router 0.5.1 → 0.6.1

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "router",
3
3
  "displayName": "jev-router",
4
- "version": "0.5.1",
4
+ "version": "0.6.1",
5
5
  "description": "Auto-picks the right Claude model for each turn — small models for quick edits, mid-tier for code, frontier for hard problems. Uses Jev to classify each request.",
6
6
  "author": { "name": "Alexei Ledenev", "url": "https://github.com/alexei-led" },
7
7
  "repository": "https://github.com/alexei-led/claude-router",
package/README.md CHANGED
@@ -7,11 +7,17 @@
7
7
 
8
8
  A Claude Code plugin that auto-picks the right model and effort for each turn.
9
9
 
10
- A local gateway on `127.0.0.1` receives each request from Claude Code. For a
11
- new user turn, the gateway asks Jev (TypeSafe) which tier the turn needs. Then
12
- the gateway rewrites `model`, `effort` and `thinking` in the request and sends
13
- it to Anthropic. All other data goes through unchanged. The gateway has no
14
- runtime dependencies and needs Node 22 or later.
10
+ **Status: experimental.** I built this to dogfood Jev, TypeSafe's routing
11
+ model, inside Claude Code. It works for me; I don't know yet if it holds up
12
+ outside my setup. Try it and open an issue with what you find.
13
+
14
+ ## Why
15
+
16
+ One model for every turn is a compromise: strong enough for the hard turns
17
+ and it burns your limits on "rename this variable"; cheap enough for the easy
18
+ turns and it struggles on the hard ones. Switching models by hand works, but
19
+ it is friction you pay on every message. This plugin asks a small router
20
+ model which tier a turn needs and switches for you, automatically.
15
21
 
16
22
  ## How it works
17
23
 
@@ -26,19 +32,23 @@ only requests for `jev-router`: ├─ facts.mjs prompt, continuation,
26
32
  responses go through unchanged; the gateway reads `usage` (context size, cache TTL)
27
33
  ```
28
34
 
35
+ A local gateway on `127.0.0.1` receives each request from Claude Code. For a
36
+ new user turn, the gateway asks Jev which tier the turn needs, then rewrites
37
+ `model`, `effort` and `thinking` and sends the request to Anthropic. All other
38
+ data goes through unchanged. No runtime dependencies; Node 22 or later.
39
+
29
40
  The tiers are `micro` (Haiku), `low` (Sonnet, the baseline), `medium` (Opus at
30
41
  high effort) and `high` (Opus at xhigh effort). The exact model IDs are in
31
42
  `~/.claude/router.json` and default to the current generation of each family.
32
43
 
33
44
  A tool continuation keeps the route of its turn — the gateway does not ask Jev.
34
- Side requests, for example session titles, get the baseline tier. A request for
35
- any other model goes through unchanged. This is how `/router:<tier>` pins and
45
+ Side requests, for example session titles, get the baseline tier. A subagent
46
+ that inherits the model gets routing with its own memory. A request for any
47
+ other model goes through unchanged; this is how `/router:<tier>` pins and
36
48
  subagents with their own `model` work.
37
49
 
38
- Module dependencies point in one direction: `gateway.mjs` (HTTP) →
39
- `router.mjs` (orchestration) `facts`, `jev`, `policy` → `cost`, `rewrite`,
40
- `store`. The modules `facts`, `cost`, `rewrite`, `sse` and `policy` are pure.
41
- Only `store` writes files. The Jev transport is injected.
50
+ See [Architecture](docs/architecture.md) for the module map, the switching
51
+ policy, and a real-usage evaluation.
42
52
 
43
53
  ## Install
44
54
 
@@ -78,14 +88,11 @@ plugin never replaces a newer gateway. The Jev API key stays in the macOS
78
88
  Keychain. Run `/router:setup` once after an update: the status line command
79
89
  path contains the plugin version.
80
90
 
81
- Updating from 0.3.0 or earlier: these gateways cannot hand over, so stop the
82
- old one once with `pkill -f scripts/gateway.mjs`.
83
-
84
91
  ## Documentation
85
92
 
86
93
  - [User guide](docs/user-guide.md): daily use, pins, decision log, troubleshooting.
87
94
  - [Configuration](docs/configuration.md): each key, and where the API key and the configuration file are.
88
- - [Design](docs/design.md): decisions, the switching policy, test results.
95
+ - [Architecture](docs/architecture.md): how the gateway works, the switching policy, a real-usage evaluation.
89
96
 
90
97
  ## Develop
91
98
 
@@ -101,4 +108,4 @@ claude --plugin-dir . --model jev-router # with ANTHROPIC_BASE_URL and TYPESAF
101
108
  Releases: push a signed tag `v<version>` that matches `package.json`. The
102
109
  release workflow publishes `@alexeiled/claude-router` to npm with trusted
103
110
  publishing and creates the GitHub release. See
104
- [docs/design.md](docs/design.md#release).
111
+ [docs/architecture.md](docs/architecture.md#release).
@@ -1,4 +1,4 @@
1
- # Design record
1
+ # Architecture
2
2
 
3
3
  Each decision has the date when the owner took it.
4
4
 
@@ -21,12 +21,17 @@ get the context size, the cache reads and the cache TTL.
21
21
  For a model without thinking (Haiku), the gateway also removes the
22
22
  `clear_thinking_*` edits from `context_management`, because the API rejects
23
23
  them without thinking. The gateway never changes `system`, `tools` or
24
- `messages`. Thus preserved
25
- thinking and prompt caching work as if Claude Code talked to Anthropic. Claude
26
- Code documents this gateway mode, including the OAuth value for a claude.ai
27
- login. See [llm-gateway](https://code.claude.com/docs/en/llm-gateway) and
24
+ `messages`. Thinking and prompt caching work as if Claude Code talked to
25
+ Anthropic directly. Claude Code documents this gateway mode, including the
26
+ OAuth value for a claude.ai login. See
27
+ [llm-gateway](https://code.claude.com/docs/en/llm-gateway) and
28
28
  [protocol](https://code.claude.com/docs/en/llm-gateway-protocol).
29
29
 
30
+ Module dependencies point in one direction: `gateway.mjs` (HTTP) →
31
+ `router.mjs` (orchestration) → `facts`, `jev`, `policy` → `cost`, `rewrite`,
32
+ `store`. The modules `facts`, `cost`, `rewrite`, `sse` and `policy` are pure.
33
+ Only `store` writes files. The Jev transport is injected.
34
+
30
35
  The `SessionStart` hook of the plugin starts the gateway when the port does not
31
36
  answer. `/router:setup` writes `model`, `ANTHROPIC_BASE_URL` and the picker row to
32
37
  the user settings once. A plugin cannot set them by itself.
@@ -38,22 +43,14 @@ is the only place where the real model of the turn is visible.
38
43
 
39
44
  ### Why not the native skill path
40
45
 
41
- The first design used a `UserPromptSubmit` hook. The hook asked Claude to call
42
- a tier skill. The frontmatter `model:` and `effort:` of the skill were to serve
43
- the rest of the turn.
44
-
45
- The test on Claude Code 2.1.278 gave this result. A skill that the user types
46
- (`/router:medium …`) changes the model for the turn. The same skill,
47
- called by Claude through the Skill tool, does not change the model. The
48
- transcript records `attributionSkill`, but the session model answers. The test
49
- ran in three sessions, in auto mode and in `acceptEdits` mode, with Opus and
50
- Fable targets. The documentation says "when this skill is active". The
51
- behavior is user invocation only. A feedback report is filed. The skills stay
52
- as manual pins.
53
-
54
- This result also removed the "Sonnet session model" argument from the peer
55
- review. The gateway has no bootstrap request and no cache drop for a turn that
56
- keeps its route. The baseline is a configuration value.
46
+ The first design used a `UserPromptSubmit` hook that asked Claude to call a
47
+ tier skill, relying on the skill's frontmatter `model:` and `effort:` to serve
48
+ the rest of the turn. On Claude Code 2.1.278 that only works for a skill the
49
+ user types (`/router:medium …`); the same skill called by Claude through the
50
+ Skill tool does not change the model the transcript records
51
+ `attributionSkill`, but the session model answers. So `/router:<tier>` skills
52
+ stay as manual pins, and the gateway is the only path that routes turns Claude
53
+ calls on its own.
57
54
 
58
55
  ## Tiers
59
56
 
@@ -64,19 +61,20 @@ keeps its route. The baseline is a configuration value.
64
61
  | low | sonnet | as sent | claude-sonnet-5 |
65
62
  | micro | haiku | none | claude-haiku-4-5 |
66
63
 
67
- The gateway lowers the effort to a level that the model family accepts. Sonnet
68
- 4.6 has no `xhigh`. Haiku gets no effort and no adaptive thinking. The ids are
69
- configuration. `sonnet` is 4.6 because the alias resolved to 4.6 on the test
70
- account.
64
+ The gateway lowers the effort to a level the model's `efforts` list accepts
65
+ (see [configuration](configuration.md#models)); Haiku accepts none, so it gets
66
+ no effort and no adaptive thinking. The ids are configuration.
71
67
 
72
68
  ## Request classes
73
69
 
74
70
  - New user turn (the last message has no `tool_result`): Jev and the policy.
75
71
  - Tool continuation: the route of the turn, without a Jev call.
76
- - Every request class other than `main`, from the header
72
+ - The classes `auxiliary` and `compaction` from the header
77
73
  `x-claude-code-request-class`: `auxiliaryTier`, and the memory stays
78
- unchanged. Without the header, a body with `thinking: disabled` and a
79
- `format` is a side request.
74
+ unchanged. `main`, `subagent` and `workflow` get routing. Without the header,
75
+ a body with `thinking: disabled` and a `format` is a side request.
76
+ - A subagent (header `x-claude-code-agent-id`): routing with its own memory,
77
+ under the key `<session>.<agent id>`. The main conversation keeps its route.
80
78
  - Any other `model`: unchanged. This covers `/router:<tier>` pins,
81
79
  `/model` changes and subagents with their own model.
82
80
  - A resent request (the same history length and the same last message): the
@@ -84,6 +82,10 @@ account.
84
82
  429, a 529 or a dropped connection.
85
83
  - A side endpoint with the alias, such as `/v1/messages/count_tokens`: the
86
84
  model of the session's last route. Only `POST /v1/messages` is a turn.
85
+ - A history break: a main request with fewer messages than the last one (a
86
+ compaction or a rewind), or the header `x-claude-code-context-compacted`.
87
+ The gateway drops the cached prefixes, the votes and the escalation hold,
88
+ then routes the request as usual. The route stays until the next decision.
87
89
 
88
90
  ## Failure handling
89
91
 
@@ -101,7 +103,7 @@ request must not reach the others.
101
103
  logged. The routing decision stands.
102
104
  - Jev: one retry on a network error or a transient status, after
103
105
  `Retry-After` when it fits the 1.5 s budget. After three failures in a row,
104
- new turns skip Jev for a minute, then try once.
106
+ new turns skip Jev for a minute, then try once per pause.
105
107
  - The daemon logs a stray exception instead of exiting. On `SIGTERM` it
106
108
  releases the port at once and finishes open streams for up to 10 minutes.
107
109
  A second daemon on a busy port exits quietly.
@@ -125,18 +127,20 @@ request must not reach the others.
125
127
  All inputs come from the traffic of the gateway. The gateway does not read
126
128
  transcripts.
127
129
 
128
- | Input | Source |
129
- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
130
- | Context of the last request, cache reads, output | `usage` in the response (`message_start` and `message_delta`) |
131
- | Granted TTL | `usage.cache_creation.ephemeral_1h_input_tokens` or the `5m` field |
132
- | Cache warmth of a model | The time of the last response of that model, plus the TTL, minus 30 s |
133
- | Reusable prefix of a model | The context plus the output at the last response of that model. Cleared when the context shrinks by more than 20% (compaction). |
134
- | Failure signal | Two `tool_result` blocks with `is_error` and the same signature, with an edit tool call between them |
135
- | Continuation | The last message contains a `tool_result`. For a new prompt, a Jev Noul answers "does this prompt continue the task". |
136
-
137
- Prices are a list-price table in the configuration. `modelPricing` is a
138
- managed setting and is not readable. The switching tax for a candidate `c`
139
- against the current route `i` is:
130
+ | Input | Source |
131
+ | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
132
+ | Context of the last request, cache reads, output | `usage` in the response (`message_start` and `message_delta`) |
133
+ | Granted TTL | `usage.cache_creation.ephemeral_1h_input_tokens` or the `5m` field |
134
+ | Cache identity | The model id and the effort the gateway sent (`claude-opus-5-5@xhigh`). An effort change rewrites the messages cache, so each effort is its own cache. |
135
+ | Cache warmth | The time of the last response for that cache, plus the TTL, minus 30 s. `unknown` when no response since the session started or the history broke. |
136
+ | Reusable prefix | The context plus the output at the last response for that cache. Cleared by a history break, or when the context shrinks by more than 20% (context editing). |
137
+ | Failure signal | Two `tool_result` blocks with `is_error` and the same signature, with an edit tool call between them |
138
+ | Continuation | The last message contains a `tool_result`. For a new prompt, a Jev Noul answers "does this prompt continue the task". |
139
+
140
+ Prices are a list-price table in the configuration (see
141
+ [configuration](configuration.md#models)). `modelPricing` is a managed setting
142
+ and is not readable. The switching tax for a candidate `c` against the current
143
+ route `i` is:
140
144
 
141
145
  ```
142
146
  input_cost(m) = P_read(m) * W_m + P_write(m) * (N - W_m)
@@ -146,11 +150,13 @@ tax = max(0, input_cost(c) - input_cost(i))
146
150
  The subscription economics are not symmetric. Every default model uses the plan
147
151
  limits, and dollars give the order between them. A model with `billing:
148
152
  "credits"` bills cash, on the 5m TTL, and behind the gateway without the
149
- consent prompt of Claude Code. `policy.cashCapUsd` ($2 by default) caps the
150
- cold cache write that the gateway will pay for an automatic route to such a
151
- model. A Claude Code turn starts at about 100k tokens (system prompt and 159
152
- tool definitions), so the gate binds on the first switch, not later. No default
153
- model bills credits; the gate stays for configurations that add one.
153
+ consent prompt of Claude Code. `policy.cashCapUsd` is a cold-write guard: it
154
+ caps the estimated first cache write of an automatic route to such a model
155
+ when its cache is not warm. It is not a budget: a warm cache passes, and
156
+ output is not counted. A Claude Code turn starts at about 100k tokens (system
157
+ prompt and tool definitions), so the guard binds on the first switch, not
158
+ later. No default model bills credits; the guard stays for configurations that
159
+ add one.
154
160
 
155
161
  ## Switching policy v0
156
162
 
@@ -169,8 +175,9 @@ Agreed with Codex on 2026-09-22. The thresholds are start values.
169
175
  `U >= 0.75 + 0.15 * tax / (tax + 0.5)`. A jump of two tiers with
170
176
  `U >= 0.95` skips the delay. A downgrade needs `D >= 0.90` and two
171
177
  consecutive votes.
172
- 5. Cash gate. An automatic route to a `credits` model needs a warm cache, or a
173
- cold write below the cap. Otherwise the strongest `plan` tier serves.
178
+ 5. Cold-write guard (reason `cash-gate`). An automatic route to a `credits`
179
+ model needs a warm cache, or a cold write below the cap. Otherwise the
180
+ strongest `plan` tier serves.
174
181
  6. No cooldown on upgrades. Plan, then execute, then hard again is sometimes
175
182
  the correct routing. The log separates reversals from real changes in the
176
183
  required capability.
@@ -178,19 +185,64 @@ Agreed with Codex on 2026-09-22. The thresholds are start values.
178
185
  and cache reads of each routed response. The memory changes only from
179
186
  responses that the gateway sent.
180
187
 
181
- ## Test results (2026-09-22, Max plan, Claude Code 2.1.278)
182
-
183
- - Easy prompt: Jev gave `micro` at confidence 1. It was the first vote, so
184
- `low` served (`claude-sonnet-5`, 103,799 context tokens on the first
185
- request).
186
- - Hard prompt: Jev gave `high` at 0.66. A cold Fable write cost $1.30 against
187
- the cap of $0.50 at that time, so `medium` served (`claude-opus-5`). Four
188
- tool continuations kept the route, with cache reads within 1% of the
189
- context.
190
- - Claude Code accepted `message_start.model` with the real model. The status
191
- line showed the alias at all times.
192
- - Cold start: the `SessionStart` hook started the gateway before the first
193
- request.
188
+ ## Cache identity, history breaks and shadow economics (2026-09-23)
189
+
190
+ From a design review with the architect of pi-model-router, the Pi router
191
+ that uses the same Jev tiers.
192
+
193
+ - Cache identity is the model and the effort. A top-level effort change
194
+ invalidates the messages cache; the cache-preserving per-message effort is
195
+ not available on Opus 5.5. Before 0.6.1 the key was the model alone, so
196
+ `medium` ↔ `high` (Opus at `high` and `xhigh`) looked like a free switch
197
+ between warm caches.
198
+ - A history break (fewer messages, or the compaction header) drops the cached
199
+ prefixes, the votes and the escalation hold: they were about turns that are
200
+ gone. Context editing shrinks the context but keeps the messages; it drops
201
+ only the prefixes. The gateway has no branch id, so it does not restore state
202
+ from before a rewind; `unknown` is the honest cache state after one.
203
+ - `/router:status` and the status line show the reason; the report adds the
204
+ estimate. The dollars are list prices, a list-price equivalent for plan
205
+ models.
206
+ - Shadow economics in `decisions.jsonl`, not read by the policy: for a turn
207
+ where Jev's choice differs from the current route, the extra cost of the
208
+ next turn, the difference for each later turn (input and output), and the
209
+ turns until a cheaper route repays its cache write. The owner's first
210
+ request (2026-09-22) was to stay while the model works on a warm cache and
211
+ to step down once the thinking is done. Rule 2 covers the first half; the
212
+ shadow estimate measures the second before any rule acts on it.
213
+ - The default prices match `test/fixtures/list-prices.json`, which names its
214
+ source and date. A test pins how a tenfold cache-read error changes one
215
+ decision: the bar moves within `upgradeBase` and `upgradeBase +
216
+ upgradeSlope`, and a confident jump ignores it. That error happened once
217
+ (a838f7c).
218
+
219
+ Not taken: the agent type as a routing signal; a payback check on downgrades
220
+ before shadow data; removing the switching tax from the upgrade bar before a
221
+ replay of the logs. The Pi router rejects the tax-to-confidence formula
222
+ because it mixes dollars with an uncalibrated probability; the replay decides.
223
+
224
+ ## Real-world evaluation (2026-09-23)
225
+
226
+ A day of dogfooding this repository on the installed plugin: 31 Claude Code
227
+ sessions, 1,578 routed requests, one machine. `decisions.jsonl` holds the
228
+ tier, the reason and the token counts for every request — no prompt text.
229
+
230
+ ![Share of requests by tier, and the input-token cost of the same traffic repriced at Opus's rates](tier-share.svg)
231
+
232
+ 82% of turns never needed more than Sonnet, 10% stayed on Haiku, and 8% needed
233
+ Opus. `medium` (Opus at `high` effort) fired once: a confident vote jumps two
234
+ tiers straight to `high` instead of stopping at `medium` (switching policy,
235
+ rule 4).
236
+
237
+ Repricing that same traffic — same tokens, same observed cache reads — at
238
+ Opus's rates puts the input-token bill 18.8% above what the router actually
239
+ spent. That number covers input tokens only: the price table has no output
240
+ price (see [Cache and cost inputs](#cache-and-cost-inputs)), so it cannot say
241
+ how much of the real saving is left out — likely more, since Haiku and Sonnet
242
+ also bill less per output token than Opus. Answer quality isn't measured
243
+ here either.
244
+
245
+ One developer, one day: a dogfood snapshot, not a benchmark.
194
246
 
195
247
  ## Layout
196
248
 
@@ -207,7 +259,7 @@ Agreed with Codex on 2026-09-22. The thresholds are start values.
207
259
  lib/config.mjs defaults, user file, validation
208
260
  lib/facts.mjs request body and memory -> facts (pure)
209
261
  lib/jev.mjs request, injected transport, parse
210
- lib/cost.mjs warmth, input cost, switching tax
262
+ lib/cost.mjs cache key, warmth, input cost, switching tax, shadow economics
211
263
  lib/policy.mjs switching policy v0
212
264
  lib/rewrite.mjs model, effort, thinking per model family
213
265
  lib/sse.mjs usage reader for SSE and JSON bodies
@@ -229,8 +281,17 @@ Agreed with Codex on 2026-09-22. The thresholds are start values.
229
281
  `decisions.jsonl` are the input for the tuning.
230
282
  - The gateway reads the configuration once. A reload without a restart is not
231
283
  implemented.
232
- - Without `CLAUDE_CODE_GATEWAY_HINT_HEADERS=1`, subagents with `model: inherit`
233
- get routing like the main conversation.
284
+ - Without `CLAUDE_CODE_GATEWAY_HINT_HEADERS=1`, the gateway guesses side
285
+ requests from the body; the guesses miss some. A missed side request with
286
+ a short history also counts as a history break.
287
+ - `x-claude-code-agent-type` is logged, not used: no policy rule reads it yet.
288
+ - Does a downgrade that the shadow estimate says never repays deserve a rule,
289
+ and does the switching tax belong in the upgrade bar? A replay of
290
+ `decisions.jsonl` against the `shadow` and `observed` lines decides.
291
+ - Effort changes how much a model writes. The shadow estimate uses the last
292
+ output size for both routes.
293
+ - Whether tools and system survive an effort change is model-specific; the
294
+ gateway counts the whole prefix as lost, an upper bound.
234
295
 
235
296
  ## Release
236
297
 
@@ -23,7 +23,8 @@ starts it again.
23
23
  "model": "jev-router[1m]",
24
24
  "env": {
25
25
  "ANTHROPIC_BASE_URL": "http://127.0.0.1:43170",
26
- "ENABLE_TOOL_SEARCH": "true"
26
+ "ENABLE_TOOL_SEARCH": "true",
27
+ "CLAUDE_CODE_GATEWAY_HINT_HEADERS": "1"
27
28
  },
28
29
  "modelPicker": {
29
30
  "options": [
@@ -76,18 +77,31 @@ If you agree, it also wraps the status line command:
76
77
  ```
77
78
 
78
79
  The wrapper runs the command after it, then adds one line for a routed
79
- session, for example `jev-router ▸ opus-5-5 · xhigh`. Without a command
80
+ session, for example `jev-router ▸ opus-5-5 · xhigh · upgrade`. Without a command
80
81
  after it, it prints only that line. The path contains the plugin version, so
81
82
  run `/router:setup` again after a plugin update.
82
83
 
83
- One optional key in `env`:
84
+ `CLAUDE_CODE_GATEWAY_HINT_HEADERS: "1"` makes Claude Code send the gateway
85
+ hint headers:
84
86
 
85
- - `CLAUDE_CODE_GATEWAY_HINT_HEADERS: "1"`. Claude Code then tells the gateway
86
- the class of each request. Requests of the class `main` get routing. All
87
- other classes (`auxiliary`, `subagent`, `workflow`, `compaction`) get
88
- `gateway.auxiliaryTier`. A subagent with `model: inherit` runs on that tier.
89
- Without the header, the gateway identifies side requests by their shape, and
90
- subagents get routing like the main conversation.
87
+ - `x-claude-code-request-class`. The classes `main`, `subagent` and `workflow`
88
+ get routing. `auxiliary` and `compaction` are side requests and get
89
+ `gateway.auxiliaryTier`.
90
+ - `x-claude-code-context-compacted` on the first request after a compaction.
91
+ The gateway then drops the cached prefixes of every model, the pending
92
+ votes and the escalation hold.
93
+ - `x-claude-code-agent-type`, for example `Explore` or `Plan`. It goes to
94
+ `decisions.jsonl` only.
95
+
96
+ Without the headers, the gateway identifies side requests by their shape. A
97
+ main request with fewer messages than the last one is a compaction or a
98
+ rewind, with or without the headers: the gateway drops the same state. A
99
+ context that shrank by more than 20% with no fewer messages is context
100
+ editing; it drops only the cached prefixes.
101
+
102
+ A subagent with `model: inherit` sends `x-claude-code-agent-id` even without
103
+ the hint headers. Each subagent keeps its own routing memory, so its turns do
104
+ not change the route of the main conversation.
91
105
 
92
106
  ## Configuration file
93
107
 
@@ -113,6 +127,7 @@ path. Nested objects merge.
113
127
  "sonnet": {
114
128
  "id": "claude-sonnet-5",
115
129
  "input": 2,
130
+ "output": 10,
116
131
  "cacheRead": 0.2,
117
132
  "contextWindow": 1000000,
118
133
  "billing": "plan",
@@ -146,14 +161,18 @@ frontmatter of `skills/<tier>/SKILL.md`. A test makes sure that they agree.
146
161
 
147
162
  ### models
148
163
 
149
- | Alias | ID | Input | Cache Read | Window | Max output | Billing | Efforts |
150
- | -------- | ------------------ | ----- | ---------- | ------ | ---------- | ------- | ------------- |
151
- | `opus` | `claude-opus-5-5` | $4 | $0.2 | 1M | as sent | plan | all |
152
- | `sonnet` | `claude-sonnet-5` | $2 | $0.2 | 1M | as sent | plan | low–xhigh–max |
153
- | `haiku` | `claude-haiku-4-5` | $1 | $0.1 | 200k | 64k | plan | none |
154
-
155
- `id` is the model id that the gateway sends to Anthropic. `input` and
156
- `cacheRead` are list prices in USD per million tokens. `contextWindow` is the
164
+ | Alias | ID | Input | Output | Cache Read | Window | Max output | Billing | Efforts |
165
+ | -------- | ------------------ | ----- | ------ | ---------- | ------ | ---------- | ------- | ------------- |
166
+ | `opus` | `claude-opus-5-5` | $4 | $20 | $0.2 | 1M | as sent | plan | all |
167
+ | `sonnet` | `claude-sonnet-5` | $2 | $10 | $0.2 | 1M | as sent | plan | low–xhigh–max |
168
+ | `haiku` | `claude-haiku-4-5` | $1 | $5 | $0.1 | 200k | 64k | plan | none |
169
+
170
+ `id` is the model id that the gateway sends to Anthropic. `input`, `output`
171
+ and `cacheRead` are list prices in USD per million tokens. `output` is
172
+ optional and feeds only the `shadow` estimate in `decisions.jsonl`; the policy
173
+ does not read it. The defaults match `test/fixtures/list-prices.json`, which
174
+ names its source and the date it was checked; a test fails when the two
175
+ differ. `contextWindow` is the
157
176
  size of the context window in tokens. `maxOutput`, when set, caps the
158
177
  `max_tokens` that Claude Code sends; the API rejects a request above the
159
178
  model's output limit. `billing` is `plan` for models that use
@@ -170,12 +189,12 @@ effort and thinking from the request.
170
189
  | `gateway.auxiliaryTier` | The tier for side requests, for example session titles. |
171
190
  | `gateway.idleShutdownMs` | The gateway exits after this long without requests, when no turn waits for a tool result. Two hours by default; `0` keeps it running. |
172
191
  | `upgradeVotes` | The number of consecutive votes above the current tier before an upgrade of one tier. |
173
- | `upgradeBase`, `upgradeSlope`, `upgradePivotUsd` | The required probability mass: `base + slope * tax / (tax + pivot)`. The `tax` is the extra input cost to read the context on the new model. |
192
+ | `upgradeBase`, `upgradeSlope`, `upgradePivotUsd` | The required probability mass: `base + slope * tax / (tax + pivot)`. The `tax` is the extra input cost to read the context on the new route; the cache is per model and effort. |
174
193
  | `jumpConfidence` | The mass that lets a jump of two tiers skip the vote delay. |
175
194
  | `downgradeVotes`, `downgradeMass` | The number of consecutive votes, and the mass at or below the candidate, for a downgrade. |
176
195
  | `continuationMass` | The Jev probability for "this prompt continues the task" that keeps the current route. |
177
196
  | `escalationHoldTurns` | The number of turns to hold one tier up after two failed repairs of the same error. |
178
- | `cashCapUsd` | The cold cache-write cost above which the gateway refuses an automatic route to a `credits` model. |
197
+ | `cashCapUsd` | The cold-write guard: the estimated first cache write above which the gateway refuses an automatic route to a `credits` model whose cache is not warm. Not a budget for the turn: output is not counted. |
179
198
 
180
199
  ## Environment variables
181
200
 
@@ -0,0 +1,44 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 500" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">
2
+ <rect width="720" height="500" fill="#fcfcfb"/>
3
+ <text x="24" y="34" font-size="18" font-weight="700" fill="#0b0b0b">Where the router sent the calls</text>
4
+ <text x="24" y="56" font-size="13" fill="#898781">one developer, 31 sessions, one day of dogfooding (2026-09-23) — 1578 routed requests</text>
5
+
6
+ <text x="24" y="74" font-size="12" font-weight="600" fill="#52514e">SHARE OF REQUESTS BY TIER</text>
7
+
8
+ <text x="196" y="111" text-anchor="end" font-size="13" fill="#52514e">micro · Haiku</text>
9
+ <rect x="210" y="96" width="50.07299270072993" height="22" rx="4" fill="#86b6ef"/>
10
+ <text x="268.0729927007299" y="111"
11
+ text-anchor="start" font-size="13" font-weight="600"
12
+ fill="#0b0b0b">9.8%</text>
13
+
14
+ <text x="196" y="157" text-anchor="end" font-size="13" fill="#52514e">low · Sonnet</text>
15
+ <rect x="210" y="142" width="420" height="22" rx="4" fill="#3987e5"/>
16
+ <text x="620" y="157"
17
+ text-anchor="end" font-size="13" font-weight="600"
18
+ fill="#ffffff">82.2%</text>
19
+
20
+ <text x="196" y="203" text-anchor="end" font-size="13" fill="#52514e">medium · Opus (high)</text>
21
+ <rect x="210" y="188" width="3" height="22" rx="4" fill="#1c5cab"/>
22
+ <text x="221" y="203"
23
+ text-anchor="start" font-size="13" font-weight="600"
24
+ fill="#0b0b0b">0.1%</text>
25
+
26
+ <text x="196" y="249" text-anchor="end" font-size="13" fill="#52514e">high · Opus (xhigh)</text>
27
+ <rect x="210" y="234" width="40.87591240875912" height="22" rx="4" fill="#0d366b"/>
28
+ <text x="258.8759124087591" y="249"
29
+ text-anchor="start" font-size="13" font-weight="600"
30
+ fill="#0b0b0b">8%</text>
31
+
32
+ <line x1="24" y1="300" x2="696" y2="300" stroke="#e1e0d9" stroke-width="1"/>
33
+ <text x="24" y="320" font-size="12" font-weight="600" fill="#52514e">INPUT-TOKEN COST, LIST PRICE — SAME TRAFFIC, REPRICED AT OPUS RATES</text>
34
+
35
+ <text x="196" y="355" text-anchor="end" font-size="13" fill="#52514e">router (actual)</text>
36
+ <rect x="210" y="340" width="341.043219076006" height="22" rx="4" fill="#2a78d6"/>
37
+ <text x="559.0432190760059" y="355" font-size="13" font-weight="600" fill="#0b0b0b">$114.42</text>
38
+
39
+ <text x="196" y="401" text-anchor="end" font-size="13" fill="#52514e">same traffic, Opus prices</text>
40
+ <rect x="210" y="386" width="420" height="22" rx="4" fill="#c3c2b7"/>
41
+ <text x="638" y="401" font-size="13" font-weight="600" fill="#0b0b0b">$140.91</text>
42
+ <text x="24" y="448" font-size="12" fill="#898781">−18.8% on input tokens alone. Output-token price isn't in the model, so real savings are likely larger, not smaller.</text>
43
+ <text x="24" y="468" font-size="12" fill="#898781">Not measured: answer quality. One machine, one day — a dogfood snapshot, not a benchmark.</text>
44
+ </svg>
@@ -29,13 +29,20 @@ ANTHROPIC_BASE_URL=http://127.0.0.1:43170 claude --plugin-dir . --model jev-rout
29
29
  route stays there for two turns. A repair is an edit between the two errors.
30
30
  - An upgrade needs two consecutive votes for a higher tier. A jump of two
31
31
  tiers with high confidence happens at once. The required confidence goes up
32
- with the cost to read the context again on the new model.
32
+ with the cost to read the context again on the new model. Opus at `high` and
33
+ Opus at `xhigh` are two caches: a change of effort rewrites the cached
34
+ conversation, so `medium` to `high` is not free.
33
35
  - A downgrade needs two consecutive confident votes.
34
- - A cold switch to a model that bills usage credits is refused when the cache
35
- write costs more than `policy.cashCapUsd`. Then the strongest plan tier
36
- serves the turn. Behind the gateway, Claude Code does not show its consent
37
- prompt for these credits, so the cap is the only guard. No default model
38
- bills credits; this applies once you add one in `router.json`.
36
+ - When the conversation gets shorter, after a compaction or a rewind, the
37
+ gateway forgets the cached prefixes, the pending votes and the escalation
38
+ hold. They were about turns that are no longer in the conversation.
39
+ - The cold-write guard: an automatic switch to a model that bills usage
40
+ credits is refused when that model's cache is not warm and the first cache
41
+ write would cost more than `policy.cashCapUsd`. Then the strongest plan tier
42
+ serves the turn. The guard limits that one estimated write, not the cost of
43
+ the turn: output is not counted. Behind the gateway, Claude Code does not
44
+ show its consent prompt for these credits. No default model bills credits;
45
+ this applies once you add one in `router.json`.
39
46
  - When Jev fails or times out, or when there is no key, the baseline tier
40
47
  serves the turn. After three failures in a row, new turns skip Jev for one
41
48
  minute and then try it once, so an outage costs one slow turn a minute, not
@@ -64,12 +71,19 @@ The gateway sends the real model id unchanged.
64
71
  ## See the current route
65
72
 
66
73
  `/router:status` shows the gateway, the routes, and the model, effort and
67
- reason of the last turn in this session.
74
+ reason of the last turn in this session. It also says why in words, and for
75
+ a vote it shows the estimate: the probability mass, the bar it had to reach,
76
+ the switching tax and whether each cache was `warm`, `expired` or `unknown`.
77
+ `unknown` means no response for that cache since the session started or the
78
+ conversation got shorter. The dollars are list prices. For plan models they
79
+ are a list-price equivalent, not a charge.
68
80
 
69
81
  The status line wrapper from `/router:setup` adds one line to your status line
70
82
  while the session uses the router:
71
83
 
72
- - `jev-router ▸ opus-5-5 · xhigh`: the model and the effort of the last turn.
84
+ - `jev-router ▸ opus-5-5 · xhigh · upgrade`: the model, the effort and the
85
+ reason of the last turn. `upgrade-pending` means that Jev asked for a higher
86
+ tier and the policy held the route back.
73
87
  - `router: no turn yet`: the session has no routed turn.
74
88
  - `router: gateway off, the next prompt starts it`: the gateway stopped after
75
89
  idle time, or it crashed. Either way the next prompt starts it.
@@ -84,7 +98,7 @@ the plugin data directory. The directory is
84
98
  by hand, the directory is `$TMPDIR/router/`.
85
99
 
86
100
  ```sh
87
- tail -n 20 ~/.claude/plugins/data/router-*/decisions.jsonl | jq -c '{tier, reason, estimate, observed}'
101
+ tail -n 20 ~/.claude/plugins/data/router-*/decisions.jsonl | jq -c '{tier, reason, estimate, shadow, observed}'
88
102
  ```
89
103
 
90
104
  `tier` is the selected tier. `reason` is the rule that decided. The `observed`
@@ -92,6 +106,12 @@ lines carry the model that answered, the context tokens and the cache reads
92
106
  from the response. Compare the estimated and the observed cache reads to tune
93
107
  the thresholds.
94
108
 
109
+ `shadow` is what following Jev's choice instead of the current route would
110
+ cost, at list prices: the next turn, each later turn, and the number of turns
111
+ until a cheaper route repays its cache write. The policy does not read it.
112
+ `historyBreak` lines mark a compaction or a rewind; `cacheReset` lines mark a
113
+ context that shrank by more than a fifth.
114
+
95
115
  `scripts/transcript-models.sh <transcript.jsonl>` shows the model for each
96
116
  assistant message in a Claude Code transcript.
97
117
 
@@ -115,3 +135,6 @@ assistant message in a Claude Code transcript.
115
135
  - Rate limits and overload errors from Anthropic (429, 529) reach Claude Code
116
136
  unchanged. Claude Code waits and retries; the gateway does not add a second
117
137
  layer of retries.
138
+ - Updating from 0.3.0 or earlier: those gateways cannot hand over to a newer
139
+ one. Stop the old one once with `pkill -f scripts/gateway.mjs`; the next
140
+ prompt starts the new version.
package/lib/config.mjs CHANGED
@@ -20,12 +20,14 @@ export const DEFAULTS = {
20
20
  micro: { model: 'haiku' },
21
21
  },
22
22
  // `id` is sent upstream verbatim. List prices in USD per million tokens; `cacheRead` is absolute,
23
- // not a multiplier (Opus 5.5 reads at 0.05x input, the rest at the standard 0.1x verify when prices move).
23
+ // not a multiplier (Opus 5.5 reads at 0.05x input, the rest at the standard 0.1x). A price change must also change
24
+ // test/fixtures/list-prices.json, with its source and date. `output` feeds only the shadow estimate in the log.
24
25
  // `efforts` lists what the model accepts; an empty list means no effort field and no adaptive thinking.
25
26
  models: {
26
27
  opus: {
27
28
  id: 'claude-opus-5-5',
28
29
  input: 4,
30
+ output: 20,
29
31
  cacheRead: 0.2,
30
32
  contextWindow: 1_000_000,
31
33
  billing: 'plan',
@@ -34,6 +36,7 @@ export const DEFAULTS = {
34
36
  sonnet: {
35
37
  id: 'claude-sonnet-5',
36
38
  input: 2,
39
+ output: 10,
37
40
  cacheRead: 0.2,
38
41
  contextWindow: 1_000_000,
39
42
  billing: 'plan',
@@ -43,6 +46,7 @@ export const DEFAULTS = {
43
46
  haiku: {
44
47
  id: 'claude-haiku-4-5',
45
48
  input: 1,
49
+ output: 5,
46
50
  cacheRead: 0.1,
47
51
  contextWindow: 200_000,
48
52
  maxOutput: 64_000,
@@ -116,6 +120,8 @@ function validate(config) {
116
120
  if (!Number.isFinite(model[field]) || model[field] < 0)
117
121
  throw new Error(`models.${alias}.${field} must be a non-negative number`);
118
122
  }
123
+ if (model.output !== undefined && !(Number.isFinite(model.output) && model.output >= 0))
124
+ throw new Error(`models.${alias}.output must be a non-negative number`);
119
125
  if (typeof model.id !== 'string' || !model.id) throw new Error(`models.${alias}.id is required`);
120
126
  if (model.maxOutput !== undefined && !(Number.isInteger(model.maxOutput) && model.maxOutput > 0))
121
127
  throw new Error(`models.${alias}.maxOutput must be a positive integer`);
package/lib/cost.mjs CHANGED
@@ -1,22 +1,46 @@
1
- // Cache-aware input cost of the next request. Pure arithmetic over configured prices and the gateway's memory.
1
+ // Cache-aware cost of the next request. Pure arithmetic over configured prices and the gateway's memory.
2
+ import { clampEffort } from './rewrite.mjs';
3
+
4
+ // The prompt cache belongs to a model, and its messages part to the effort too: a top-level effort change rewrites
5
+ // the messages cache. Opus at `high` and Opus at `xhigh` are two caches, not one.
6
+ export function cacheKey(modelId, effort) {
7
+ return effort ? `${modelId}@${effort}` : modelId;
8
+ }
9
+
10
+ // The cache a request routed to `tier` uses. `sentEffort` is what Claude Code sent, for a route that keeps it.
11
+ export function routeCacheKey(config, tier, sentEffort) {
12
+ const model = modelOf(config, tier);
13
+ return cacheKey(model.id, clampEffort(config.routes[tier].effort ?? sentEffort, model.efforts));
14
+ }
2
15
 
3
16
  export function isWarm(modelState, now, cache) {
4
17
  if (!modelState) return false;
5
18
  return now < modelState.lastAt + cache.ttlMs[modelState.ttl] - cache.warmMarginMs;
6
19
  }
7
20
 
21
+ // For the logs: `unknown` means no response for this cache since the session started or its history broke. The cost
22
+ // arithmetic treats `unknown` and `expired` alike, as a full write.
23
+ export function cacheState(modelState, now, cache) {
24
+ if (!modelState) return 'unknown';
25
+ return isWarm(modelState, now, cache) ? 'warm' : 'expired';
26
+ }
27
+
28
+ function modelOf(config, tier) {
29
+ return config.models[config.routes[tier].model];
30
+ }
31
+
8
32
  function ttlFor(config, alias, facts) {
9
33
  if (config.models[alias].billing === 'credits') return '5m';
10
34
  return facts.lastRequest?.ttl ?? '5m';
11
35
  }
12
36
 
13
- // USD for the input side of one request on `alias` with `tokens` of context.
14
- export function inputCostUsd(config, alias, tokens, facts, now) {
37
+ // USD for the input side of one request routed to `tier` with `tokens` of context.
38
+ export function inputCostUsd(config, tier, tokens, facts, now) {
39
+ const alias = config.routes[tier].model;
15
40
  const model = config.models[alias];
16
- const state = facts.models[model.id];
41
+ const state = facts.models[routeCacheKey(config, tier, facts.effort)];
17
42
  const reusable = isWarm(state, now, config.cache) ? Math.min(state.prefixTokens, tokens) : 0;
18
- const ttl = ttlFor(config, alias, facts);
19
- const write = model.input * config.cache.writeMultiplier[ttl];
43
+ const write = model.input * config.cache.writeMultiplier[ttlFor(config, alias, facts)];
20
44
  return (model.cacheRead * reusable + write * (tokens - reusable)) / 1e6;
21
45
  }
22
46
 
@@ -31,9 +55,30 @@ export function nextContextTokens(facts) {
31
55
  return last ? last.tokens + last.outputTokens : 0;
32
56
  }
33
57
 
34
- export function switchingTaxUsd(config, candidateAlias, incumbentAlias, facts, now) {
58
+ export function switchingTaxUsd(config, candidateTier, incumbentTier, facts, now) {
35
59
  const tokens = nextContextTokens(facts);
36
60
  return (
37
- inputCostUsd(config, candidateAlias, tokens, facts, now) - inputCostUsd(config, incumbentAlias, tokens, facts, now)
61
+ inputCostUsd(config, candidateTier, tokens, facts, now) - inputCostUsd(config, incumbentTier, tokens, facts, now)
38
62
  );
39
63
  }
64
+
65
+ // Shadow estimate for the decision log; the policy does not read it. At list prices, so for `plan` models it is a
66
+ // list-price equivalent, not a charge. Output uses the last observed output size for both routes, although effort
67
+ // changes how much a model writes.
68
+ // - nextTurnUsd: candidate minus incumbent for the next request, input at the current cache state plus output.
69
+ // - laterTurnUsd: the same difference for each later turn, once both caches are warm.
70
+ // - paybackTurns: 0 when the switch is cheaper at once, n when later turns repay it after n turns, null when never.
71
+ export function shadowEconomics(config, candidateTier, incumbentTier, facts, now) {
72
+ const candidate = modelOf(config, candidateTier);
73
+ const incumbent = modelOf(config, incumbentTier);
74
+ if (candidate.output === undefined || incumbent.output === undefined) return null;
75
+ const tokens = nextContextTokens(facts);
76
+ const outputTokens = facts.lastRequest?.outputTokens ?? 0;
77
+ const outputUsd = ((candidate.output - incumbent.output) * outputTokens) / 1e6;
78
+ const nextTurnUsd = switchingTaxUsd(config, candidateTier, incumbentTier, facts, now) + outputUsd;
79
+ const laterTurnUsd = ((candidate.cacheRead - incumbent.cacheRead) * tokens) / 1e6 + outputUsd;
80
+ let paybackTurns = null;
81
+ if (nextTurnUsd <= 0 && laterTurnUsd <= 0) paybackTurns = 0;
82
+ else if (nextTurnUsd > 0 && laterTurnUsd < 0) paybackTurns = Math.ceil(nextTurnUsd / -laterTurnUsd);
83
+ return { nextTurnUsd, laterTurnUsd, paybackTurns, outputTokens };
84
+ }
package/lib/facts.mjs CHANGED
@@ -32,6 +32,8 @@ export function factsFromRequest(body, memory, { recentTurns, maxTextChars }) {
32
32
  // The same history length and the same last message: Claude Code resent the request (429, 529, a dropped stream).
33
33
  turnKey: last?.role === 'user' ? `${messages.length}:${hash(JSON.stringify(last.content))}` : null,
34
34
  failure: repeatedFailure(errors, edits, messages.length - 1),
35
+ // The effort Claude Code sent: a route without its own effort keeps it, and it is part of the cache key.
36
+ effort: body.output_config?.effort ?? null,
35
37
  lastRoute: memory.lastRoute,
36
38
  lastRequest: memory.lastRequest,
37
39
  models: memory.models,
package/lib/gateway.mjs CHANGED
@@ -63,10 +63,7 @@ export function createGateway({
63
63
  if (parsed && router.isRouted(parsed)) {
64
64
  if (turn) {
65
65
  try {
66
- routed = await router.route(parsed, {
67
- sessionId: session,
68
- requestClass: req.headers['x-claude-code-request-class'] ?? null,
69
- });
66
+ routed = await router.route(parsed, { sessionId: session, ...routingHints(req) });
70
67
  } catch (error) {
71
68
  onError(error);
72
69
  routed = router.fallback(parsed); // never forward the alias upstream
@@ -116,7 +113,7 @@ export function createGateway({
116
113
  if (reader.stopReason === 'tool_use') activity.turnPaused(session);
117
114
  if (!routed || routed.auxiliary) return;
118
115
  try {
119
- router.recordResponse(session, routed.tier, usage);
116
+ router.recordResponse(session, routed.tier, usage, routed.effort);
120
117
  } catch (recordError) {
121
118
  onError(recordError);
122
119
  }
@@ -130,8 +127,15 @@ export function createGateway({
130
127
  }
131
128
  }
132
129
 
133
- // Claude Code sends its session id as a header; the metadata field is the fallback.
130
+ // Claude Code sends its session id as a header; the metadata field is the fallback. A subagent's requests carry
131
+ // its agent id too: it gets its own routing memory, so its turns do not mix with the main conversation's.
134
132
  export function sessionKey(req, body) {
133
+ const agent = req.headers['x-claude-code-agent-id'];
134
+ const session = sessionOf(req, body);
135
+ return agent ? `${session}.${agent}` : session;
136
+ }
137
+
138
+ function sessionOf(req, body) {
135
139
  const header = req.headers['x-claude-code-session-id'];
136
140
  if (header) return String(header);
137
141
  try {
@@ -141,6 +145,15 @@ export function sessionKey(req, body) {
141
145
  }
142
146
  }
143
147
 
148
+ // Gateway hint headers, sent when CLAUDE_CODE_GATEWAY_HINT_HEADERS=1. Without them the router falls back to the body.
149
+ function routingHints(req) {
150
+ return {
151
+ requestClass: req.headers['x-claude-code-request-class'] ?? null,
152
+ agentType: req.headers['x-claude-code-agent-type'] ?? null,
153
+ contextCompacted: req.headers['x-claude-code-context-compacted'] ?? null,
154
+ };
155
+ }
156
+
144
157
  // A web page can reach a loopback port too: by DNS rebinding (a foreign Host) or by a cross-site request (a foreign
145
158
  // Origin). Claude Code sends a loopback Host and no Origin.
146
159
  function isLocalClient(req) {
package/lib/policy.mjs CHANGED
@@ -1,6 +1,6 @@
1
- // Switching policy v0 (docs/design.md): stickiness, escalation floor, cost-gated votes.
1
+ // Switching policy v0 (docs/architecture.md): stickiness, escalation floor, cost-gated votes.
2
2
  import { rank, TIERS } from './config.mjs';
3
- import { coldWriteUsd, isWarm, nextContextTokens, switchingTaxUsd } from './cost.mjs';
3
+ import { cacheState, coldWriteUsd, isWarm, nextContextTokens, routeCacheKey, switchingTaxUsd } from './cost.mjs';
4
4
 
5
5
  export function initialState() {
6
6
  return { turn: 0, votes: [], holdUntilTurn: 0, escalatedSignature: null };
@@ -34,15 +34,13 @@ export function decide({ config, facts, advice, state, baseline, now }) {
34
34
  if (rank(gated.fallback) <= rank(incumbent)) return stay('cash-gate', gated.estimate);
35
35
  return result(gated.fallback, 'cash-gate', next, gated.estimate);
36
36
  }
37
- const tax = Math.max(
38
- 0,
39
- switchingTaxUsd(config, config.routes[choice].model, config.routes[incumbent].model, facts, now),
40
- );
37
+ const tax = Math.max(0, switchingTaxUsd(config, choice, incumbent, facts, now));
41
38
  const threshold =
42
39
  config.policy.upgradeBase + config.policy.upgradeSlope * (tax / (tax + config.policy.upgradePivotUsd));
43
40
  const jump = rank(choice) - rank(incumbent) >= 2 && upgrade >= config.policy.jumpConfidence;
44
41
  const streak = trailing(next.votes, (v) => rank(v.tier) > rank(incumbent));
45
- const estimate = { taxUsd: tax, threshold, upgradeMass: upgrade, streak };
42
+ const cache = { candidate: cacheOf(config, choice, facts, now), incumbent: cacheOf(config, incumbent, facts, now) };
43
+ const estimate = { taxUsd: tax, threshold, upgradeMass: upgrade, streak, cache };
46
44
  if (jump || (streak >= config.policy.upgradeVotes && upgrade >= threshold))
47
45
  return result(choice, jump ? 'jump' : 'upgrade', next, estimate);
48
46
  return stay('upgrade-pending', estimate);
@@ -73,16 +71,22 @@ export function fitTier(config, tier, tokens) {
73
71
  );
74
72
  }
75
73
 
76
- // Automatic routing to a credits-billed model must fit the cash cap unless its cache is warm.
74
+ // Cold-write guard: an automatic route to a credits-billed model whose cache is not warm must not start with a cache
75
+ // write above `cashCapUsd`. It bounds that one estimated write, not the spend of the turn: a warm cache passes, and
76
+ // output is not counted.
77
77
  function cashGate(config, tier, facts, now) {
78
78
  const alias = config.routes[tier].model;
79
- const model = config.models[alias];
80
- if (model.billing !== 'credits') return { blocked: false };
81
- if (isWarm(facts.models[model.id], now, config.cache)) return { blocked: false };
79
+ if (config.models[alias].billing !== 'credits') return { blocked: false };
80
+ if (isWarm(facts.models[routeCacheKey(config, tier, facts.effort)], now, config.cache)) return { blocked: false };
82
81
  const cold = coldWriteUsd(config, alias, nextContextTokens(facts), facts);
83
82
  if (cold <= config.policy.cashCapUsd) return { blocked: false };
84
83
  const fallback = [...TIERS].reverse().find((t) => config.models[config.routes[t].model].billing === 'plan');
85
- return { blocked: true, fallback, estimate: { coldUsd: cold, cap: config.policy.cashCapUsd } };
84
+ const estimate = { coldUsd: cold, cap: config.policy.cashCapUsd, cache: cacheOf(config, tier, facts, now) };
85
+ return { blocked: true, fallback, estimate };
86
+ }
87
+
88
+ function cacheOf(config, tier, facts, now) {
89
+ return cacheState(facts.models[routeCacheKey(config, tier, facts.effort)], now, config.cache);
86
90
  }
87
91
 
88
92
  function trailing(votes, predicate) {
package/lib/router.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // Per-request orchestration: facts -> advice -> policy -> rewritten body. Knows nothing about HTTP.
2
- import { LEGACY_ALIAS } from './config.mjs';
3
- import { nextContextTokens } from './cost.mjs';
2
+ import { LEGACY_ALIAS, TIERS } from './config.mjs';
3
+ import { cacheKey, nextContextTokens, shadowEconomics } from './cost.mjs';
4
4
  import { factsFromRequest } from './facts.mjs';
5
5
  import { askJev } from './jev.mjs';
6
6
  import { decide, fitTier, initialState } from './policy.mjs';
@@ -13,19 +13,30 @@ export const JEV_FAILURES_TO_PAUSE = 3;
13
13
  export const JEV_PAUSE_MS = 60_000;
14
14
  // Reasons that reuse the route of the turn and must not replace the reason that chose it.
15
15
  const REUSED_ROUTE = new Set(['tool-continuation', 'retry']);
16
+ // `x-claude-code-request-class` values that are side requests. `main`, `subagent` and `workflow` carry a conversation.
17
+ const SIDE_REQUEST_CLASSES = new Set(['auxiliary', 'compaction']);
16
18
 
17
19
  export function emptyMemory() {
18
20
  return {
19
21
  lastRoute: null,
20
22
  lastReason: null,
23
+ lastEstimate: null,
21
24
  lastEffort: null,
22
25
  lastRequest: null,
23
26
  lastTurnKey: null,
27
+ lastMessageCount: null,
24
28
  models: {},
25
29
  state: null,
26
30
  };
27
31
  }
28
32
 
33
+ // The history the gateway saw is gone: a compaction or a rewind. The cached prefixes are unknown, and the votes and
34
+ // the escalation hold were about turns that are no longer in it. The route stays until the next decision.
35
+ function restartHistory(memory) {
36
+ memory.models = {};
37
+ if (memory.state) memory.state = { ...memory.state, votes: [], holdUntilTurn: 0, escalatedSignature: null };
38
+ }
39
+
29
40
  export class Router {
30
41
  constructor({ config, fetchFn, dataDir, now = Date.now, onError = () => {} }) {
31
42
  this.config = config;
@@ -44,10 +55,19 @@ export class Router {
44
55
 
45
56
  // Returns { body, tier, reason, auxiliary }. The caller forwards `body` and records the response
46
57
  // usage only when `auxiliary` is false: side requests carry their own context sizes.
47
- async route(body, { sessionId, requestClass }) {
58
+ // The hints come from Claude Code's gateway headers (CLAUDE_CODE_GATEWAY_HINT_HEADERS=1); each may be missing.
59
+ async route(body, { sessionId, requestClass = null, agentType = null, contextCompacted = null }) {
48
60
  const memory = this.memory(sessionId);
61
+ const auxiliary = requestClass ? SIDE_REQUEST_CLASSES.has(requestClass) : isAuxiliaryShape(body);
62
+ // Messages only grow within one history. Fewer than the last main request: Claude Code compacted the conversation
63
+ // or the user rewound it. Context editing clears tool results but keeps the messages, so it is not a break.
64
+ const messageCount = Array.isArray(body.messages) ? body.messages.length : 0;
65
+ const rewound = !auxiliary && memory.lastMessageCount !== null && messageCount < memory.lastMessageCount;
66
+ if (contextCompacted || rewound) {
67
+ restartHistory(memory);
68
+ this.log({ session: sessionId, historyBreak: contextCompacted ? 'compaction' : 'shorter-history' });
69
+ }
49
70
  const facts = factsFromRequest(body, memory, this.config.context);
50
- const auxiliary = requestClass ? requestClass !== 'main' : isAuxiliaryShape(body);
51
71
  let decision;
52
72
  if (auxiliary) decision = { tier: this.config.gateway.auxiliaryTier, reason: 'auxiliary', state: memory.state };
53
73
  else if (facts.continuation && memory.lastRoute)
@@ -61,20 +81,27 @@ export class Router {
61
81
  if (tier !== decision.tier) decision = { ...decision, tier, reason: 'context-fit' };
62
82
  }
63
83
  const rewritten = rewriteRequest(body, decision.tier, this.config);
84
+ const effort = rewritten.output_config?.effort ?? null;
64
85
  if (!auxiliary) {
65
86
  memory.lastRoute = decision.tier;
66
- memory.lastEffort = rewritten.output_config?.effort ?? null;
87
+ memory.lastEffort = effort;
67
88
  memory.lastTurnKey = facts.turnKey;
68
- if (!REUSED_ROUTE.has(decision.reason)) memory.lastReason = decision.reason;
89
+ memory.lastMessageCount = messageCount;
90
+ if (!REUSED_ROUTE.has(decision.reason)) {
91
+ memory.lastReason = decision.reason;
92
+ memory.lastEstimate = decision.estimate ?? null;
93
+ }
69
94
  memory.state = decision.state;
70
95
  this.persist(sessionId, memory);
71
96
  }
72
97
  this.log({
73
98
  session: sessionId,
74
99
  requestClass,
100
+ agentType,
75
101
  tier: decision.tier,
76
102
  reason: decision.reason,
77
103
  estimate: decision.estimate ?? null,
104
+ shadow: decision.shadow ?? null,
78
105
  advice: decision.advice ?? null,
79
106
  adviceError: decision.adviceError ?? null,
80
107
  contextTokens: memory.lastRequest?.tokens ?? 0,
@@ -82,6 +109,7 @@ export class Router {
82
109
  return {
83
110
  body: rewritten,
84
111
  tier: decision.tier,
112
+ effort,
85
113
  reason: decision.reason,
86
114
  auxiliary,
87
115
  };
@@ -124,15 +152,15 @@ export class Router {
124
152
  }
125
153
  }
126
154
  }
127
- const decision = decide({
128
- config: this.config,
129
- facts,
130
- advice,
131
- state,
132
- baseline: gateway.baselineTier,
133
- now: this.now(),
134
- });
135
- return { ...decision, advice, adviceError };
155
+ const now = this.now();
156
+ const decision = decide({ config: this.config, facts, advice, state, baseline: gateway.baselineTier, now });
157
+ // Shadow only: what following Jev's choice instead of staying would cost at list prices. The policy ignores it.
158
+ const incumbent = TIERS.includes(facts.lastRoute) ? facts.lastRoute : gateway.baselineTier;
159
+ const shadow =
160
+ TIERS.includes(advice?.choice) && advice.choice !== incumbent
161
+ ? shadowEconomics(this.config, advice.choice, incumbent, facts, now)
162
+ : null;
163
+ return { ...decision, advice, adviceError, shadow };
136
164
  }
137
165
 
138
166
  jevSucceeded() {
@@ -154,14 +182,19 @@ export class Router {
154
182
  );
155
183
  }
156
184
 
157
- // Called with the usage the gateway read from a forwarded main-conversation response.
158
- recordResponse(sessionId, tier, usage) {
185
+ // Called with the usage the gateway read from a forwarded main-conversation response, and the effort the gateway
186
+ // sent with that request: the cache is keyed by both.
187
+ recordResponse(sessionId, tier, usage, effort = null) {
159
188
  if (!usage) return;
160
189
  const memory = this.memory(sessionId);
161
190
  const modelId = usage.model ?? this.config.models[this.config.routes[tier].model].id;
162
191
  const at = this.now();
163
- // A context that shrank is a compaction: every model's cached prefix is gone.
164
- if (memory.lastRequest && usage.tokens < memory.lastRequest.tokens * COMPACTION_SHRINK) memory.models = {};
192
+ // A context that shrank by more than a fifth: a compaction, or context editing that cleared old tool results.
193
+ // Either way the cached prefixes no longer match. The votes stay: a shorter history in messages resets them.
194
+ if (memory.lastRequest && usage.tokens < memory.lastRequest.tokens * COMPACTION_SHRINK) {
195
+ memory.models = {};
196
+ this.log({ session: sessionId, cacheReset: 'context-shrink' });
197
+ }
165
198
  memory.lastRequest = {
166
199
  model: modelId,
167
200
  tokens: usage.tokens,
@@ -170,7 +203,11 @@ export class Router {
170
203
  ttl: usage.ttl,
171
204
  at,
172
205
  };
173
- memory.models[modelId] = { lastAt: at, prefixTokens: usage.tokens + usage.outputTokens, ttl: usage.ttl };
206
+ memory.models[cacheKey(modelId, effort)] = {
207
+ lastAt: at,
208
+ prefixTokens: usage.tokens + usage.outputTokens,
209
+ ttl: usage.ttl,
210
+ };
174
211
  this.persist(sessionId, memory);
175
212
  this.log({ session: sessionId, observed: { ...usage, model: modelId, tier } });
176
213
  }
package/lib/status.mjs CHANGED
@@ -34,6 +34,7 @@ export function statusSnapshot(config, memory) {
34
34
  ? {
35
35
  tier: memory.lastRoute,
36
36
  reason: memory.lastReason ?? null,
37
+ estimate: memory.lastEstimate ?? null,
37
38
  effort: memory.lastEffort ?? null,
38
39
  model: memory.lastRequest?.model ?? null,
39
40
  tokens: memory.lastRequest?.tokens ?? null,
@@ -51,14 +52,32 @@ function routeRow(config, tier) {
51
52
  return { tier, model: model.id, effort };
52
53
  }
53
54
 
54
- // One status-line segment, e.g. "router opus-5-5 · xhigh".
55
+ // Why the route of the last turn is what it is, for /router:status.
56
+ const REASONS = {
57
+ upgrade: 'Jev voted above the current tier often enough, with enough mass for the switching tax',
58
+ jump: 'Jev was confident enough to skip a tier and the vote delay',
59
+ downgrade: 'Jev voted for a lower tier often enough, with enough mass',
60
+ 'upgrade-pending': 'Jev asked for a higher tier; the route stays until the votes and the mass are enough',
61
+ 'downgrade-pending': 'Jev asked for a lower tier; the route stays until the votes and the mass are enough',
62
+ 'same-tier': 'Jev agreed with the current tier',
63
+ continuation: 'the prompt continues the task, so the route stays',
64
+ uncertain: 'Jev abstained, so the route stays',
65
+ 'no-advice': 'no Jev answer (no key, a failure or a pause), so the route stays',
66
+ escalation: 'the same error came back after an edit: one tier up',
67
+ hold: 'the tier stays up for a few turns after an escalation',
68
+ 'cash-gate':
69
+ 'cold-write guard: the first cache write on a credits model would cost more than policy.cashCapUsd, so the strongest plan tier served',
70
+ 'context-fit': "the chosen model's window does not hold the context",
71
+ forced: 'ROUTER_FORCE_TIER is set',
72
+ };
73
+
74
+ // One status-line segment, e.g. "router ▸ opus-5-5 · xhigh · upgrade".
55
75
  export function statusSegment(status) {
56
76
  if (!status) return 'router: gateway off, the next prompt starts it';
57
77
  const last = status.session;
58
78
  if (!last) return `${status.alias}: no turn yet`;
59
79
  const model = shortModel(last.model ?? status.routes.find((r) => r.tier === last.tier)?.model);
60
- const effort = last.effort ? ` · ${last.effort}` : '';
61
- return `${status.alias} ▸ ${model}${effort}`;
80
+ return [`${status.alias} ${model}`, last.effort, last.reason].filter(Boolean).join(' · ');
62
81
  }
63
82
 
64
83
  // Markdown for /router:status.
@@ -82,10 +101,32 @@ export function statusReport(status) {
82
101
  const effort = last.effort ? ` at ${last.effort}` : '';
83
102
  const context = last.tokens ? `, context ${last.tokens} tokens, cache reads ${last.cacheReadTokens}` : '';
84
103
  lines.push(`Last turn: ${last.tier} → ${model}${effort}, reason ${last.reason ?? 'unknown'}${context}.`);
104
+ if (REASONS[last.reason]) lines.push(`Why: ${REASONS[last.reason]}.`);
105
+ const estimate = describeEstimate(last.estimate);
106
+ if (estimate) lines.push(`Estimate: ${estimate}.`);
85
107
  }
86
108
  return lines.join('\n');
87
109
  }
88
110
 
111
+ // Dollars are list prices: for `plan` models a list-price equivalent, not a charge. `unknown` cache: no response for
112
+ // that cache since the session started or its history broke.
113
+ function describeEstimate(e) {
114
+ if (!e) return null;
115
+ const usd = (n) => `$${n.toFixed(2)}`;
116
+ const parts = [];
117
+ if (e.upgradeMass !== undefined)
118
+ parts.push(
119
+ `upgrade mass ${e.upgradeMass.toFixed(2)} against a bar of ${e.threshold.toFixed(2)}`,
120
+ `switching tax ${usd(e.taxUsd)} at list prices`,
121
+ );
122
+ if (e.downgradeMass !== undefined) parts.push(`downgrade mass ${e.downgradeMass.toFixed(2)}`);
123
+ if (e.coldUsd !== undefined) parts.push(`cold write ${usd(e.coldUsd)} against the cap of ${usd(e.cap)}`);
124
+ if (e.streak !== undefined) parts.push(`${e.streak} vote(s) in a row`);
125
+ if (e.cache?.candidate) parts.push(`cache: candidate ${e.cache.candidate}, current ${e.cache.incumbent}`);
126
+ else if (typeof e.cache === 'string') parts.push(`cache ${e.cache}`);
127
+ return parts.join(', ');
128
+ }
129
+
89
130
  // null when the gateway does not answer in time.
90
131
  export async function fetchStatus(port, sessionId) {
91
132
  const query = sessionId ? `?session=${encodeURIComponent(sessionId)}` : '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexeiled/claude-router",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "Claude Code plugin: auto-picks the right model and effort for each turn using Jev routing.",
5
5
  "license": "MIT",
6
6
  "author": "Alexei Ledenev",
@@ -13,6 +13,7 @@ Configure Claude Code for the router gateway. This session does not use the gate
13
13
  - `model`: `"jev-router[1m]"`. Claude Code does not know the model `jev-router` and assumes a 200K window; the `[1m]` suffix declares 1M. Claude Code strips the suffix before it sends the request. If `router.json` routes no model with a 1M `contextWindow`, use `"jev-router"`.
14
14
  - `env.ANTHROPIC_BASE_URL`: `"http://127.0.0.1:43170"`. If `router.json` sets `gateway.port`, use that port.
15
15
  - `env.ENABLE_TOOL_SEARCH`: `"true"`. With a custom `ANTHROPIC_BASE_URL`, Claude Code turns tool search off and loads every MCP tool schema into each request (about 50K tokens with the claude.ai connectors). This key keeps the schemas deferred, as with the Anthropic API.
16
+ - `env.CLAUDE_CODE_GATEWAY_HINT_HEADERS`: `"1"`. Claude Code then tells the gateway the class of each request (main turn, subagent, side request, compaction) instead of the gateway guessing it from the body.
16
17
  - Remove `env.CLAUDE_CODE_MAX_CONTEXT_TOKENS`. Older versions of this setup wrote it; Claude Code ignores it for `jev-router`. The gateway sends a turn only to a model whose window holds the context, and drops the 1M beta header for a model with a smaller window.
17
18
  - The `/model` picker row. In `modelPicker.options`, replace the row whose `model` is `"jev-router[1m]"`, `"jev-router"` or `"router"` (the name before 0.4.2), or append it if there is none:
18
19
  `{ "model": "jev-router[1m]", "label": "Jev Router (auto)", "description": "Auto-selects the model and effort for each turn", "behavesAs": "claude-opus-5-5" }`.
@@ -24,7 +25,7 @@ Configure Claude Code for the router gateway. This session does not use the gate
24
25
  - Restart Claude Code now. Until the restart, this session can show "There's an issue with the selected model (jev-router[1m])", because it still sends requests to Anthropic and not to the gateway.
25
26
  - After the restart, the router serves each turn, and `/router:status` shows the routes and the last turn.
26
27
  - The status line path contains the plugin version. After a plugin update, run `/router:setup` again.
27
- - To stop the routing, remove `model`, `env.ANTHROPIC_BASE_URL`, `env.ENABLE_TOOL_SEARCH` and the `jev-router[1m]` row of `modelPicker.options`, and restore the status line command.
28
+ - To stop the routing, remove `model`, `env.ANTHROPIC_BASE_URL`, `env.ENABLE_TOOL_SEARCH`, `env.CLAUDE_CODE_GATEWAY_HINT_HEADERS` and the `jev-router[1m]` row of `modelPicker.options`, and restore the status line command.
28
29
  5. Write the whole object to `~/.claude/settings.json` with one Write call. Do not use a sequence of edits. Do nothing after this step.
29
30
 
30
31
  Do not change any other file.