@alexeiled/claude-router 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +18 -0
- package/LICENSE +21 -0
- package/README.md +76 -0
- package/docs/configuration.md +112 -0
- package/docs/design.md +192 -0
- package/docs/user-guide.md +87 -0
- package/hooks/hooks.json +16 -0
- package/lib/config.mjs +129 -0
- package/lib/cost.mjs +39 -0
- package/lib/facts.mjs +66 -0
- package/lib/gateway.mjs +92 -0
- package/lib/jev.mjs +84 -0
- package/lib/policy.mjs +88 -0
- package/lib/rewrite.mjs +31 -0
- package/lib/router.mjs +136 -0
- package/lib/runtime.mjs +13 -0
- package/lib/sse.mjs +52 -0
- package/lib/store.mjs +36 -0
- package/package.json +39 -0
- package/scripts/ensure-gateway.mjs +55 -0
- package/scripts/gateway.mjs +12 -0
- package/scripts/transcript-models.sh +4 -0
- package/skills/high/SKILL.md +8 -0
- package/skills/low/SKILL.md +7 -0
- package/skills/medium/SKILL.md +8 -0
- package/skills/micro/SKILL.md +7 -0
- package/skills/setup/SKILL.md +15 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "router",
|
|
3
|
+
"displayName": "Router",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Local gateway that selects a model and an effort level for each user turn with a TypeSafe Jev Choice.",
|
|
6
|
+
"author": { "name": "Alexei Ledenev", "url": "https://github.com/alexei-led" },
|
|
7
|
+
"repository": "https://github.com/alexei-led/claude-router",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"keywords": ["router", "jev", "typesafe", "effort", "gateway"],
|
|
10
|
+
"userConfig": {
|
|
11
|
+
"typesafe_api_key": {
|
|
12
|
+
"type": "string",
|
|
13
|
+
"title": "TypeSafe API key",
|
|
14
|
+
"description": "Jev key for the routing decision. Stored in the Keychain. Leave empty to run on the baseline tier.",
|
|
15
|
+
"sensitive": true
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alexei Ledenev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# claude-router
|
|
2
|
+
|
|
3
|
+
A Claude Code plugin that selects a model and an effort level for each user turn.
|
|
4
|
+
|
|
5
|
+
A local gateway on `127.0.0.1` receives each request from Claude Code. For a
|
|
6
|
+
new user turn, the gateway asks TypeSafe Jev which tier the turn needs. Then
|
|
7
|
+
the gateway changes `model`, `effort` and `thinking` in the request and sends
|
|
8
|
+
it to Anthropic. All other data goes through unchanged. The gateway has no
|
|
9
|
+
runtime dependencies and needs Node 22 or later.
|
|
10
|
+
|
|
11
|
+
## How it works
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
Claude Code --model router ──▶ gateway 127.0.0.1:43170 ──▶ api.anthropic.com
|
|
15
|
+
│
|
|
16
|
+
only requests for `router`: ├─ facts.mjs prompt, continuation, failures
|
|
17
|
+
├─ jev.mjs one Choice (tier) + one Noul (continuation?)
|
|
18
|
+
├─ policy.mjs stickiness, escalation, cost-gated votes
|
|
19
|
+
├─ rewrite.mjs model, effort, thinking per model family
|
|
20
|
+
└─ store.mjs session memory, decisions.jsonl
|
|
21
|
+
responses go through unchanged; the gateway reads `usage` (context size, cache TTL)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The tiers are `micro` (haiku), `low` (sonnet, the baseline), `medium` (opus at
|
|
25
|
+
high effort) and `high` (fable at xhigh effort).
|
|
26
|
+
|
|
27
|
+
A tool continuation is a request whose last message is a `tool_result`. It
|
|
28
|
+
keeps the route of the turn, and the gateway does not ask Jev. Side requests,
|
|
29
|
+
for example session titles, get the baseline tier. A request for any other
|
|
30
|
+
model goes through unchanged. This is how `/router:<tier>` pins and subagents
|
|
31
|
+
with their own `model` work.
|
|
32
|
+
|
|
33
|
+
Module dependencies point in one direction: `gateway.mjs` (HTTP) →
|
|
34
|
+
`router.mjs` (orchestration) → `facts`, `jev`, `policy` → `cost`, `rewrite`,
|
|
35
|
+
`store`. The modules `facts`, `cost`, `rewrite`, `sse` and `policy` are pure.
|
|
36
|
+
Only `store` writes files. The Jev transport is injected.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
1. Add the marketplace and install the plugin:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
claude plugin marketplace add alexei-led/claude-router
|
|
44
|
+
claude plugin install router@alexei-led-claude-router
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
2. When Claude Code asks, enter the TypeSafe API key. The key goes to the
|
|
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.
|
|
52
|
+
|
|
53
|
+
The `SessionStart` hook of the plugin starts the gateway when the port does not
|
|
54
|
+
answer. A claude.ai login continues to work: the gateway sends the
|
|
55
|
+
authorization header and the OAuth value of `anthropic-beta` unchanged.
|
|
56
|
+
|
|
57
|
+
## Documentation
|
|
58
|
+
|
|
59
|
+
- [User guide](docs/user-guide.md): daily use, pins, decision log, troubleshooting.
|
|
60
|
+
- [Configuration](docs/configuration.md): each key, and where the API key and the configuration file are.
|
|
61
|
+
- [Design](docs/design.md): decisions, the switching policy, test results.
|
|
62
|
+
|
|
63
|
+
## Develop
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
npm install
|
|
67
|
+
npm test # node:test
|
|
68
|
+
npm run check # biome lint and format
|
|
69
|
+
npm run validate # claude plugin validate
|
|
70
|
+
claude --plugin-dir . --model router # with ANTHROPIC_BASE_URL and TYPESAFE_API_KEY set
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Releases: push a signed tag `v<version>` that matches `package.json`. The
|
|
74
|
+
release workflow publishes `@alexeiled/claude-router` to npm with trusted
|
|
75
|
+
publishing and creates the GitHub release. See
|
|
76
|
+
[docs/design.md](docs/design.md#release).
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Configuration
|
|
2
|
+
|
|
3
|
+
## Where each value is
|
|
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. |
|
|
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. |
|
|
11
|
+
|
|
12
|
+
The gateway ignores project files. To use another file, set
|
|
13
|
+
`ROUTER_CONFIG=/path/to/file.json`. The gateway reads the file at start. After
|
|
14
|
+
an edit, stop the gateway with `pkill -f scripts/gateway.mjs`. The next session
|
|
15
|
+
starts it again.
|
|
16
|
+
|
|
17
|
+
## Claude Code settings
|
|
18
|
+
|
|
19
|
+
`/router:setup` writes this to `~/.claude/settings.json`:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"model": "router",
|
|
24
|
+
"env": { "ANTHROPIC_BASE_URL": "http://127.0.0.1:43170" }
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Two optional keys in `env`:
|
|
29
|
+
|
|
30
|
+
- `CLAUDE_CODE_GATEWAY_HINT_HEADERS: "1"`. Claude Code then tells the gateway
|
|
31
|
+
the class of each request. Requests of the class `main` get routing. All
|
|
32
|
+
other classes (`auxiliary`, `subagent`, `workflow`, `compaction`) get
|
|
33
|
+
`gateway.auxiliaryTier`. A subagent with `model: inherit` runs on that tier.
|
|
34
|
+
Without the header, the gateway identifies side requests by their shape, and
|
|
35
|
+
subagents get routing like the main conversation.
|
|
36
|
+
- `ANTHROPIC_CUSTOM_MODEL_OPTION: "router"`. The `/model` picker then shows a
|
|
37
|
+
`router` row.
|
|
38
|
+
|
|
39
|
+
## Configuration file
|
|
40
|
+
|
|
41
|
+
Each key is optional. A key in the file replaces the default with the same
|
|
42
|
+
path. Nested objects merge.
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"gateway": { "port": 43170, "alias": "router", "baselineTier": "low", "auxiliaryTier": "low" },
|
|
47
|
+
"routes": {
|
|
48
|
+
"high": { "model": "fable", "effort": "xhigh" },
|
|
49
|
+
"medium": { "model": "opus", "effort": "high" },
|
|
50
|
+
"low": { "model": "sonnet" },
|
|
51
|
+
"micro": { "model": "haiku" }
|
|
52
|
+
},
|
|
53
|
+
"models": {
|
|
54
|
+
"sonnet": { "id": "claude-sonnet-4-6", "input": 3, "cacheRead": 0.3, "contextWindow": 1000000, "billing": "plan", "efforts": ["low", "medium", "high", "max"] }
|
|
55
|
+
},
|
|
56
|
+
"policy": {
|
|
57
|
+
"upgradeVotes": 2,
|
|
58
|
+
"upgradeBase": 0.75,
|
|
59
|
+
"upgradeSlope": 0.15,
|
|
60
|
+
"upgradePivotUsd": 0.5,
|
|
61
|
+
"jumpConfidence": 0.95,
|
|
62
|
+
"downgradeVotes": 2,
|
|
63
|
+
"downgradeMass": 0.9,
|
|
64
|
+
"continuationMass": 0.7,
|
|
65
|
+
"escalationHoldTurns": 2,
|
|
66
|
+
"cashCapUsd": 2
|
|
67
|
+
},
|
|
68
|
+
"jev": { "model": "jev-1.13.0", "timeoutMs": 1500 },
|
|
69
|
+
"context": { "recentTurns": 6, "maxTextChars": 1200 },
|
|
70
|
+
"log": true
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### routes
|
|
75
|
+
|
|
76
|
+
`routes.<tier>.model` is a key of `models`. `effort` is one of `low`, `medium`,
|
|
77
|
+
`high`, `xhigh`, `max`, or absent. When `effort` is absent, the gateway keeps
|
|
78
|
+
the effort that Claude Code sent. If you change a route, also change the
|
|
79
|
+
frontmatter of `skills/<tier>/SKILL.md`. A test makes sure that they agree.
|
|
80
|
+
|
|
81
|
+
### models
|
|
82
|
+
|
|
83
|
+
`id` is the model id that the gateway sends to Anthropic. `input` and
|
|
84
|
+
`cacheRead` are list prices in USD per million tokens. `contextWindow` is the
|
|
85
|
+
size of the context window in tokens. `billing` is `plan` for models that use
|
|
86
|
+
the subscription limits, or `credits` for models that bill usage credits.
|
|
87
|
+
`policy.cashCapUsd` applies to `credits` models only. `efforts` lists the
|
|
88
|
+
levels that the model accepts. An empty list means that the gateway removes
|
|
89
|
+
effort and thinking from the request.
|
|
90
|
+
|
|
91
|
+
### policy
|
|
92
|
+
|
|
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. |
|
|
98
|
+
| `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. |
|
|
104
|
+
|
|
105
|
+
## Environment variables
|
|
106
|
+
|
|
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`. |
|
package/docs/design.md
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# Design record
|
|
2
|
+
|
|
3
|
+
Each decision has the date when the owner took it.
|
|
4
|
+
|
|
5
|
+
## Goal
|
|
6
|
+
|
|
7
|
+
Select a model and an effort level for each user turn, from the models that
|
|
8
|
+
Claude Code offers, with one TypeSafe Jev Choice. Stay inside the Claude Code
|
|
9
|
+
terminal interface.
|
|
10
|
+
|
|
11
|
+
## Mechanism: local gateway (2026-09-22)
|
|
12
|
+
|
|
13
|
+
Claude Code runs with `--model router`. `ANTHROPIC_BASE_URL` points at a
|
|
14
|
+
gateway on `127.0.0.1`. The gateway sends each request to `api.anthropic.com`
|
|
15
|
+
unchanged, except a request whose `model` is the alias. For that request, the
|
|
16
|
+
gateway reads facts from the body, asks Jev once for each new user turn, runs
|
|
17
|
+
the policy, and changes `model`, `output_config.effort` and `thinking`.
|
|
18
|
+
Responses go through unchanged. The gateway reads `usage` from the response to
|
|
19
|
+
get the context size, the cache reads and the cache TTL.
|
|
20
|
+
|
|
21
|
+
The gateway never changes `system`, `tools` or `messages`. Thus preserved
|
|
22
|
+
thinking and prompt caching work as if Claude Code talked to Anthropic. Claude
|
|
23
|
+
Code documents this gateway mode, including the OAuth value for a claude.ai
|
|
24
|
+
login. See [llm-gateway](https://code.claude.com/docs/en/llm-gateway) and
|
|
25
|
+
[protocol](https://code.claude.com/docs/en/llm-gateway-protocol).
|
|
26
|
+
|
|
27
|
+
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.
|
|
30
|
+
|
|
31
|
+
### Why not the native skill path
|
|
32
|
+
|
|
33
|
+
The first design used a `UserPromptSubmit` hook. The hook asked Claude to call
|
|
34
|
+
a tier skill. The frontmatter `model:` and `effort:` of the skill were to serve
|
|
35
|
+
the rest of the turn.
|
|
36
|
+
|
|
37
|
+
The test on Claude Code 2.1.278 gave this result. A skill that the user types
|
|
38
|
+
(`/router:medium …`) changes the model for the turn. The same skill,
|
|
39
|
+
called by Claude through the Skill tool, does not change the model. The
|
|
40
|
+
transcript records `attributionSkill`, but the session model answers. The test
|
|
41
|
+
ran in three sessions, in auto mode and in `acceptEdits` mode, with Opus and
|
|
42
|
+
Fable targets. The documentation says "when this skill is active". The
|
|
43
|
+
behavior is user invocation only. A feedback report is filed. The skills stay
|
|
44
|
+
as manual pins.
|
|
45
|
+
|
|
46
|
+
This result also removed the "Sonnet session model" argument from the peer
|
|
47
|
+
review. The gateway has no bootstrap request and no cache drop for a turn that
|
|
48
|
+
keeps its route. The baseline is a configuration value.
|
|
49
|
+
|
|
50
|
+
## Tiers
|
|
51
|
+
|
|
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 |
|
|
58
|
+
|
|
59
|
+
The gateway lowers the effort to a level that the model family accepts. Sonnet
|
|
60
|
+
4.6 has no `xhigh`. Haiku gets no effort and no adaptive thinking. The ids are
|
|
61
|
+
configuration. `sonnet` is 4.6 because the alias resolved to 4.6 on the test
|
|
62
|
+
account.
|
|
63
|
+
|
|
64
|
+
## Request classes
|
|
65
|
+
|
|
66
|
+
- New user turn (the last message has no `tool_result`): Jev and the policy.
|
|
67
|
+
- Tool continuation: the route of the turn, without a Jev call.
|
|
68
|
+
- Every request class other than `main`, from the header
|
|
69
|
+
`x-claude-code-request-class`: `auxiliaryTier`, and the memory stays
|
|
70
|
+
unchanged. Without the header, a body with `thinking: disabled` and a
|
|
71
|
+
`format` is a side request.
|
|
72
|
+
- Any other `model`: unchanged. This covers `/router:<tier>` pins,
|
|
73
|
+
`/model` changes and subagents with their own model.
|
|
74
|
+
|
|
75
|
+
## Cache and cost inputs
|
|
76
|
+
|
|
77
|
+
All inputs come from the traffic of the gateway. The gateway does not read
|
|
78
|
+
transcripts.
|
|
79
|
+
|
|
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
|
+
|
|
89
|
+
Prices are a list-price table in the configuration. `modelPricing` is a
|
|
90
|
+
managed setting and is not readable. The switching tax for a candidate `c`
|
|
91
|
+
against the current route `i` is:
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
input_cost(m) = P_read(m) * W_m + P_write(m) * (N - W_m)
|
|
95
|
+
tax = max(0, input_cost(c) - input_cost(i))
|
|
96
|
+
```
|
|
97
|
+
|
|
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.
|
|
104
|
+
|
|
105
|
+
## Switching policy v0
|
|
106
|
+
|
|
107
|
+
Agreed with Codex on 2026-09-22. The thresholds are start values.
|
|
108
|
+
|
|
109
|
+
1. Pins win. A request for a real model id goes through unchanged.
|
|
110
|
+
2. To stay is a decision. The current route is the route that served the last
|
|
111
|
+
turn. A prompt that continues the task keeps it and never votes for a
|
|
112
|
+
downgrade.
|
|
113
|
+
3. Escalation floor. Two failed repairs of the same failure signature, with an
|
|
114
|
+
edit between them, move the route one tier up. The route holds for two
|
|
115
|
+
turns, once for each signature.
|
|
116
|
+
4. Votes keep the Jev probabilities. `U` is the mass above the current tier.
|
|
117
|
+
`D` is the mass at or below the candidate. The `uncertain` mass supports
|
|
118
|
+
neither. An upgrade needs two consecutive votes above the current tier and
|
|
119
|
+
`U >= 0.75 + 0.15 * tax / (tax + 0.5)`. A jump of two tiers with
|
|
120
|
+
`U >= 0.95` skips the delay. A downgrade needs `D >= 0.90` and two
|
|
121
|
+
consecutive votes.
|
|
122
|
+
5. Cash gate. An automatic route to a `credits` model needs a warm cache, or a
|
|
123
|
+
cold write below the cap. Otherwise the strongest `plan` tier serves.
|
|
124
|
+
6. No cooldown on upgrades. Plan, then execute, then hard again is sometimes
|
|
125
|
+
the correct routing. The log separates reversals from real changes in the
|
|
126
|
+
required capability.
|
|
127
|
+
7. Logs. The tier, the reason, the estimates, and the observed model, tokens
|
|
128
|
+
and cache reads of each routed response. The memory changes only from
|
|
129
|
+
responses that the gateway sent.
|
|
130
|
+
|
|
131
|
+
## Test results (2026-09-22, Max plan, Claude Code 2.1.278)
|
|
132
|
+
|
|
133
|
+
- Easy prompt: Jev gave `micro` at confidence 1. It was the first vote, so
|
|
134
|
+
`low` served (`claude-sonnet-4-6`, 103,799 context tokens on the first
|
|
135
|
+
request).
|
|
136
|
+
- Hard prompt: Jev gave `high` at 0.66. A cold Fable write cost $1.30 against
|
|
137
|
+
the cap of $0.50 at that time, so `medium` served (`claude-opus-5`). Four
|
|
138
|
+
tool continuations kept the route, with cache reads within 1% of the
|
|
139
|
+
context.
|
|
140
|
+
- Claude Code accepted `message_start.model` with the real model. The status
|
|
141
|
+
line showed the alias at all times.
|
|
142
|
+
- Cold start: the `SessionStart` hook started the gateway before the first
|
|
143
|
+
request.
|
|
144
|
+
|
|
145
|
+
## Layout
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
.claude-plugin/plugin.json userConfig.typesafe_api_key (Keychain)
|
|
149
|
+
.claude-plugin/marketplace.json npm source @alexeiled/claude-router
|
|
150
|
+
hooks/hooks.json SessionStart -> scripts/ensure-gateway.mjs
|
|
151
|
+
scripts/gateway.mjs daemon entry
|
|
152
|
+
scripts/ensure-gateway.mjs port probe, detached spawn
|
|
153
|
+
scripts/transcript-models.sh model for each assistant line of a transcript
|
|
154
|
+
lib/runtime.mjs configuration and data directory from the environment
|
|
155
|
+
lib/config.mjs defaults, user file, validation
|
|
156
|
+
lib/facts.mjs request body and memory -> facts (pure)
|
|
157
|
+
lib/jev.mjs request, injected transport, parse
|
|
158
|
+
lib/cost.mjs warmth, input cost, switching tax
|
|
159
|
+
lib/policy.mjs switching policy v0
|
|
160
|
+
lib/rewrite.mjs model, effort, thinking per model family
|
|
161
|
+
lib/sse.mjs usage reader for SSE and JSON bodies
|
|
162
|
+
lib/router.mjs orchestration for one request, session memory
|
|
163
|
+
lib/gateway.mjs HTTP passthrough and rewrite
|
|
164
|
+
lib/store.mjs files: configuration, memory, decisions.jsonl
|
|
165
|
+
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
|
|
167
|
+
test/ node:test, builders in helpers.mjs
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Open questions
|
|
171
|
+
|
|
172
|
+
- The exit from plan mode is not visible in the request body. The policy has
|
|
173
|
+
no boundary rule.
|
|
174
|
+
- The thresholds are not tuned. The estimated and the observed cache reads in
|
|
175
|
+
`decisions.jsonl` are the input for the tuning.
|
|
176
|
+
- The gateway reads the configuration once. A reload without a restart is not
|
|
177
|
+
implemented.
|
|
178
|
+
- Without `CLAUDE_CODE_GATEWAY_HINT_HEADERS=1`, subagents with `model: inherit`
|
|
179
|
+
get routing like the main conversation.
|
|
180
|
+
|
|
181
|
+
## Release
|
|
182
|
+
|
|
183
|
+
The repository root is the plugin and the npm package `@alexeiled/claude-router`.
|
|
184
|
+
The marketplace `alexei-led-claude-router` in `.claude-plugin/marketplace.json`
|
|
185
|
+
points at that package. Local development uses `claude --plugin-dir .`.
|
|
186
|
+
|
|
187
|
+
A release is a signed annotated tag `v<version>` on `main`, where the version
|
|
188
|
+
matches `package.json`. The `release.yml` workflow makes sure that the tag is
|
|
189
|
+
signed and on `main`, runs the checks and the tests, publishes to npm with
|
|
190
|
+
trusted publishing (`npm publish --provenance`, no token), and creates the
|
|
191
|
+
GitHub release. The `ci.yml` workflow runs the checks and the tests for each
|
|
192
|
+
push and pull request on `main`.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# User guide
|
|
2
|
+
|
|
3
|
+
## Start a session
|
|
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
|
|
7
|
+
each message. The first session starts the gateway. The gateway continues to
|
|
8
|
+
run after the session ends.
|
|
9
|
+
|
|
10
|
+
To try the gateway in one session without a change to the configuration:
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
TYPESAFE_API_KEY=… node scripts/gateway.mjs &
|
|
14
|
+
ANTHROPIC_BASE_URL=http://127.0.0.1:43170 claude --plugin-dir . --model router
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## What happens at each prompt
|
|
18
|
+
|
|
19
|
+
- A new prompt causes one Jev call. The call takes about one second and gives
|
|
20
|
+
a tier. Then the policy decides if it acts on the tier.
|
|
21
|
+
- Tool calls inside the turn keep the route. The gateway does not ask Jev, and
|
|
22
|
+
the cache of the model continues to hit.
|
|
23
|
+
- A prompt that continues the task keeps the route. Examples: "continue",
|
|
24
|
+
"yes", "now fix the tests".
|
|
25
|
+
- After two failed repairs of the same error, the route goes up one tier. The
|
|
26
|
+
route stays there for two turns. A repair is an edit between the two errors.
|
|
27
|
+
- An upgrade needs two consecutive votes for a higher tier. A jump of two
|
|
28
|
+
tiers with high confidence happens at once. The required confidence goes up
|
|
29
|
+
with the cost to read the context again on the new model.
|
|
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.
|
|
35
|
+
- When Jev fails or times out, or when there is no key, the baseline tier
|
|
36
|
+
serves the turn.
|
|
37
|
+
- Jev receives the prompt and the last six turns of text. Tool results are not
|
|
38
|
+
sent. No other data leaves the machine, except the usual Anthropic request.
|
|
39
|
+
|
|
40
|
+
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
|
+
|
|
43
|
+
## Pin a tier by hand
|
|
44
|
+
|
|
45
|
+
Type the tier skill as a command. The turn runs on the model of that skill.
|
|
46
|
+
The gateway sends the real model id unchanged.
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
/router:high redesign the auth flow
|
|
50
|
+
/router:micro rename foo to bar in this file
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`/model <name>` also works. It stops the routing for the rest of the session.
|
|
54
|
+
|
|
55
|
+
## Read the decisions
|
|
56
|
+
|
|
57
|
+
The gateway writes one line for each routed request to `decisions.jsonl` in
|
|
58
|
+
the plugin data directory. The directory is
|
|
59
|
+
`~/.claude/plugins/data/router-<marketplace>/`. For a gateway that you started
|
|
60
|
+
by hand, the directory is `$TMPDIR/router/`.
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
tail -n 20 ~/.claude/plugins/data/router-*/decisions.jsonl | jq -c '{tier, reason, estimate, observed}'
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`tier` is the selected tier. `reason` is the rule that decided. The `observed`
|
|
67
|
+
lines carry the model that answered, the context tokens and the cache reads
|
|
68
|
+
from the response. Compare the estimated and the observed cache reads to tune
|
|
69
|
+
the thresholds.
|
|
70
|
+
|
|
71
|
+
`scripts/transcript-models.sh <transcript.jsonl>` shows the model for each
|
|
72
|
+
assistant message in a Claude Code transcript.
|
|
73
|
+
|
|
74
|
+
## Troubleshooting
|
|
75
|
+
|
|
76
|
+
- If Claude Code does not accept `router` as a model, make sure that the
|
|
77
|
+
gateway runs and that `ANTHROPIC_BASE_URL` is set. The command
|
|
78
|
+
`curl http://127.0.0.1:43170/v1/models` lists the alias.
|
|
79
|
+
- If Claude Code reports that it does not use the gateway, run
|
|
80
|
+
`/router:setup` and restart Claude Code.
|
|
81
|
+
- If each turn runs on Sonnet, make sure that the key is set. Read the
|
|
82
|
+
`router:` lines in `gateway.log` next to `decisions.jsonl`.
|
|
83
|
+
- If the effort is not what you set, read the `efforts` list of the model. The
|
|
84
|
+
gateway lowers the effort to a level that the model accepts. Sonnet 4.6 has
|
|
85
|
+
no `xhigh`. Haiku has no effort and no thinking.
|
|
86
|
+
- To stop the gateway, run `pkill -f scripts/gateway.mjs`. The next session
|
|
87
|
+
starts it again.
|
package/hooks/hooks.json
ADDED
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Defaults, user overrides and validation. Pure: callers pass env and the parsed user file.
|
|
2
|
+
|
|
3
|
+
export const TIERS = ['micro', 'low', 'medium', 'high'];
|
|
4
|
+
export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
5
|
+
|
|
6
|
+
export const DEFAULTS = {
|
|
7
|
+
gateway: { port: 43170, alias: 'router', baselineTier: 'low', auxiliaryTier: 'low' },
|
|
8
|
+
routes: {
|
|
9
|
+
high: { model: 'fable', effort: 'xhigh' },
|
|
10
|
+
medium: { model: 'opus', effort: 'high' },
|
|
11
|
+
low: { model: 'sonnet' },
|
|
12
|
+
micro: { model: 'haiku' },
|
|
13
|
+
},
|
|
14
|
+
// `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).
|
|
16
|
+
// `efforts` lists what the model accepts; an empty list means no effort field and no adaptive thinking.
|
|
17
|
+
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
|
+
opus: {
|
|
27
|
+
id: 'claude-opus-5',
|
|
28
|
+
input: 5,
|
|
29
|
+
cacheRead: 0.5,
|
|
30
|
+
contextWindow: 1_000_000,
|
|
31
|
+
billing: 'plan',
|
|
32
|
+
efforts: EFFORTS,
|
|
33
|
+
},
|
|
34
|
+
sonnet: {
|
|
35
|
+
id: 'claude-sonnet-4-6',
|
|
36
|
+
input: 3,
|
|
37
|
+
cacheRead: 0.3,
|
|
38
|
+
contextWindow: 1_000_000,
|
|
39
|
+
billing: 'plan',
|
|
40
|
+
efforts: ['low', 'medium', 'high', 'max'],
|
|
41
|
+
},
|
|
42
|
+
haiku: { id: 'claude-haiku-4-5', input: 1, cacheRead: 0.1, contextWindow: 200_000, billing: 'plan', efforts: [] },
|
|
43
|
+
},
|
|
44
|
+
cache: {
|
|
45
|
+
writeMultiplier: { '5m': 1.25, '1h': 2 },
|
|
46
|
+
ttlMs: { '5m': 300_000, '1h': 3_600_000 },
|
|
47
|
+
warmMarginMs: 30_000,
|
|
48
|
+
},
|
|
49
|
+
policy: {
|
|
50
|
+
upgradeVotes: 2,
|
|
51
|
+
upgradeBase: 0.75,
|
|
52
|
+
upgradeSlope: 0.15,
|
|
53
|
+
upgradePivotUsd: 0.5, // tax at which half the slope applies: $0.20 of tax raises the bar to ~0.79, $4 to ~0.88
|
|
54
|
+
jumpConfidence: 0.95,
|
|
55
|
+
downgradeVotes: 2,
|
|
56
|
+
downgradeMass: 0.9,
|
|
57
|
+
continuationMass: 0.7,
|
|
58
|
+
escalationHoldTurns: 2,
|
|
59
|
+
cashCapUsd: 2, // a Claude Code turn starts near 100k tokens (system prompt + tools): cold Fable is ~$1.25 before any history
|
|
60
|
+
},
|
|
61
|
+
jev: { endpoint: 'https://api.typesafe.ai/v1/systemone', model: 'jev-1.13.0', timeoutMs: 1500 },
|
|
62
|
+
context: { recentTurns: 6, maxTextChars: 1200 },
|
|
63
|
+
log: true,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const API_KEY_VAR = 'TYPESAFE_API_KEY';
|
|
67
|
+
|
|
68
|
+
export function loadConfig({ env = {}, userFile = null } = {}) {
|
|
69
|
+
const config = merge(DEFAULTS, userFile ?? {});
|
|
70
|
+
validate(config);
|
|
71
|
+
const apiKey = env[API_KEY_VAR]?.trim() || null;
|
|
72
|
+
const forcedTier = env.ROUTER_FORCE_TIER ?? null;
|
|
73
|
+
if (forcedTier && !TIERS.includes(forcedTier))
|
|
74
|
+
throw new Error(`ROUTER_FORCE_TIER must be one of ${TIERS.join(', ')}`);
|
|
75
|
+
return { ...config, apiKey, forcedTier };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function rank(tier) {
|
|
79
|
+
return TIERS.indexOf(tier);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function merge(base, override) {
|
|
83
|
+
if (Array.isArray(base) || typeof base !== 'object' || base === null) return override;
|
|
84
|
+
const out = { ...base };
|
|
85
|
+
for (const [key, value] of Object.entries(override)) {
|
|
86
|
+
out[key] =
|
|
87
|
+
key in base && typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
88
|
+
? merge(base[key], value)
|
|
89
|
+
: value;
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function validate(config) {
|
|
95
|
+
for (const tier of TIERS) {
|
|
96
|
+
const route = config.routes[tier];
|
|
97
|
+
if (!route || typeof route.model !== 'string') throw new Error(`routes.${tier}.model is required`);
|
|
98
|
+
const model = config.models[route.model];
|
|
99
|
+
if (!model) throw new Error(`routes.${tier}.model "${route.model}" is not in models`);
|
|
100
|
+
if (route.effort !== undefined && !EFFORTS.includes(route.effort))
|
|
101
|
+
throw new Error(`routes.${tier}.effort "${route.effort}" is invalid`);
|
|
102
|
+
}
|
|
103
|
+
for (const [alias, model] of Object.entries(config.models)) {
|
|
104
|
+
for (const field of ['input', 'cacheRead', 'contextWindow']) {
|
|
105
|
+
if (!Number.isFinite(model[field]) || model[field] < 0)
|
|
106
|
+
throw new Error(`models.${alias}.${field} must be a non-negative number`);
|
|
107
|
+
}
|
|
108
|
+
if (typeof model.id !== 'string' || !model.id) throw new Error(`models.${alias}.id is required`);
|
|
109
|
+
if (!['plan', 'credits'].includes(model.billing))
|
|
110
|
+
throw new Error(`models.${alias}.billing must be plan or credits`);
|
|
111
|
+
if (!Array.isArray(model.efforts) || model.efforts.some((e) => !EFFORTS.includes(e)))
|
|
112
|
+
throw new Error(`models.${alias}.efforts must list valid effort levels`);
|
|
113
|
+
}
|
|
114
|
+
for (const key of ['baselineTier', 'auxiliaryTier']) {
|
|
115
|
+
if (!TIERS.includes(config.gateway[key])) throw new Error(`gateway.${key} must be one of ${TIERS.join(', ')}`);
|
|
116
|
+
}
|
|
117
|
+
if (!Number.isInteger(config.gateway.port) || config.gateway.port < 1 || config.gateway.port > 65535)
|
|
118
|
+
throw new Error('gateway.port must be a port number');
|
|
119
|
+
if (typeof config.gateway.alias !== 'string' || !config.gateway.alias) throw new Error('gateway.alias is required');
|
|
120
|
+
const p = config.policy;
|
|
121
|
+
for (const field of ['upgradeBase', 'jumpConfidence', 'downgradeMass', 'continuationMass']) {
|
|
122
|
+
if (!(p[field] >= 0 && p[field] <= 1)) throw new Error(`policy.${field} must be between 0 and 1`);
|
|
123
|
+
}
|
|
124
|
+
for (const field of ['upgradeVotes', 'downgradeVotes', 'escalationHoldTurns']) {
|
|
125
|
+
if (!Number.isInteger(p[field]) || p[field] < 1) throw new Error(`policy.${field} must be a positive integer`);
|
|
126
|
+
}
|
|
127
|
+
if (!(p.cashCapUsd >= 0)) throw new Error('policy.cashCapUsd must be a non-negative number');
|
|
128
|
+
if (!(config.jev.timeoutMs > 0)) throw new Error('jev.timeoutMs must be positive');
|
|
129
|
+
}
|