@alexeiled/claude-router 0.1.0 → 0.2.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": "Router",
4
- "version": "0.1.0",
4
+ "version": "0.2.0",
5
5
  "description": "Local gateway that selects a model and an effort level for each user turn with a TypeSafe Jev Choice.",
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
@@ -22,7 +22,7 @@ Claude Code --model router ──▶ gateway 127.0.0.1:43170 ──▶ api.
22
22
  ```
23
23
 
24
24
  The tiers are `micro` (haiku), `low` (sonnet, the baseline), `medium` (opus at
25
- high effort) and `high` (fable at xhigh effort).
25
+ high effort) and `high` (opus at xhigh effort).
26
26
 
27
27
  A tool continuation is a request whose last message is a `tool_result`. It
28
28
  keeps the route of the turn, and the gateway does not ask Jev. Side requests,
@@ -46,9 +46,10 @@ Only `store` writes files. The Jev transport is injected.
46
46
 
47
47
  2. When Claude Code asks, enter the TypeSafe API key. The key goes to the
48
48
  macOS Keychain.
49
- 3. In Claude Code, run `/router:setup`. It writes two keys to
50
- `~/.claude/settings.json`: `model` and `env.ANTHROPIC_BASE_URL`.
51
- 4. Restart Claude Code.
49
+ 3. In Claude Code, run `/router:setup`. It writes `model`,
50
+ `env.ANTHROPIC_BASE_URL` and the `/model` picker row to
51
+ `~/.claude/settings.json`, and offers a status line segment.
52
+ 4. Restart Claude Code. `/router:status` shows the routes and the last turn.
52
53
 
53
54
  The `SessionStart` hook of the plugin starts the gateway when the port does not
54
55
  answer. A claude.ai login continues to work: the gateway sends the
@@ -64,6 +65,7 @@ authorization header and the OAuth value of `anthropic-beta` unchanged.
64
65
 
65
66
  ```sh
66
67
  npm install
68
+ git config --local core.hooksPath scripts/git-hooks # pre-commit: biome + gitleaks; pre-push: check, test, pack, gitleaks
67
69
  npm test # node:test
68
70
  npm run check # biome lint and format
69
71
  npm run validate # claude plugin validate
@@ -2,12 +2,12 @@
2
2
 
3
3
  ## Where each value is
4
4
 
5
- | Value | Location | Reason |
6
- | ----- | -------- | ------ |
7
- | TypeSafe API key | `TYPESAFE_API_KEY` in the environment of the gateway | One name in all places. The key is never in a file. |
5
+ | Value | Location | Reason |
6
+ | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
7
+ | TypeSafe API key | `TYPESAFE_API_KEY` in the environment of the gateway | One name in all places. The key is never in a file. |
8
8
  | The key for an installed plugin | The plugin option `typesafe_api_key`. Claude Code asks for it when you enable the plugin and stores it in the macOS Keychain. The `SessionStart` hook gives it to the gateway as `TYPESAFE_API_KEY`. | Claude Code exports plugin options as `CLAUDE_PLUGIN_OPTION_<KEY>`. The gateway does not read that name. |
9
- | Claude Code settings | `~/.claude/settings.json`: `model` and `env.ANTHROPIC_BASE_URL`. `/router:setup` writes them. | Claude Code reads the base URL at start. A plugin cannot set it. |
10
- | Routing configuration | `~/.claude/router.json`, user scope only | A cloned repository must not change your routing or your spend. |
9
+ | Claude Code settings | `~/.claude/settings.json`: `model`, `env.ANTHROPIC_BASE_URL`, the picker row, the status line. `/router:setup` writes them. | Claude Code reads the base URL at start. A plugin cannot set it. |
10
+ | Routing configuration | `~/.claude/router.json`, user scope only | A cloned repository must not change your routing or your spend. |
11
11
 
12
12
  The gateway ignores project files. To use another file, set
13
13
  `ROUTER_CONFIG=/path/to/file.json`. The gateway reads the file at start. After
@@ -21,11 +21,35 @@ starts it again.
21
21
  ```json
22
22
  {
23
23
  "model": "router",
24
- "env": { "ANTHROPIC_BASE_URL": "http://127.0.0.1:43170" }
24
+ "env": {
25
+ "ANTHROPIC_BASE_URL": "http://127.0.0.1:43170",
26
+ "ANTHROPIC_CUSTOM_MODEL_OPTION": "router",
27
+ "ANTHROPIC_CUSTOM_MODEL_OPTION_NAME": "Router (auto)",
28
+ "ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION": "Picks Opus 5.5 / Sonnet 4.6 / Haiku 4.5 and the effort for each turn"
29
+ }
25
30
  }
26
31
  ```
27
32
 
28
- Two optional keys in `env`:
33
+ The three `ANTHROPIC_CUSTOM_MODEL_OPTION*` keys add a `Router (auto)` row to
34
+ the `/model` picker.
35
+
36
+ If you agree, it also wraps the status line command:
37
+
38
+ ```json
39
+ {
40
+ "statusLine": {
41
+ "type": "command",
42
+ "command": "node <plugin root>/scripts/statusline.mjs claude-powerline"
43
+ }
44
+ }
45
+ ```
46
+
47
+ The wrapper runs the command after it, then adds one line for a routed
48
+ session, for example `router ▸ opus-5-5 · xhigh (high)`. Without a command
49
+ after it, it prints only that line. The path contains the plugin version, so
50
+ run `/router:setup` again after a plugin update.
51
+
52
+ One optional key in `env`:
29
53
 
