@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.
@@ -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.
@@ -0,0 +1,16 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "node",
9
+ "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/ensure-gateway.mjs"],
10
+ "timeout": 10
11
+ }
12
+ ]
13
+ }
14
+ ]
15
+ }
16
+ }
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
+ }