@alexeiled/claude-router 0.1.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -4
- package/docs/configuration.md +71 -31
- package/docs/design.md +45 -26
- package/docs/user-guide.md +23 -7
- package/lib/config.mjs +10 -14
- package/lib/gateway.mjs +16 -2
- package/lib/rewrite.mjs +9 -1
- package/lib/router.mjs +6 -2
- package/lib/status.mjs +90 -0
- package/package.json +1 -1
- package/scripts/status.mjs +7 -0
- package/scripts/statusline.mjs +37 -0
- package/skills/high/SKILL.md +3 -2
- package/skills/setup/SKILL.md +12 -3
- package/skills/status/SKILL.md +11 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "router",
|
|
3
3
|
"displayName": "Router",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.2",
|
|
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` (
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
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
|
package/docs/configuration.md
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## Where each value is
|
|
4
4
|
|
|
5
|
-
| Value
|
|
6
|
-
|
|
|
7
|
-
| TypeSafe API key
|
|
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
|
|
10
|
-
| Routing configuration
|
|
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": {
|
|
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
|
-
|
|
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": {
|
|
68
|
+
"gateway": {
|
|
69
|
+
"port": 43170,
|
|
70
|
+
"alias": "router",
|
|
71
|
+
"baselineTier": "low",
|
|
72
|
+
"auxiliaryTier": "low"
|
|
73
|
+
},
|
|
47
74
|
"routes": {
|
|
48
|
-
"high":
|
|
49
|
-
"medium": { "model": "opus",
|
|
50
|
-
"low":
|
|
51
|
-
"micro":
|
|
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": {
|
|
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
|
|
94
|
-
|
|
|
95
|
-
| `gateway.baselineTier`
|
|
96
|
-
| `gateway.auxiliaryTier`
|
|
97
|
-
| `upgradeVotes`
|
|
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`
|
|
100
|
-
| `downgradeVotes`, `downgradeMass`
|
|
101
|
-
| `continuationMass`
|
|
102
|
-
| `escalationHoldTurns`
|
|
103
|
-
| `cashCapUsd`
|
|
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
|
|
108
|
-
|
|
|
109
|
-
| `TYPESAFE_API_KEY`
|
|
110
|
-
| `ROUTER_CONFIG`
|
|
111
|
-
| `ROUTER_FORCE_TIER`
|
|
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
|
-
|
|
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
|
|
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
|
|
53
|
-
| ------ | ------ |
|
|
54
|
-
| high |
|
|
55
|
-
| medium | opus | high
|
|
56
|
-
| low | sonnet | as sent
|
|
57
|
-
| micro | haiku | none
|
|
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
|
|
81
|
-
|
|
|
82
|
-
| Context of the last request, cache reads, output | `usage` in the response (`message_start` and `message_delta`)
|
|
83
|
-
| Granted TTL
|
|
84
|
-
| Cache warmth of a model
|
|
85
|
-
| Reusable prefix of a model
|
|
86
|
-
| Failure signal
|
|
87
|
-
| Continuation
|
|
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.
|
|
99
|
-
|
|
100
|
-
cash, on the 5m TTL, and behind the gateway without the
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
|
@@ -146,10 +156,12 @@ Agreed with Codex on 2026-09-22. The thresholds are start values.
|
|
|
146
156
|
|
|
147
157
|
```
|
|
148
158
|
.claude-plugin/plugin.json userConfig.typesafe_api_key (Keychain)
|
|
149
|
-
.claude-plugin/marketplace.json
|
|
159
|
+
.claude-plugin/marketplace.json github source alexei-led/claude-router
|
|
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
|
|
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
|
|
|
@@ -182,7 +196,12 @@ Agreed with Codex on 2026-09-22. The thresholds are start values.
|
|
|
182
196
|
|
|
183
197
|
The repository root is the plugin and the npm package `@alexeiled/claude-router`.
|
|
184
198
|
The marketplace `alexei-led-claude-router` in `.claude-plugin/marketplace.json`
|
|
185
|
-
points at
|
|
199
|
+
points at this GitHub repository (`"source": "github"`), so Claude Code installs the
|
|
200
|
+
plugin from git and never calls npm. An npm source
|
|
201
|
+
fails under npm 12: Claude Code fetches the tarball URL, and npm 12 refuses
|
|
202
|
+
remote tarballs by default (`EALLOWREMOTE`). `claude plugin update` compares
|
|
203
|
+
the `version` in `.claude-plugin/plugin.json`, so each release bumps it. Local
|
|
204
|
+
development uses `claude --plugin-dir .`.
|
|
186
205
|
|
|
187
206
|
A release is a signed annotated tag `v<version>` on `main`, where the version
|
|
188
207
|
matches `package.json`. The `release.yml` workflow makes sure that the tag is
|
package/docs/user-guide.md
CHANGED
|
@@ -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.
|
|
6
|
-
|
|
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
|
|
32
|
-
`policy.cashCapUsd`. Then
|
|
33
|
-
Behind the gateway, Claude Code does not show its consent
|
|
34
|
-
credits
|
|
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
|
|
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
|
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: '
|
|
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
|
|
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:
|
|
29
|
-
cacheRead: 0.
|
|
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
|
-
|
|
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:
|
|
87
|
-
description:
|
|
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
|
|
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:
|
|
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.
|
|
3
|
+
"version": "0.2.2",
|
|
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,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
|
+
}
|
package/skills/high/SKILL.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: high
|
|
3
3
|
disable-model-invocation: true
|
|
4
|
-
description: Manual pin: run this turn on
|
|
5
|
-
model:
|
|
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.
|
package/skills/setup/SKILL.md
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: setup
|
|
3
|
-
description: Point Claude Code at the router gateway. Adds `model` and `
|
|
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.
|
|
13
|
-
|
|
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.
|