30
54
  - `CLAUDE_CODE_GATEWAY_HINT_HEADERS: "1"`. Claude Code then tells the gateway
31
55
  the class of each request. Requests of the class `main` get routing. All
@@ -33,8 +57,6 @@ Two optional keys in `env`:
33
57
  `gateway.auxiliaryTier`. A subagent with `model: inherit` runs on that tier.
34
58
  Without the header, the gateway identifies side requests by their shape, and
35
59
  subagents get routing like the main conversation.
36
- - `ANTHROPIC_CUSTOM_MODEL_OPTION: "router"`. The `/model` picker then shows a
37
- `router` row.
38
60
 
39
61
  ## Configuration file
40
62
 
@@ -43,15 +65,27 @@ path. Nested objects merge.
43
65
 
44
66
  ```json
45
67
  {
46
- "gateway": { "port": 43170, "alias": "router", "baselineTier": "low", "auxiliaryTier": "low" },
68
+ "gateway": {
69
+ "port": 43170,
70
+ "alias": "router",
71
+ "baselineTier": "low",
72
+ "auxiliaryTier": "low"
73
+ },
47
74
  "routes": {
48
- "high": { "model": "fable", "effort": "xhigh" },
49
- "medium": { "model": "opus", "effort": "high" },
50
- "low": { "model": "sonnet" },
51
- "micro": { "model": "haiku" }
75
+ "high": { "model": "opus", "effort": "xhigh" },
76
+ "medium": { "model": "opus", "effort": "high" },
77
+ "low": { "model": "sonnet" },
78
+ "micro": { "model": "haiku" }
52
79
  },
53
80
  "models": {
54
- "sonnet": { "id": "claude-sonnet-4-6", "input": 3, "cacheRead": 0.3, "contextWindow": 1000000, "billing": "plan", "efforts": ["low", "medium", "high", "max"] }
81
+ "sonnet": {
82
+ "id": "claude-sonnet-4-6",
83
+ "input": 3,
84
+ "cacheRead": 0.3,
85
+ "contextWindow": 1000000,
86
+ "billing": "plan",
87
+ "efforts": ["low", "medium", "high", "max"]
88
+ }
55
89
  },
56
90
  "policy": {
57
91
  "upgradeVotes": 2,
@@ -80,6 +114,12 @@ frontmatter of `skills/<tier>/SKILL.md`. A test makes sure that they agree.
80
114
 
81
115
  ### models
82
116
 
117
+ | Alias | ID | Input | Cache Read | Window | Billing | Efforts |
118
+ | -------- | ------------------- | ----- | ---------- | ------ | ------- | ------- |
119
+ | `opus` | `claude-opus-5-5` | $4 | $0.2 | 1M | plan | all |
120
+ | `sonnet` | `claude-sonnet-4-6` | $3 | $0.3 | 1M | plan | low–max |
121
+ | `haiku` | `claude-haiku-4-5` | $1 | $0.1 | 200k | plan | none |
122
+
83
123
  `id` is the model id that the gateway sends to Anthropic. `input` and
84
124
  `cacheRead` are list prices in USD per million tokens. `contextWindow` is the
85
125
  size of the context window in tokens. `billing` is `plan` for models that use
@@ -90,23 +130,23 @@ effort and thinking from the request.
90
130
 
91
131
  ### policy
92
132
 
93
- | Key | Meaning |
94
- | --- | ------- |
95
- | `gateway.baselineTier` | The tier when nothing else decides: Jev abstains, Jev fails, or the session is new. |
96
- | `gateway.auxiliaryTier` | The tier for side requests, for example session titles. |
97
- | `upgradeVotes` | The number of consecutive votes above the current tier before an upgrade of one tier. |
133
+ | Key | Meaning |
134
+ | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
135
+ | `gateway.baselineTier` | The tier when nothing else decides: Jev abstains, Jev fails, or the session is new. |
136
+ | `gateway.auxiliaryTier` | The tier for side requests, for example session titles. |
137
+ | `upgradeVotes` | The number of consecutive votes above the current tier before an upgrade of one tier. |
98
138
  | `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. |
99
- | `jumpConfidence` | The mass that lets a jump of two tiers skip the vote delay. |
100
- | `downgradeVotes`, `downgradeMass` | The number of consecutive votes, and the mass at or below the candidate, for a downgrade. |
101
- | `continuationMass` | The Jev probability for "this prompt continues the task" that keeps the current route. |
102
- | `escalationHoldTurns` | The number of turns to hold one tier up after two failed repairs of the same error. |
103
- | `cashCapUsd` | The cold cache-write cost above which the gateway refuses an automatic route to a `credits` model. |
139
+ | `jumpConfidence` | The mass that lets a jump of two tiers skip the vote delay. |
140
+ | `downgradeVotes`, `downgradeMass` | The number of consecutive votes, and the mass at or below the candidate, for a downgrade. |
141
+ | `continuationMass` | The Jev probability for "this prompt continues the task" that keeps the current route. |
142
+ | `escalationHoldTurns` | The number of turns to hold one tier up after two failed repairs of the same error. |
143
+ | `cashCapUsd` | The cold cache-write cost above which the gateway refuses an automatic route to a `credits` model. |
104
144
 
105
145
  ## Environment variables
106
146
 
