@alexeiled/claude-router 0.5.0 → 0.6.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "router",
3
3
  "displayName": "jev-router",
4
- "version": "0.5.0",
4
+ "version": "0.6.0",
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
@@ -31,7 +31,8 @@ high effort) and `high` (Opus at xhigh effort). The exact model IDs are in
31
31
  `~/.claude/router.json` and default to the current generation of each family.
32
32
 
33
33
  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
34
+ Side requests, for example session titles, get the baseline tier. A subagent
35
+ that inherits the model gets routing with its own memory. A request for
35
36
  any other model goes through unchanged. This is how `/router:<tier>` pins and
36
37
  subagents with their own `model` work.
37
38
 
@@ -22,7 +22,9 @@ starts it again.
22
22
  {
23
23
  "model": "jev-router[1m]",
24
24
  "env": {
25
- "ANTHROPIC_BASE_URL": "http://127.0.0.1:43170"
25
+ "ANTHROPIC_BASE_URL": "http://127.0.0.1:43170",
26
+ "ENABLE_TOOL_SEARCH": "true",
27
+ "CLAUDE_CODE_GATEWAY_HINT_HEADERS": "1"
26
28
  },
27
29
  "modelPicker": {
28
30
  "options": [
@@ -37,6 +39,11 @@ starts it again.
37
39
  }
38
40
  ```
39
41
 
42
+ With a custom `ANTHROPIC_BASE_URL`, Claude Code turns tool search off: every
43
+ MCP tool schema goes into each request, about 50K tokens with the claude.ai
44
+ connectors. `ENABLE_TOOL_SEARCH` keeps the schemas deferred, as with the
45
+ Anthropic API; `/context` lists them as "MCP tools (deferred)".
46
+
40
47
  The `modelPicker` row adds `Jev Router (auto)` to the `/model` picker, next to the
41
48
  built-in rows. `behavesAs` maps `jev-router` to a model that Claude Code knows.
42
49
  Without it, Claude Code rejects `jev-router` because the model is not in its
@@ -74,14 +81,23 @@ session, for example `jev-router ▸ opus-5-5 · xhigh`. Without a command
74
81
  after it, it prints only that line. The path contains the plugin version, so
75
82
  run `/router:setup` again after a plugin update.
76
83
 
77
- One optional key in `env`:
84
+ `CLAUDE_CODE_GATEWAY_HINT_HEADERS: "1"` makes Claude Code send the gateway
85
+ hint headers:
86
+
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.
92
+ - `x-claude-code-agent-type`, for example `Explore` or `Plan`. It goes to
93
+ `decisions.jsonl` only.
94
+
95
+ Without the headers, the gateway identifies side requests by their shape and
96
+ a compaction by a context that shrank by more than 20%.
78
97
 
79
- - `CLAUDE_CODE_GATEWAY_HINT_HEADERS: "1"`. Claude Code then tells the gateway
80
- the class of each request. Requests of the class `main` get routing. All
81
- other classes (`auxiliary`, `subagent`, `workflow`, `compaction`) get
82
- `gateway.auxiliaryTier`. A subagent with `model: inherit` runs on that tier.
83
- Without the header, the gateway identifies side requests by their shape, and
84
- subagents get routing like the main conversation.
98
+ A subagent with `model: inherit` sends `x-claude-code-agent-id` even without
99
+ the hint headers. Each subagent keeps its own routing memory, so its turns do
100
+ not change the route of the main conversation.
85
101
 
86
102
  ## Configuration file
87
103
 
package/docs/design.md CHANGED
@@ -73,10 +73,12 @@ account.
73
73
 
74
74
  - New user turn (the last message has no `tool_result`): Jev and the policy.
75
75
  - Tool continuation: the route of the turn, without a Jev call.
76
- - Every request class other than `main`, from the header
76
+ - The classes `auxiliary` and `compaction` from the header
77
77
  `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.
78
+ unchanged. `main`, `subagent` and `workflow` get routing. Without the header,
79
+ a body with `thinking: disabled` and a `format` is a side request.
80
+ - A subagent (header `x-claude-code-agent-id`): routing with its own memory,
81
+ under the key `<session>.<agent id>`. The main conversation keeps its route.
80
82
  - Any other `model`: unchanged. This covers `/router:<tier>` pins,
81
83
  `/model` changes and subagents with their own model.
82
84
  - A resent request (the same history length and the same last message): the
@@ -130,7 +132,7 @@ transcripts.
130
132
  | Context of the last request, cache reads, output | `usage` in the response (`message_start` and `message_delta`) |
131
133
  | Granted TTL | `usage.cache_creation.ephemeral_1h_input_tokens` or the `5m` field |
132
134
  | 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). |
135
+ | Reusable prefix of a model | The context plus the output at the last response of that model. Cleared by `x-claude-code-context-compacted`, or when the context shrinks by more than 20% (compaction).|
134
136
  | Failure signal | Two `tool_result` blocks with `is_error` and the same signature, with an edit tool call between them |
135
137
  | Continuation | The last message contains a `tool_result`. For a new prompt, a Jev Noul answers "does this prompt continue the task". |
136
138
 
@@ -229,8 +231,9 @@ Agreed with Codex on 2026-09-22. The thresholds are start values.
229
231
  `decisions.jsonl` are the input for the tuning.
230
232
  - The gateway reads the configuration once. A reload without a restart is not
231
233
  implemented.
232
- - Without `CLAUDE_CODE_GATEWAY_HINT_HEADERS=1`, subagents with `model: inherit`
233
- get routing like the main conversation.
234
+ - Without `CLAUDE_CODE_GATEWAY_HINT_HEADERS=1`, the gateway guesses side
235
+ requests and compactions from the body; the guesses miss some.
236
+ - `x-claude-code-agent-type` is logged, not used: no policy rule reads it yet.
234
237
 
235
238
  ## Release
236
239
 
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
@@ -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/router.mjs CHANGED
@@ -13,6 +13,8 @@ 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 {
@@ -44,10 +46,13 @@ export class Router {
44
46
 
45
47
  // Returns { body, tier, reason, auxiliary }. The caller forwards `body` and records the response
46
48
  // usage only when `auxiliary` is false: side requests carry their own context sizes.
47
- async route(body, { sessionId, requestClass }) {
49
+ // The hints come from Claude Code's gateway headers (CLAUDE_CODE_GATEWAY_HINT_HEADERS=1); each may be missing.
50
+ async route(body, { sessionId, requestClass = null, agentType = null, contextCompacted = null }) {
48
51
  const memory = this.memory(sessionId);
52
+ // The compaction rewrote the conversation: no model has its prefix cached any more.
53
+ if (contextCompacted) memory.models = {};
49
54
  const facts = factsFromRequest(body, memory, this.config.context);
50
- const auxiliary = requestClass ? requestClass !== 'main' : isAuxiliaryShape(body);
55
+ const auxiliary = requestClass ? SIDE_REQUEST_CLASSES.has(requestClass) : isAuxiliaryShape(body);
51
56
  let decision;
52
57
  if (auxiliary) decision = { tier: this.config.gateway.auxiliaryTier, reason: 'auxiliary', state: memory.state };
53
58
  else if (facts.continuation && memory.lastRoute)
@@ -72,6 +77,7 @@ export class Router {
72
77
  this.log({
73
78
  session: sessionId,
74
79
  requestClass,
80
+ agentType,
75
81
  tier: decision.tier,
76
82
  reason: decision.reason,
77
83
  estimate: decision.estimate ?? null,
@@ -160,7 +166,8 @@ export class Router {
160
166
  const memory = this.memory(sessionId);
161
167
  const modelId = usage.model ?? this.config.models[this.config.routes[tier].model].id;
162
168
  const at = this.now();
163
- // A context that shrank is a compaction: every model's cached prefix is gone.
169
+ // A context that shrank is a compaction: every model's cached prefix is gone. Without the hint headers this is
170
+ // the only sign of a compaction.
164
171
  if (memory.lastRequest && usage.tokens < memory.lastRequest.tokens * COMPACTION_SHRINK) memory.models = {};
165
172
  memory.lastRequest = {
166
173
  model: modelId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexeiled/claude-router",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: setup
3
- description: Point Claude Code at the router gateway. Adds `model` with the 1M window, `ANTHROPIC_BASE_URL` and the `/model` picker row to the user settings, and offers the status line.
3
+ description: Point Claude Code at the router gateway. Adds `model` with the 1M window, `ANTHROPIC_BASE_URL`, tool search and the `/model` picker row to the user settings, and offers the status line.
4
4
  disable-model-invocation: true
5
5
  allowed-tools: Read, Edit, Write
6
6
  ---
@@ -12,6 +12,8 @@ Configure Claude Code for the router gateway. This session does not use the gate
12
12
  3. Prepare the new settings object in memory:
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
+ - `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.
15
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.
16
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:
17
19
  `{ "model": "jev-router[1m]", "label": "Jev Router (auto)", "description": "Auto-selects the model and effort for each turn", "behavesAs": "claude-opus-5-5" }`.
@@ -23,7 +25,7 @@ Configure Claude Code for the router gateway. This session does not use the gate
23
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.
24
26
  - After the restart, the router serves each turn, and `/router:status` shows the routes and the last turn.
25
27
  - The status line path contains the plugin version. After a plugin update, run `/router:setup` again.
26
- - To stop the routing, remove `model`, `env.ANTHROPIC_BASE_URL` 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.
27
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.
28
30
 
29
31
  Do not change any other file.