107
- | Variable | Effect |
108
- | -------- | ------ |
109
- | `TYPESAFE_API_KEY` | The Jev key. |
110
- | `ROUTER_CONFIG` | The path of the configuration file. |
111
- | `ROUTER_FORCE_TIER` | `micro`, `low`, `medium` or `high`. Skips Jev and the policy and always routes to that tier. For tests. |
112
- | `CLAUDE_PLUGIN_DATA` | Set by Claude Code for hooks. The directory holds `sessions/`, `decisions.jsonl` and `gateway.log`. |
147
+ | Variable | Effect |
148
+ | -------------------- | ------------------------------------------------------------------------------------------------------- |
149
+ | `TYPESAFE_API_KEY` | The Jev key. |
150
+ | `ROUTER_CONFIG` | The path of the configuration file. |
151
+ | `ROUTER_FORCE_TIER` | `micro`, `low`, `medium` or `high`. Skips Jev and the policy and always routes to that tier. For tests. |
152
+ | `CLAUDE_PLUGIN_DATA` | Set by Claude Code for hooks. The directory holds `sessions/`, `decisions.jsonl` and `gateway.log`. |
package/docs/design.md CHANGED
@@ -18,15 +18,23 @@ the policy, and changes `model`, `output_config.effort` and `thinking`.
18
18
  Responses go through unchanged. The gateway reads `usage` from the response to
19
19
  get the context size, the cache reads and the cache TTL.
20
20
 
21
- The gateway never changes `system`, `tools` or `messages`. Thus preserved
21
+ For a model without thinking (Haiku), the gateway also removes the
22
+ `clear_thinking_*` edits from `context_management`, because the API rejects
23
+ them without thinking. The gateway never changes `system`, `tools` or
24
+ `messages`. Thus preserved
22
25
  thinking and prompt caching work as if Claude Code talked to Anthropic. Claude
23
26
  Code documents this gateway mode, including the OAuth value for a claude.ai
24
27
  login. See [llm-gateway](https://code.claude.com/docs/en/llm-gateway) and
25
28
  [protocol](https://code.claude.com/docs/en/llm-gateway-protocol).
26
29
 
27
30
  The `SessionStart` hook of the plugin starts the gateway when the port does not
28
- answer. `/router:setup` writes `model` and `ANTHROPIC_BASE_URL` to the user settings
29
- once. A plugin cannot set them by itself.
31
+ answer. `/router:setup` writes `model`, `ANTHROPIC_BASE_URL` and the picker row to
32
+ the user settings once. A plugin cannot set them by itself.
33
+
34
+ The gateway serves `GET /router/status?session=<id>`: the routes and the last
35
+ turn of the session, never the key. `/router:status` and the status line
36
+ wrapper read it. Claude Code shows only the alias as the model, so the wrapper
37
+ is the only place where the real model of the turn is visible.
30
38
 
31
39
  ### Why not the native skill path
32
40
 
@@ -49,12 +57,12 @@ keeps its route. The baseline is a configuration value.
49
57
 
50
58
  ## Tiers
51
59
 
52
- | Tier | model | effort | id sent to Anthropic |
53
- | ------ | ------ | -------- | -------------------- |
54
- | high | fable | xhigh | claude-fable-5-1 |
55
- | medium | opus | high | claude-opus-5 |
56
- | low | sonnet | as sent | claude-sonnet-4-6 |
57
- | micro | haiku | none | claude-haiku-4-5 |
60
+ | Tier | model | effort | id sent to Anthropic |
61
+ | ------ | ------ | ------- | -------------------- |
62
+ | high | opus | xhigh | claude-opus-5-5 |
63
+ | medium | opus | high | claude-opus-5-5 |
64
+ | low | sonnet | as sent | claude-sonnet-4-6 |
65
+ | micro | haiku | none | claude-haiku-4-5 |
58
66
 
59
67
  The gateway lowers the effort to a level that the model family accepts. Sonnet
60
68
  4.6 has no `xhigh`. Haiku gets no effort and no adaptive thinking. The ids are
@@ -77,14 +85,14 @@ account.
77
85
  All inputs come from the traffic of the gateway. The gateway does not read
78
86
  transcripts.
79
87
 
80
- | Input | Source |
81
- | ----- | ------ |
82
- | Context of the last request, cache reads, output | `usage` in the response (`message_start` and `message_delta`) |
83
- | Granted TTL | `usage.cache_creation.ephemeral_1h_input_tokens` or the `5m` field |
84
- | Cache warmth of a model | The time of the last response of that model, plus the TTL, minus 30 s |
85
- | 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). |
86
- | Failure signal | Two `tool_result` blocks with `is_error` and the same signature, with an edit tool call between them |
87
- | Continuation | The last message contains a `tool_result`. For a new prompt, a Jev Noul answers "does this prompt continue the task". |
88
+ | Input | Source |
89
+ | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
90
+ | Context of the last request, cache reads, output | `usage` in the response (`message_start` and `message_delta`) |
91
+ | Granted TTL | `usage.cache_creation.ephemeral_1h_input_tokens` or the `5m` field |
92
+ | Cache warmth of a model | The time of the last response of that model, plus the TTL, minus 30 s |
93
+ | 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). |
94
+ | Failure signal | Two `tool_result` blocks with `is_error` and the same signature, with an edit tool call between them |
95
+ | Continuation | The last message contains a `tool_result`. For a new prompt, a Jev Noul answers "does this prompt continue the task". |
88
96
 
89
97
  Prices are a list-price table in the configuration. `modelPricing` is a
90
98
  managed setting and is not readable. The switching tax for a candidate `c`
@@ -95,12 +103,14 @@ input_cost(m) = P_read(m) * W_m + P_write(m) * (N - W_m)
95
103
  tax = max(0, input_cost(c) - input_cost(i))
96
104
  ```
97
105
 
98
- The subscription economics are not symmetric. Sonnet, Opus and Haiku use the
99
- plan limits. Dollars give the order between them. Fable bills usage credits in
100
- cash, on the 5m TTL, and behind the gateway without the consent prompt of
101
- Claude Code. A Claude Code turn starts at about 100k tokens (system prompt and
102
- 159 tool definitions). A cold Fable write costs about $1.25 before any history.
103
- `policy.cashCapUsd` is $2 by default.
106
+ The subscription economics are not symmetric. Every default model uses the plan
107
+ limits, and dollars give the order between them. A model with `billing:
108
+ "credits"` bills cash, on the 5m TTL, and behind the gateway without the
109
+ consent prompt of Claude Code. `policy.cashCapUsd` ($2 by default) caps the
110
+ cold cache write that the gateway will pay for an automatic route to such a
111
+ model. A Claude Code turn starts at about 100k tokens (system prompt and 159
112
+ tool definitions), so the gate binds on the first switch, not later. No default
113
+ model bills credits; the gate stays for configurations that add one.
104
114
 
105
115
  ## Switching policy v0
106
116
 
@@ -150,6 +160,8 @@ Agreed with Codex on 2026-09-22. The thresholds are start values.
150
160
  hooks/hooks.json SessionStart -> scripts/ensure-gateway.mjs
151
161
  scripts/gateway.mjs daemon entry
152
162
  scripts/ensure-gateway.mjs port probe, detached spawn
163
+ scripts/statusline.mjs status line wrapper: wrapped command, then the route
164
+ scripts/status.mjs report for /router:status
153
165
  scripts/transcript-models.sh model for each assistant line of a transcript
154
166
  lib/runtime.mjs configuration and data directory from the environment
155
167
  lib/config.mjs defaults, user file, validation
@@ -162,8 +174,10 @@ Agreed with Codex on 2026-09-22. The thresholds are start values.
162
174
  lib/router.mjs orchestration for one request, session memory
163
175
  lib/gateway.mjs HTTP passthrough and rewrite
164
176
  lib/store.mjs files: configuration, memory, decisions.jsonl
177
+ lib/status.mjs status snapshot, status line segment, report
165
178
  skills/<tier>/SKILL.md manual pins (model and effort frontmatter)
166
- skills/setup/SKILL.md writes model and ANTHROPIC_BASE_URL to the user settings
179
+ skills/setup/SKILL.md writes model, base URL, picker row, status line
180
+ skills/status/SKILL.md /router:status
167
181
  test/ node:test, builders in helpers.mjs
168
182
  ```
169
183
 
@@ -2,8 +2,8 @@
2
2
 
3
3
  ## Start a session
4
4
 
5
- After the install steps in the README, run `claude` as usual. The status line
6
- shows `router` as the model. The transcript records the model that answered
5
+ After the install steps in the README, run `claude` as usual. Claude Code shows
6
+ `Router (auto)` as the model. The transcript records the model that answered
7
7
  each message. The first session starts the gateway. The gateway continues to
8
8
  run after the session ends.
9
9
 
@@ -28,17 +28,18 @@ ANTHROPIC_BASE_URL=http://127.0.0.1:43170 claude --plugin-dir . --model router
28
28
  tiers with high confidence happens at once. The required confidence goes up
29
29
  with the cost to read the context again on the new model.
30
30
  - A downgrade needs two consecutive confident votes.
31
- - A cold switch to Fable is refused when the cache write costs more than
32
- `policy.cashCapUsd`. Then Opus serves the turn. Fable bills usage credits.
33
- Behind the gateway, Claude Code does not show its consent prompt for these
34
- credits. The cap is the only guard.
31
+ - A cold switch to a model that bills usage credits is refused when the cache
32
+ write costs more than `policy.cashCapUsd`. Then the strongest plan tier
33
+ serves the turn. Behind the gateway, Claude Code does not show its consent
34
+ prompt for these credits, so the cap is the only guard. No default model
35
+ bills credits; this applies once you add one in `router.json`.
35
36
  - When Jev fails or times out, or when there is no key, the baseline tier
36
37
  serves the turn.
37
38
  - Jev receives the prompt and the last six turns of text. Tool results are not
38
39
  sent. No other data leaves the machine, except the usual Anthropic request.
39
40
 
40
41
  A Claude Code turn starts at about 100k tokens of system prompt and tool
41
- definitions. A cold Fable cache write costs about $1.25 before any history.
42
+ definitions, so the first switch to a model is the expensive one.
42
43
 
43
44
  ## Pin a tier by hand
44
45
 
@@ -52,6 +53,21 @@ The gateway sends the real model id unchanged.
52
53
 
53
54
  `/model <name>` also works. It stops the routing for the rest of the session.
54
55
 
56
+ ## See the current route
57
+
58
+ `/router:status` shows the gateway, the routes, and the model, effort and
59
+ reason of the last turn in this session.
60
+
61
+ The status line wrapper from `/router:setup` adds one line to your status line
62
+ while the session uses the router:
63
+
64
+ - `router ▸ opus-5-5 · xhigh (high)`: the model, the effort and the tier of the
65
+ last turn.
66
+ - `router: no turn yet`: the session has no routed turn.
67
+ - `router: gateway down`: the gateway does not answer.
68
+
69
+ The line updates when Claude Code redraws the status line, after each message.
70
+
55
71
  ## Read the decisions
56
72
 
57
73
  The gateway writes one line for each routed request to `decisions.jsonl` in
@@ -73,6 +89,11 @@ assistant message in a Claude Code transcript.
73
89
 
74
90
  ## Troubleshooting
75
91
 
92
+ - If `claude plugin install` fails with `code EALLOWREMOTE`, your npm is version
93
+ 12 or later. Claude Code downloads the plugin by tarball URL, and npm 12
94
+ refuses remote tarballs by default (`allow-remote = "none"`). Run the install
95
+ with npm 11, for example `fnm exec --using 22 claude plugin install
96
+ router@alexei-led-claude-router`, or set `npm config set allow-remote all`.
76
97
  - If Claude Code does not accept `router` as a model, make sure that the
77
98
  gateway runs and that `ANTHROPIC_BASE_URL` is set. The command
78
99
  `curl http://127.0.0.1:43170/v1/models` lists the alias.
package/lib/config.mjs CHANGED
@@ -5,28 +5,22 @@ export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
5
5
 
6
6
  export const DEFAULTS = {
7
7
  gateway: { port: 43170, alias: 'router', baselineTier: 'low', auxiliaryTier: 'low' },
8
+ // `high` and `medium` share one model and differ by effort: Opus 5.5 at xhigh is the strongest
9
+ // setting this router can ask for, and the tier ladder stays four wide for the policy.
8
10
  routes: {
9
- high: { model: 'fable', effort: 'xhigh' },
11
+ high: { model: 'opus', effort: 'xhigh' },
10
12
  medium: { model: 'opus', effort: 'high' },
11
13
  low: { model: 'sonnet' },
12
14
  micro: { model: 'haiku' },
13
15
  },
14
16
  // `id` is sent upstream verbatim. List prices in USD per million tokens; `cacheRead` is absolute,
15
- // not a multiplier: Fable 5.1 reads are billed at 0.25 (Claude API reference, verify when prices move).
17
+ // not a multiplier (Opus 5.5 reads at 0.05x input, the rest at the standard 0.1x verify when prices move).
16
18
  // `efforts` lists what the model accepts; an empty list means no effort field and no adaptive thinking.
17
19
  models: {
18
- fable: {
19
- id: 'claude-fable-5-1',
20
- input: 10,
21
- cacheRead: 0.25,
22
- contextWindow: 1_000_000,
23
- billing: 'credits',
24
- efforts: EFFORTS,
25
- },
26
20
  opus: {
27
- id: 'claude-opus-5',
28
- input: 5,
29
- cacheRead: 0.5,
21
+ id: 'claude-opus-5-5',
22
+ input: 4,
23
+ cacheRead: 0.2,
30
24
  contextWindow: 1_000_000,
31
25
  billing: 'plan',
32
26
  efforts: EFFORTS,
@@ -56,7 +50,9 @@ export const DEFAULTS = {
56
50
  downgradeMass: 0.9,
57
51
  continuationMass: 0.7,
58
52
  escalationHoldTurns: 2,
59
- cashCapUsd: 2, // a Claude Code turn starts near 100k tokens (system prompt + tools): cold Fable is ~$1.25 before any history
53
+ // Ceiling on a cold cache write to a `credits` model. No default model bills credits, so this is
54
+ // inert until a user adds one in router.json; a Claude Code turn starts near 100k tokens.
55
+ cashCapUsd: 2,
60
56
  },
61
57
  jev: { endpoint: 'https://api.typesafe.ai/v1/systemone', model: 'jev-1.13.0', timeoutMs: 1500 },
62
58
  context: { recentTurns: 6, maxTextChars: 1200 },
package/lib/gateway.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  import { createServer, request as httpRequest } from 'node:http';
4
4
  import { request as httpsRequest } from 'node:https';
5
5
  import { UsageReader } from './sse.mjs';
6
+ import { ROUTER_DISPLAY_NAME, STATUS_PATH, statusSnapshot } from './status.mjs';
6
7
 
7
8
  const HOP_BY_HOP = new Set(['host', 'connection', 'content-length', 'accept-encoding', 'transfer-encoding']);
8
9
 
@@ -12,6 +13,7 @@ export function createGateway({ router, upstream = 'https://api.anthropic.com',
12
13
 
13
14
  return createServer((req, res) => {
14
15
  if (req.method === 'GET' && req.url.startsWith('/v1/models')) return discovery(router, res);
16
+ if (req.method === 'GET' && req.url.startsWith(STATUS_PATH)) return status(router, req, res);
15
17
  const chunks = [];
16
18
  req.on('data', (c) => chunks.push(c));
17
19
  req.on('end', async () => {
@@ -83,10 +85,22 @@ function discovery(router, res) {
83
85
  data: [
84
86
  {
85
87
  id: alias,
86
- display_name: 'Model Router',
87
- description: 'A tier for each turn, selected with TypeSafe Jev',
88
+ display_name: ROUTER_DISPLAY_NAME,
89
+ description: routesDescription(router.config),
88
90
  },
89
91
  ],
90
92
  }),
91
93
  );
92
94
  }
95
+
96
+ function status(router, req, res) {
97
+ const session = new URL(req.url, 'http://localhost').searchParams.get('session');
98
+ res.writeHead(200, { 'content-type': 'application/json' });
99
+ res.end(JSON.stringify(statusSnapshot(router.config, session ? router.memory(session) : null)));
100
+ }
101
+
102
+ // "Picks claude-opus-5-5 / claude-sonnet-4-6 / claude-haiku-4-5 and the effort for each turn"
103
+ export function routesDescription(config) {
104
+ const ids = [...new Set(Object.values(config.routes).map((r) => config.models[r.model].id))];
105
+ return `Picks ${ids.join(' / ')} and the effort for each turn`;
106
+ }
package/lib/rewrite.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  // Turn a request for the router alias into a request for a concrete model. Only `model`,
2
- // `output_config.effort` and `thinking` change; system, tools and messages are never touched.
2
+ // `output_config.effort`, `thinking` and thinking edits in `context_management` change;
3
+ // system, tools and messages are never touched.
3
4
  import { EFFORTS } from './config.mjs';
4
5
 
5
6
  export function rewriteRequest(body, tier, config) {
@@ -21,8 +22,15 @@ export function clampEffort(wanted, supported) {
21
22
  }
22
23
 
23
24
  // A family with no effort control has no adaptive thinking either: omit both and run without thinking.
25
+ // The API rejects a clear_thinking edit without thinking, so drop those edits too.
24
26
  function withoutThinking(out, outputConfig) {
25
27
  delete out.thinking;
28
+ const edits = out.context_management?.edits;
29
+ if (edits) {
30
+ const kept = edits.filter((e) => !e.type?.startsWith('clear_thinking'));
31
+ if (kept.length) out.context_management = { ...out.context_management, edits: kept };
32
+ else delete out.context_management;
33
+ }
26
34
  if (!outputConfig) return out;
27
35
  const { effort: _dropped, ...rest } = outputConfig;
28
36
  if (Object.keys(rest).length) out.output_config = rest;
package/lib/router.mjs CHANGED
@@ -8,7 +8,7 @@ import { appendLog, loadMemory, saveMemory } from './store.mjs';
8
8
  const COMPACTION_SHRINK = 0.8;
9
9
 
10
10
  export function emptyMemory() {
11
- return { lastRoute: null, lastRequest: null, models: {}, state: null };
11
+ return { lastRoute: null, lastReason: null, lastEffort: null, lastRequest: null, models: {}, state: null };
12
12
  }
13
13
 
14
14
  export class Router {
@@ -35,8 +35,12 @@ export class Router {
35
35
  else if (facts.continuation && memory.lastRoute)
36
36
  decision = { tier: memory.lastRoute, reason: 'tool-continuation', state: memory.state };
37
37
  else decision = await this.decideTurn(facts, memory);
38
+ const rewritten = rewriteRequest(body, decision.tier, this.config);
38
39
  if (!auxiliary) {
39
40
  memory.lastRoute = decision.tier;
41
+ memory.lastEffort = rewritten.output_config?.effort ?? null;
42
+ // A tool continuation keeps the reason of the decision that started the turn.
43
+ if (decision.reason !== 'tool-continuation') memory.lastReason = decision.reason;
40
44
  memory.state = decision.state;
41
45
  this.persist(sessionId, memory);
42
46
  }
@@ -51,7 +55,7 @@ export class Router {
51
55
  contextTokens: memory.lastRequest?.tokens ?? 0,
52
56
  });
53
57
  return {
54
- body: rewriteRequest(body, decision.tier, this.config),
58
+ body: rewritten,
55
59
  tier: decision.tier,
56
60
  reason: decision.reason,
57
61
  auxiliary,
package/lib/status.mjs ADDED
@@ -0,0 +1,90 @@
1
+ // Router status: the snapshot the gateway serves at GET /router/status, and its text forms for the
2
+ // status line and /router:status. The snapshot never carries the API key, only whether one is set.
3
+ import { TIERS } from './config.mjs';
4
+ import { clampEffort } from './rewrite.mjs';
5
+
6
+ export const STATUS_PATH = '/router/status';
7
+ // The name of the alias in the /model picker and in discovery.
8
+ export const ROUTER_DISPLAY_NAME = 'Router (auto)';
9
+ const FETCH_TIMEOUT_MS = 300;
10
+
11
+ export function statusSnapshot(config, memory) {
12
+ const { alias, port, baselineTier } = config.gateway;
13
+ return {
14
+ alias,
15
+ port,
16
+ keySet: Boolean(config.apiKey),
17
+ forcedTier: config.forcedTier ?? null,
18
+ baselineTier,
19
+ routes: TIERS.map((tier) => routeRow(config, tier)),
20
+ session: memory?.lastRoute
21
+ ? {
22
+ tier: memory.lastRoute,
23
+ reason: memory.lastReason ?? null,
24
+ effort: memory.lastEffort ?? null,
25
+ model: memory.lastRequest?.model ?? null,
26
+ tokens: memory.lastRequest?.tokens ?? null,
27
+ cacheReadTokens: memory.lastRequest?.cacheReadTokens ?? null,
28
+ at: memory.lastRequest?.at ?? null,
29
+ }
30
+ : null,
31
+ };
32
+ }
33
+
34
+ function routeRow(config, tier) {
35
+ const route = config.routes[tier];
36
+ const model = config.models[route.model];
37
+ const effort = model.efforts.length === 0 ? 'none' : (clampEffort(route.effort, model.efforts) ?? 'as sent');
38
+ return { tier, model: model.id, effort };
39
+ }
40
+
41
+ // One status-line segment, e.g. "router ▸ opus-5-5 · xhigh (high)".
42
+ export function statusSegment(status) {
43
+ if (!status) return 'router: gateway down';
44
+ const last = status.session;
45
+ if (!last) return `${status.alias}: no turn yet`;
46
+ const model = shortModel(last.model ?? status.routes.find((r) => r.tier === last.tier)?.model);
47
+ const effort = last.effort ? ` · ${last.effort}` : '';
48
+ return `${status.alias} ▸ ${model}${effort} (${last.tier})`;
49
+ }
50
+
51
+ // Markdown for /router:status.
52
+ export function statusReport(status) {
53
+ if (!status) return 'The router gateway does not answer. Start a new session, or run `node scripts/gateway.mjs`.';
54
+ const lines = [
55
+ `Gateway: http://127.0.0.1:${status.port}, alias \`${status.alias}\`.`,
56
+ `Jev key: ${status.keySet ? 'set' : 'not set, every turn runs on the baseline tier'}.`,
57
+ `Baseline tier: ${status.baselineTier}.${status.forcedTier ? ` Forced tier: ${status.forcedTier}.` : ''}`,
58
+ '',
59
+ '| Tier | Model | Effort |',
60
+ '| ---- | ----- | ------ |',
61
+ ...status.routes.map((r) => `| ${r.tier} | ${r.model} | ${r.effort} |`),
62
+ '',
63
+ ];
64
+ const last = status.session;
65
+ if (!last) lines.push('No routed turn in this session yet.');
66
+ else {
67
+ const model = last.model ?? status.routes.find((r) => r.tier === last.tier)?.model;
68
+ const effort = last.effort ? ` at ${last.effort}` : '';
69
+ const context = last.tokens ? `, context ${last.tokens} tokens, cache reads ${last.cacheReadTokens}` : '';
70
+ lines.push(`Last turn: ${last.tier} → ${model}${effort}, reason ${last.reason ?? 'unknown'}${context}.`);
71
+ }
72
+ return lines.join('\n');
73
+ }
74
+
75
+ // null when the gateway does not answer in time.
76
+ export async function fetchStatus(port, sessionId) {
77
+ const query = sessionId ? `?session=${encodeURIComponent(sessionId)}` : '';
78
+ try {
79
+ const res = await fetch(`http://127.0.0.1:${port}${STATUS_PATH}${query}`, {
80
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
81
+ });
82
+ return res.ok ? await res.json() : null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ function shortModel(id) {
89
+ return id ? id.replace(/^claude-/, '') : 'unknown';
90
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexeiled/claude-router",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Claude Code plugin: a local gateway that selects a model and an effort level for each user turn with a TypeSafe Jev Choice.",
5
5
  "license": "MIT",
6
6
  "author": "Alexei Ledenev",
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env bash
2
+ # Staged-only checks: Biome on staged .mjs/.json, Gitleaks on the staged diff.
3
+ # Full check/test/pack runs in pre-push.
4
+ set -euo pipefail
5
+
6
+ cd "$(git rev-parse --show-toplevel)"
7
+
8
+ biome=node_modules/.bin/biome
9
+ [ -x "$biome" ] || {
10
+ echo "pre-commit: $biome missing; run 'npm ci'" >&2
11
+ exit 1
12
+ }
13
+ command -v gitleaks >/dev/null || {
14
+ echo "pre-commit: gitleaks missing; run 'brew install gitleaks'" >&2
15
+ exit 1
16
+ }
17
+
18
+ files=()
19
+ while IFS= read -r -d '' f; do
20
+ case "$f" in *.mjs | *.json) files+=("$f") ;; esac
21
+ done < <(git diff --cached --name-only -z --diff-filter=ACMR)
22
+
23
+ if [ "${#files[@]}" -gt 0 ]; then
24
+ "$biome" check --no-errors-on-unmatched -- "${files[@]}"
25
+ fi
26
+
27
+ gitleaks git --pre-commit --staged --redact --verbose --no-banner
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env bash
2
+ # CI-equivalent validation before code leaves the machine (mirrors .github/workflows/ci.yml).
3
+ set -euo pipefail
4
+
5
+ cd "$(git rev-parse --show-toplevel)"
6
+ zero=0000000000000000000000000000000000000000
7
+
8
+ [ -x node_modules/.bin/biome ] || {
9
+ echo "pre-push: node_modules/.bin/biome missing; run 'npm ci'" >&2
10
+ exit 1
11
+ }
12
+ command -v gitleaks >/dev/null || {
13
+ echo "pre-push: gitleaks missing; run 'brew install gitleaks'" >&2
14
+ exit 1
15
+ }
16
+
17
+ ranges=()
18
+ while read -r _local_ref local_sha _remote_ref remote_sha; do
19
+ [ "$local_sha" = "$zero" ] && continue
20
+ if [ "$remote_sha" = "$zero" ]; then
21
+ ranges+=("$local_sha") # new remote branch: scan all reachable history
22
+ else
23
+ ranges+=("$remote_sha..$local_sha")
24
+ fi
25
+ done
26
+
27
+ [ "${#ranges[@]}" -eq 0 ] && exit 0 # deletion-only push
28
+
29
+ npm run check
30
+ npm test
31
+ npm run pack:dry
32
+ for range in "${ranges[@]}"; do
33
+ gitleaks git --redact --no-banner --log-opts="$range"
34
+ done
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ // /router:status: routes, key state and the last routed turn of a session, as markdown.
3
+ import { loadRuntime } from '../lib/runtime.mjs';
4
+ import { fetchStatus, statusReport } from '../lib/status.mjs';
5
+
6
+ const { config } = loadRuntime(process.env);
7
+ process.stdout.write(`${statusReport(await fetchStatus(config.gateway.port, process.argv[2]))}\n`);
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ // Status line: prints the model and effort that served the last routed turn.
3
+ // Usage in settings.json: "statusLine": { "command": "node <plugin>/scripts/statusline.mjs [wrapped command ...]" }.
4
+ // A wrapped command (for example claude-powerline) gets the same stdin; its output comes first.
5
+ import { spawn } from 'node:child_process';
6
+ import { text } from 'node:stream/consumers';
7
+ import { loadRuntime } from '../lib/runtime.mjs';
8
+ import { fetchStatus, ROUTER_DISPLAY_NAME, statusSegment } from '../lib/status.mjs';
9
+
10
+ const input = await text(process.stdin);
11
+ const [command, ...args] = process.argv.slice(2);
12
+ const { config } = loadRuntime(process.env);
13
+ let payload = {};
14
+ try {
15
+ payload = JSON.parse(input);
16
+ } catch {}
17
+
18
+ const names = [config.gateway.alias, ROUTER_DISPLAY_NAME];
19
+ const routed = [payload.model?.id, payload.model?.display_name].some((name) => names.includes(name));
20
+ const [status] = await Promise.all([
21
+ routed ? fetchStatus(config.gateway.port, payload.session_id) : null,
22
+ command ? runWrapped(command, args, input) : null,
23
+ ]);
24
+ if (routed) process.stdout.write(`${statusSegment(status)}\n`);
25
+
26
+ function runWrapped(cmd, argv, stdin) {
27
+ return new Promise((resolve) => {
28
+ const child = spawn(cmd, argv, { stdio: ['pipe', 'inherit', 'inherit'] });
29
+ child.on('error', (error) => {
30
+ process.stdout.write(`statusline: ${cmd}: ${error.message}\n`);
31
+ resolve();
32
+ });
33
+ child.on('close', resolve);
34
+ child.stdin.on('error', () => {}); // the wrapped command may exit without reading stdin
35
+ child.stdin.end(stdin);
36
+ });
37
+ }
@@ -1,8 +1,9 @@
1
1
  ---
2
2
  name: high
3
3
  disable-model-invocation: true
4
- description: Manual pin: run this turn on fable at xhigh effort. Use as /router:high <prompt>.
5
- model: fable
4
+ description: Manual pin: run this turn on opus at xhigh effort. Use as /router:high <prompt>.
5
+ model: opus
6
6
  effort: xhigh
7
7
  ---
8
+
8
9
  Routing tier applied. Continue with the user's request as written. Do not mention the router.
@@ -1,15 +1,24 @@
1
1
  ---
2
2
  name: setup
3
- description: Point Claude Code at the router gateway. Adds `model` and `ANTHROPIC_BASE_URL` to the user settings.
3
+ description: Point Claude Code at the router gateway. Adds `model`, `ANTHROPIC_BASE_URL` 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
  ---
7
+
7
8
  Configure Claude Code for the router gateway. Do these steps:
8
9
 
9
10
  1. Read `~/.claude/settings.json`. If the file does not exist, start from `{}`.
10
11
  2. Set the key `model` to `"router"`.
11
12
  3. Set the key `env.ANTHROPIC_BASE_URL` to `"http://127.0.0.1:43170"`. If `~/.claude/router.json` sets `gateway.port`, use that port.
12
- 4. Keep every other key unchanged. Write the file.
13
- 5. Tell the user: restart Claude Code, then the router serves each turn. To stop the routing, remove the two keys.
13
+ 4. Set these keys in `env`, for the `/model` picker row:
14
+ - `ANTHROPIC_CUSTOM_MODEL_OPTION`: `"router"`
15
+ - `ANTHROPIC_CUSTOM_MODEL_OPTION_NAME`: `"Router (auto)"`
16
+ - `ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION`: `"Picks Opus 5.5 / Sonnet 4.6 / Haiku 4.5 and the effort for each turn"`
17
+ 5. Keep every other key unchanged. Write the file.
18
+ 6. The status line can show the model and effort of the last routed turn. The command is `node ${CLAUDE_PLUGIN_ROOT}/scripts/statusline.mjs`, followed by the current status line command if there is one (for example `node ${CLAUDE_PLUGIN_ROOT}/scripts/statusline.mjs claude-powerline`). Show the user the current `statusLine` value and the new one, and ask. Change `statusLine.command` only if the user agrees. Keep the other `statusLine` keys.
19
+ 7. Tell the user:
20
+ - Restart Claude Code. Then the router serves each turn, and `/router:status` shows the routes and the last turn.
21
+ - The status line path contains the plugin version. After a plugin update, run `/router:setup` again.
22
+ - To stop the routing, remove `model`, `env.ANTHROPIC_BASE_URL` and the three `ANTHROPIC_CUSTOM_MODEL_OPTION*` keys, and restore the status line command.
14
23
 
15
24
  Do not change any other file.
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: status
3
+ description: Show the router routes, the Jev key state, and the model and effort of the last routed turn.
4
+ disable-model-invocation: true
5
+ allowed-tools: Bash(node ${CLAUDE_PLUGIN_ROOT}/scripts/status.mjs *)
6
+ ---
7
+ Router status:
8
+
9
+ !`node ${CLAUDE_PLUGIN_ROOT}/scripts/status.mjs ${CLAUDE_SESSION_ID}`
10
+
11
+ Show this status to the user as written. Do not add commentary.