@qadanm/opencode-smart-router 0.0.0-stage → 0.4.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/CHANGELOG.md +118 -0
- package/CONTRIBUTING.md +96 -0
- package/LICENSE +21 -0
- package/README.md +341 -2
- package/SECURITY.md +174 -0
- package/config.example.json +108 -0
- package/dist/allowance.js +487 -0
- package/dist/gui-experimental.cjs +1 -0
- package/dist/index.js +11111 -0
- package/dist/npm/index.js +40 -0
- package/dist/tui.js +587 -0
- package/docs/ARCHITECTURE.md +104 -0
- package/docs/DEBUGGING-GUI.md +70 -0
- package/docs/INSTALL.md +120 -0
- package/docs/LIMITATIONS.md +213 -0
- package/docs/MODELS.md +124 -0
- package/docs/OPENDESIGN-INTEGRATION.md +635 -0
- package/docs/QA.md +142 -0
- package/docs/SHARED-CORE.md +72 -0
- package/install +75 -0
- package/package.json +92 -4
- package/scripts/install.sh +324 -0
- package/scripts/uninstall.sh +91 -0
- package/src/allowance.ts +135 -0
- package/src/core/classify/classifier.ts +305 -0
- package/src/core/config-writer.ts +167 -0
- package/src/core/config.ts +1030 -0
- package/src/core/context/strategy.ts +188 -0
- package/src/core/context-health.ts +39 -0
- package/src/core/failures.ts +398 -0
- package/src/core/history/opencode.ts +173 -0
- package/src/core/models/protocol.ts +22 -0
- package/src/core/models/registry.ts +203 -0
- package/src/core/models/seed.ts +771 -0
- package/src/core/offline/mode.ts +321 -0
- package/src/core/plan-page.ts +390 -0
- package/src/core/plans/catalog.ts +481 -0
- package/src/core/policy/calibration.ts +259 -0
- package/src/core/policy/eligibility.ts +578 -0
- package/src/core/providers/enablement.ts +143 -0
- package/src/core/providers/opendesign-credits.ts +206 -0
- package/src/core/providers/opendesign-models.ts +628 -0
- package/src/core/providers/opendesign.ts +474 -0
- package/src/core/quota/calibration.ts +166 -0
- package/src/core/quota/commandcode.ts +471 -0
- package/src/core/quota/credentials.ts +490 -0
- package/src/core/quota/ledger.ts +1273 -0
- package/src/core/quota/opencode-go.ts +148 -0
- package/src/core/quota/opendesign.ts +1043 -0
- package/src/core/quota/reconcile.ts +155 -0
- package/src/core/quota/report.ts +261 -0
- package/src/core/quota/service.ts +273 -0
- package/src/core/reprompt.ts +48 -0
- package/src/core/routing/engine.ts +919 -0
- package/src/core/types.ts +825 -0
- package/src/core/ui.ts +236 -0
- package/src/core/util.ts +114 -0
- package/src/gui/index.tsx +1670 -0
- package/src/gui/viewmodel.ts +456 -0
- package/src/index.ts +48 -0
- package/src/rpc.ts +204 -0
- package/src/server/controller.ts +3281 -0
- package/src/server/route-tracker.ts +60 -0
- package/src/server/shared-core.ts +671 -0
- package/src/tui/index.tsx +168 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Notable changes to OpenCode Smart Router. Dates are the day the change landed on
|
|
4
|
+
`main`.
|
|
5
|
+
|
|
6
|
+
## Unreleased
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **Launch kit** (`docs/LAUNCH-KIT.md`): paste-ready copy for Discord, Hacker
|
|
11
|
+
News, X, and the opencode.cafe submission, plus the demo film assets.
|
|
12
|
+
|
|
13
|
+
## 0.4.0 — 2026-11-05
|
|
14
|
+
|
|
15
|
+
Self-healing routing, and npm distribution.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **Self-healing routing.** A rebuilt session can carry mid-session notices
|
|
20
|
+
into the prompt as system roles, which kills the turn on any
|
|
21
|
+
Anthropic-protocol model with a 400 error. The router folds those notices
|
|
22
|
+
into user messages before the request leaves, refuses the host retry on a
|
|
23
|
+
deterministic rejection, and continues the turn on the next best eligible
|
|
24
|
+
model with one notice. The panel records what was rejected and what took
|
|
25
|
+
over. Found on a live Command Code session: the error was
|
|
26
|
+
"Invalid discriminator value. Expected 'user' | 'assistant' at
|
|
27
|
+
messages.23.role".
|
|
28
|
+
- **npm package** (`@qadanm/opencode-smart-router`). One self-contained file
|
|
29
|
+
with no runtime dependencies, loadable with a single `plugin` entry so the
|
|
30
|
+
router appears in OpenCode's own ecosystem path. The full experience stays
|
|
31
|
+
with the installer.
|
|
32
|
+
|
|
33
|
+
## 0.3.0 — 2026-11-05
|
|
34
|
+
|
|
35
|
+
The release where the router gained a face, and where several numbers it had been
|
|
36
|
+
asserting turned out to be wrong.
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- **Desktop panel** (`./gui-experimental`). A side panel in OpenCode Desktop that
|
|
41
|
+
shows the last routing decision, why the chosen model won, every candidate that
|
|
42
|
+
lost with the reason, per-provider allowances with all their windows, and the
|
|
43
|
+
provider list. Built on the OpenCode GUI plugin API, which the SDK marks
|
|
44
|
+
experimental.
|
|
45
|
+
- **First-run onboarding.** Reports the providers found on this machine, what was
|
|
46
|
+
imported from history, and what the router will do — before asking for anything.
|
|
47
|
+
Evidence rather than a questionnaire, because detection already did the finding.
|
|
48
|
+
- **Terminal parity** (`./tui`). `/router-why` and `/router-quota`, plus a footer
|
|
49
|
+
status slot. Same view-model as the panel, so the two cannot disagree.
|
|
50
|
+
- **Calibration.** The safety margin is now measured from the charges the provider
|
|
51
|
+
reported rather than assumed. Narrows with evidence; stays wide and warns when
|
|
52
|
+
the charges imply a wrong price.
|
|
53
|
+
- **History import from OpenCode's own database.** Per-message, so each event
|
|
54
|
+
carries its real timestamp and the model that actually served it. This is what
|
|
55
|
+
makes a fixed seven-day window accurate instead of smeared onto session
|
|
56
|
+
boundaries.
|
|
57
|
+
- **Per-session automatic-routing switch.** Stops the router changing the model
|
|
58
|
+
without disabling it. `SessionState.paused` existed but nothing read it, so this
|
|
59
|
+
also fixed a switch that would have silently done nothing.
|
|
60
|
+
- **Account reading by paste.** OpenDesign exposes no usage API, so the estimate
|
|
61
|
+
can only see traffic the router routed. Pasting the Plan & balance page seeds
|
|
62
|
+
the real figure. Reconciled against the catalog, which reports disagreement
|
|
63
|
+
rather than absorbing it.
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
- **Thirteen OpenDesign models were priced wrong**, some by 4x in both directions.
|
|
68
|
+
The catalog stored a base price and then applied the Design Plan multiplier on
|
|
69
|
+
top, and several entries already had the multiplier baked in. Verified against
|
|
70
|
+
the published CoRouter catalog and pinned by 36 assertions.
|
|
71
|
+
- **The Plus tier had no weekly cap**, making it look able to spend a whole month
|
|
72
|
+
in one week.
|
|
73
|
+
- **The seven-day window was modelled as rolling** when the vendor documents it as
|
|
74
|
+
fixed from the subscription start, and the estimator ignored the declaration
|
|
75
|
+
entirely even after the catalog was corrected.
|
|
76
|
+
- **The reported cost was discarded.** Every step carries the real charge, and the
|
|
77
|
+
estimator recomputed an approximation of it from a static table instead.
|
|
78
|
+
- **Command Code's DeepSeek V4.1 Flash sent a model id the gateway rejects**, which
|
|
79
|
+
surfaced as "The provider rejected this request" on manual selection.
|
|
80
|
+
- **Session titles were rewritten, and could be destroyed.** `titleIndicator`
|
|
81
|
+
defaulted to true and compact mode returned the prefix alone, losing the name
|
|
82
|
+
irrecoverably. It now defaults to false and never derives a name from its own
|
|
83
|
+
previous output.
|
|
84
|
+
- **Every provider was enabled by default** regardless of whether the machine had
|
|
85
|
+
an account, so a new install produced authentication failures for providers the
|
|
86
|
+
user never claimed. Enablement now follows discovered credentials, and an
|
|
87
|
+
explicit configuration always wins.
|
|
88
|
+
- **A spent allowance was not recognised.** There was no `quota_exhausted` failure
|
|
89
|
+
class, so a 402 was retried and never marked the provider spent. It now stops
|
|
90
|
+
the turn and holds the provider back until its window resets.
|
|
91
|
+
- **`DEPLOYMENT.json` could not be trusted.** It recorded the commit but not that
|
|
92
|
+
the tree was dirty, so three installs were attributed to the wrong revision. It
|
|
93
|
+
now records the diff hash, the changed file count and the GUI bundle hash.
|
|
94
|
+
|
|
95
|
+
### Changed
|
|
96
|
+
|
|
97
|
+
- **`ui.titleIndicator` now defaults to `false`.** The router no longer edits chat
|
|
98
|
+
names. Anyone who wants the indicator back can set it, and turning it off again
|
|
99
|
+
restores any prefix a previous version added.
|
|
100
|
+
- **Provider enablement is credential-aware.** A provider the user never mentioned
|
|
101
|
+
is enabled only when a credential or connection exists for it.
|
|
102
|
+
- Prices, ceilings and multipliers are now checked against vendor documentation by
|
|
103
|
+
tests, so they cannot drift silently.
|
|
104
|
+
|
|
105
|
+
### Known limitations
|
|
106
|
+
|
|
107
|
+
- OpenDesign has no usage API for an inference key, and the account-control
|
|
108
|
+
endpoints require a credential the product does not issue. `nexu-io/open-design`
|
|
109
|
+
issue #8674 requests one and remains open. The estimate plus a pasted reading is
|
|
110
|
+
the best available figure.
|
|
111
|
+
- The GUI plugin API is marked experimental by the SDK and may change between
|
|
112
|
+
OpenCode releases.
|
|
113
|
+
|
|
114
|
+
## 0.2.0 — prior to this changelog
|
|
115
|
+
|
|
116
|
+
Subscription-aware routing across OpenCode Go, Command Code GOAT and OpenDesign,
|
|
117
|
+
with nested usage windows, multi-pool credit accounting and context policy. See
|
|
118
|
+
`docs/ARCHITECTURE.md`.
|
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
This project is small and deliberately narrow in scope, so a precise bug report
|
|
4
|
+
is usually more useful than a large patch.
|
|
5
|
+
|
|
6
|
+
## Before you start
|
|
7
|
+
|
|
8
|
+
Open an issue describing what you want to change and why. For anything touching
|
|
9
|
+
credential handling, routing eligibility, or quota arithmetic, please discuss it
|
|
10
|
+
first. Those areas carry security and correctness properties that are easy to
|
|
11
|
+
break from the outside, and a short conversation saves a rejected pull request.
|
|
12
|
+
|
|
13
|
+
## Ground rules
|
|
14
|
+
|
|
15
|
+
**Every change ships with a test.** `npm test` must pass. If behavior is hard to
|
|
16
|
+
test, treat that as a design signal.
|
|
17
|
+
|
|
18
|
+
**Never commit a credential, a token, an account identifier, or a personal file
|
|
19
|
+
path.** Every path in the codebase derives from `homedir()`. Keep it that way.
|
|
20
|
+
`safeError()` and `redact()` exist for this reason.
|
|
21
|
+
|
|
22
|
+
**Do not weaken a safety property to make a test pass.** When a test fails, the
|
|
23
|
+
test may be wrong (it has happened) or the code may be wrong. Work out which
|
|
24
|
+
before editing either.
|
|
25
|
+
|
|
26
|
+
**Provider facts need a source.** Allowances, multipliers, context windows, and
|
|
27
|
+
prices are claims about the world. Cite where a number came from, with a
|
|
28
|
+
retrieval date, in a comment. An unsourced number that looks authoritative is
|
|
29
|
+
worse than an admitted gap.
|
|
30
|
+
|
|
31
|
+
## Development
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install
|
|
35
|
+
npm run typecheck # tsc --noEmit
|
|
36
|
+
npm test # vitest run
|
|
37
|
+
npm run check # both
|
|
38
|
+
npm run build:server # the plugin bundle
|
|
39
|
+
npm run build:allowance # the standalone allowance core
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Node 20.12 or newer.
|
|
43
|
+
|
|
44
|
+
## Architecture boundaries
|
|
45
|
+
|
|
46
|
+
Two rules keep this maintainable and publishable.
|
|
47
|
+
|
|
48
|
+
**The allowance core stays portable.** `src/allowance.ts` is the public,
|
|
49
|
+
host-independent surface. It may import only `core/plans/catalog`,
|
|
50
|
+
`core/context/strategy`, `core/quota/report`, and `core/types`. It must never
|
|
51
|
+
touch the filesystem, the network, the environment, or the host's plugin API.
|
|
52
|
+
`test/allowance-api.test.ts` enforces this. If that test fails, the change is
|
|
53
|
+
wrong, not the test.
|
|
54
|
+
|
|
55
|
+
**Plans are data, not code.** A subscription tier is a `PlanDefinition` in
|
|
56
|
+
`src/core/plans/catalog.ts`. Do not add `if (plan === "pro")` to a routing or
|
|
57
|
+
quota module. If a new tier needs different behavior instead of different
|
|
58
|
+
numbers, that is an architecture discussion.
|
|
59
|
+
|
|
60
|
+
Provider HTTP handling belongs in `src/core/quota/*-provider.ts`. Routing policy
|
|
61
|
+
belongs in `src/core/routing/` and `src/core/policy/`.
|
|
62
|
+
|
|
63
|
+
## Adding a provider
|
|
64
|
+
|
|
65
|
+
A provider adapter documents four things before it is accepted:
|
|
66
|
+
|
|
67
|
+
- which credential it needs, and whether that credential can read usage;
|
|
68
|
+
- every host it contacts, and whether each call is read-only;
|
|
69
|
+
- how it reports confidence (`provider`, `estimate`, or `unknown`), and why;
|
|
70
|
+
- what it does when the usage source is unavailable.
|
|
71
|
+
|
|
72
|
+
A provider whose limits cannot be read must report `estimated` or `unknown`. It
|
|
73
|
+
must never report an unknown allowance as unlimited.
|
|
74
|
+
|
|
75
|
+
## Adding a plan
|
|
76
|
+
|
|
77
|
+
Add a `PlanDefinition` with a source citation and a retrieval date. Add a test
|
|
78
|
+
for the pool shape, the nesting invariants, and the absence of windows the plan
|
|
79
|
+
does not have. A plan with no five-hour window must not report "0% of a five-hour
|
|
80
|
+
limit," because that implies a ceiling nothing can hit.
|
|
81
|
+
|
|
82
|
+
## Commit messages
|
|
83
|
+
|
|
84
|
+
Explain why, not what. The diff already shows what. A commit that fixes a subtle
|
|
85
|
+
bug should say how the bug showed up, because that part is not recoverable from
|
|
86
|
+
the code.
|
|
87
|
+
|
|
88
|
+
## Out of scope
|
|
89
|
+
|
|
90
|
+
Likely to be declined:
|
|
91
|
+
|
|
92
|
+
- spending money on your behalf;
|
|
93
|
+
- acquiring credentials or control keys automatically;
|
|
94
|
+
- disabling provider-side prompt caching;
|
|
95
|
+
- silently truncating or discarding conversation history;
|
|
96
|
+
- telemetry or analytics.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Moe Qadan
|
|
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
CHANGED
|
@@ -1,3 +1,342 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<img alt="smart router" src="assets/logo.svg" width="300">
|
|
4
|
+
|
|
5
|
+
<h3>A model router for OpenCode that bills against your subscription, not a price list</h3>
|
|
6
|
+
|
|
7
|
+
<p>Every other router asks what a token costs. This one asks what your plan allows, what is about to expire, and what you already paid for. MIT licensed.</p>
|
|
8
|
+
|
|
9
|
+
<p>
|
|
10
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
|
|
11
|
+
<img src="https://img.shields.io/badge/OpenCode-2.x-000000.svg" alt="OpenCode 2.x">
|
|
12
|
+
<img src="https://img.shields.io/badge/tests-759%20passing-2ea44f.svg" alt="759 tests">
|
|
13
|
+
<img src="https://img.shields.io/badge/runtime-Node%2020.12%2B-339933.svg" alt="Node 20.12+">
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
<p>
|
|
17
|
+
<a href="#install"><b>Install</b></a> ·
|
|
18
|
+
<a href="#how-it-decides">How it decides</a> ·
|
|
19
|
+
<a href="#commands">Commands</a> ·
|
|
20
|
+
<a href="docs/ARCHITECTURE.md">Architecture</a> ·
|
|
21
|
+
<a href="docs/LIMITATIONS.md">Limitations</a>
|
|
22
|
+
</p>
|
|
23
|
+
|
|
24
|
+
<br>
|
|
25
|
+
|
|
26
|
+
<picture>
|
|
27
|
+
<source media="(prefers-color-scheme: dark)" srcset="assets/architecture-dark.svg">
|
|
28
|
+
<img alt="Your prompt goes to the smart router, which classifies and prices it against your real quota before OpenCode sends the step to one provider" src="assets/architecture-light.svg" width="880">
|
|
29
|
+
</picture>
|
|
30
|
+
|
|
31
|
+
<br>
|
|
32
|
+
|
|
33
|
+
<p>
|
|
34
|
+
<a href="assets/screenshots/router-panel.png"><img alt="The router panel beside a live OpenCode session: the model it chose and why, the allowance windows it is spending against, and every provider it can route to" src="assets/screenshots/router-panel.png" width="720"></a>
|
|
35
|
+
</p>
|
|
36
|
+
|
|
37
|
+
<table>
|
|
38
|
+
<tr>
|
|
39
|
+
<td align="center" width="50%">
|
|
40
|
+
<a href="assets/screenshots/router-decision.png"><img alt="The decision inspector: the winning model with its estimated cost, and the runner-ups it beat" src="assets/screenshots/router-decision.png" width="376"></a>
|
|
41
|
+
<br><sub>The choice, laid out with its runner-ups</sub>
|
|
42
|
+
</td>
|
|
43
|
+
<td align="center" width="50%">
|
|
44
|
+
<a href="assets/screenshots/router-allowances.png"><img alt="Allowance meters for each provider: five-hour, seven-day and monthly windows, the credit pools behind them, and an honest lower-bound marker where the figure is an estimate" src="assets/screenshots/router-allowances.png" width="376"></a>
|
|
45
|
+
<br><sub>What is left, per window and per pool</sub>
|
|
46
|
+
</td>
|
|
47
|
+
</tr>
|
|
48
|
+
</table>
|
|
49
|
+
|
|
50
|
+
<p>
|
|
51
|
+
<a href="assets/screenshots/router-onboarding.png"><img alt="First-run onboarding: what the router found on this machine, what it learned from your history, and the one thing it cannot see, with the exact page to paste" src="assets/screenshots/router-onboarding.png" width="720"></a>
|
|
52
|
+
</p>
|
|
53
|
+
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
If you pay for a coding plan, you are not buying tokens. You are buying a set of
|
|
59
|
+
windows that expire. A five-hour window sits inside a seven-day window inside a
|
|
60
|
+
monthly ceiling, some windows are metered per model and do not pool, and a few
|
|
61
|
+
plans run two credit pools that draw at different rates.
|
|
62
|
+
|
|
63
|
+
Token-based routers cannot see any of that. They price every request the same way
|
|
64
|
+
and happily spend the last dollar of an allowance that was about to reset anyway.
|
|
65
|
+
This router models the plan you actually bought.
|
|
66
|
+
|
|
67
|
+
## The problem, concretely
|
|
68
|
+
|
|
69
|
+
You have three subscriptions. One has 90% of its five-hour window free but resets
|
|
70
|
+
in forty minutes. Another has a huge untouched monthly pool. A third is nearly
|
|
71
|
+
exhausted and expires tonight.
|
|
72
|
+
|
|
73
|
+
A price-per-token router picks the same model every time. Whichever is cheapest on
|
|
74
|
+
paper. It ignores that the first allowance is about to evaporate, that the second
|
|
75
|
+
is your real safety margin, and that the third will be wasted if nobody spends it.
|
|
76
|
+
|
|
77
|
+
The smart router ranks by what a request actually costs *you*: the modelled
|
|
78
|
+
allowance it consumes, adjusted for how much of that allowance would otherwise be
|
|
79
|
+
lost. When a pool is about to expire and mostly unspent, spending it is close to
|
|
80
|
+
free. When a pool is nearly gone, it stops being attractive.
|
|
81
|
+
|
|
82
|
+
<p align="center">
|
|
83
|
+
<img alt="Comparison: a fixed price-per-token router keeps choosing the same model, while the smart router shifts its choice as allowances fill and expire" src="assets/demo.svg" width="760">
|
|
84
|
+
</p>
|
|
85
|
+
|
|
86
|
+
## Install
|
|
87
|
+
|
|
88
|
+
Requires Node 20.12+, npm and git. No `jq`, no global CLI, nothing to configure.
|
|
89
|
+
|
|
90
|
+
### From npm
|
|
91
|
+
|
|
92
|
+
Add the plugin to your `opencode.json`; OpenCode installs it from npm at startup:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"$schema": "https://opencode.ai/config.json",
|
|
97
|
+
"plugin": ["@qadanm/opencode-smart-router"]
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
That is the routing core in one self-contained file. The full experience (desktop
|
|
102
|
+
panel, terminal commands, history import, first-run onboarding) comes from the
|
|
103
|
+
installer below.
|
|
104
|
+
|
|
105
|
+
### Full experience (recommended)
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
curl -fsSL https://raw.githubusercontent.com/qadanm/opencode-smart-router/main/install | bash
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Or from a clone:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
git clone https://github.com/qadanm/opencode-smart-router.git
|
|
115
|
+
cd opencode-smart-router && ./install
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
It validates the source, runs the full suite, backs up your config, builds, installs,
|
|
119
|
+
and confirms the bundle loads. Every check runs before anything is written, so a
|
|
120
|
+
failed install leaves your machine exactly as it was.
|
|
121
|
+
|
|
122
|
+
Non-interactive, for CI or an agent:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
./install --yes
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
See what it would do without touching anything:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
./install --dry-run
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Restart OpenCode, then run `/router-status` in any session.
|
|
135
|
+
|
|
136
|
+
Already using it? Run `./install` again to update. Installs are versioned, so a
|
|
137
|
+
running session keeps the module it loaded and picks up the new one on next start.
|
|
138
|
+
|
|
139
|
+
**[Full install, verify and rollback guide →](docs/INSTALL.md)**
|
|
140
|
+
|
|
141
|
+
## How it decides
|
|
142
|
+
|
|
143
|
+
<p align="center">
|
|
144
|
+
<img alt="Pipeline: classify the task, evaluate each approved model for eligibility, price it against quota, then pick the lowest effective cost with a cache-affinity margin" src="assets/pipeline-light.svg" width="820">
|
|
145
|
+
</p>
|
|
146
|
+
|
|
147
|
+
Every prompt is classified locally, in about a millisecond, from its structure
|
|
148
|
+
rather than its wording. The router reads scope, attachments, agent type, context
|
|
149
|
+
size and recent failures, then holds substantive engineering work to an
|
|
150
|
+
intelligence floor and requires native image input for anything visual.
|
|
151
|
+
|
|
152
|
+
Then it does the part that matters:
|
|
153
|
+
|
|
154
|
+
1. **Eligibility first.** A model is discarded if a plan cannot fund it, if a
|
|
155
|
+
provider allowance is exhausted, if the task needs vision the model lacks, or
|
|
156
|
+
if the intelligence floor excludes it. Refusals are recorded with the reason.
|
|
157
|
+
2. **Price against the plan.** Cost is the modelled allowance a request consumes
|
|
158
|
+
in the windows that will actually be charged, not a per-token rate.
|
|
159
|
+
3. **Discount what is expiring.** Prepaid allowance about to reset is priced near
|
|
160
|
+
its probability of being wasted. It is a bounded discount, never free, because
|
|
161
|
+
a pool with no headroom cannot carry the work.
|
|
162
|
+
4. **Prefer staying put.** Switching discards a warm prompt cache, which costs real
|
|
163
|
+
money. The router only moves when the saving clears a margin.
|
|
164
|
+
|
|
165
|
+
When a genuinely near-expiry window and a cheap provider disagree, the router picks
|
|
166
|
+
the option that spends money you have already committed to losing.
|
|
167
|
+
|
|
168
|
+
## Self-healing routing
|
|
169
|
+
|
|
170
|
+
Some providers reject a rebuilt session outright. OpenCode turns mid-session
|
|
171
|
+
notices into system-role messages, and the Anthropic protocol only accepts user
|
|
172
|
+
and assistant roles, so one notice kills the whole turn with a 400 error.
|
|
173
|
+
|
|
174
|
+
The router handles this before it can happen. For Anthropic-protocol models it
|
|
175
|
+
folds system entries after the first message into user messages on the way out,
|
|
176
|
+
so the request leaves in a shape the provider accepts. Utility requests, like
|
|
177
|
+
title generation, are left alone.
|
|
178
|
+
|
|
179
|
+
When a provider still says no, a 400 that would reproduce on every retry gets
|
|
180
|
+
its own failure class and the host's retry is refused. The router holds the
|
|
181
|
+
rejecting model back for five minutes, re-routes the same task to the next best
|
|
182
|
+
eligible choice, and continues the turn with a single `[router]` notice. One
|
|
183
|
+
failover per prompt, never a loop. The hold covers every session, because a
|
|
184
|
+
gateway that rejects one rebuilt session will reject others like it. The
|
|
185
|
+
panel's last decision records what was rejected and what took over.
|
|
186
|
+
|
|
187
|
+
## What it will not do
|
|
188
|
+
|
|
189
|
+
- Never buys credits, changes billing, or enables top-ups.
|
|
190
|
+
- Never routes to a provider you did not approve.
|
|
191
|
+
- Never spends premium quota without an explicit one-shot approval or a budget you set.
|
|
192
|
+
- Never switches a session to a model OpenCode cannot currently resolve.
|
|
193
|
+
- Never fails open on missing quota data when other providers already reported.
|
|
194
|
+
- Never blocks your other work when one provider is at its limit.
|
|
195
|
+
|
|
196
|
+
## Status
|
|
197
|
+
|
|
198
|
+
Version 0.4.0. Routing runs on the maintainer's machine in automatic mode;
|
|
199
|
+
premium escalation and purchased-credit spending are disabled. Current behavior
|
|
200
|
+
and open questions are in
|
|
201
|
+
[docs/PRO-MIGRATION-2026-10-29.md](docs/PRO-MIGRATION-2026-10-29.md).
|
|
202
|
+
|
|
203
|
+
Published on npm as `@qadanm/opencode-smart-router`. The unscoped name
|
|
204
|
+
`opencode-smart-router` belongs to an unrelated GPL project, so this package
|
|
205
|
+
lives under a scope.
|
|
206
|
+
|
|
207
|
+
## Commands
|
|
208
|
+
|
|
209
|
+
Everything is a native slash command. No GUI needed, and routing works with the
|
|
210
|
+
command panel closed.
|
|
211
|
+
|
|
212
|
+
| Command | Purpose |
|
|
213
|
+
|---|---|
|
|
214
|
+
| `/router-status` | Runtime mode, selected model, task tier, quota, warnings |
|
|
215
|
+
| `/router-explain` | Last decision, signals, candidate costs, reasons |
|
|
216
|
+
| `/router-why` | Why this model, for the prompt you just sent |
|
|
217
|
+
| `/router-report` | Local usage, switches, premium prevention, retries, latency |
|
|
218
|
+
| `/router-floor` | The intelligence floor in force and what it admits |
|
|
219
|
+
| `/router-mode efficiency\|balanced\|premium` | Change routing mode |
|
|
220
|
+
| `/router-dry-run on\|off` | Explain decisions without changing the model |
|
|
221
|
+
| `/router-auto` | Clear a manual override, resume automatic routing |
|
|
222
|
+
| `/router-pin provider/model` | Pin a model for this session |
|
|
223
|
+
| `/router-exclude provider/model` | Exclude a model from automatic routing |
|
|
224
|
+
| `/router-approve` | Approve one premium request for this session |
|
|
225
|
+
| `/router-pause` / `/router-resume` | Pause or resume routing globally |
|
|
226
|
+
| `/router-premium-budget <usd>` | Set the premium budget |
|
|
227
|
+
| `/router-reserve provider monthly weekly fiveHour` | Adjust provider reserves |
|
|
228
|
+
| `/router-reset-metrics --confirm` | Reset metrics and routing state, not conversations |
|
|
229
|
+
|
|
230
|
+
Selecting a model in the desktop picker is detected and respected. The session
|
|
231
|
+
reports `manual` until you run `/router-auto`.
|
|
232
|
+
|
|
233
|
+
## Configuration
|
|
234
|
+
|
|
235
|
+
The defaults are safe and need no file. To change them:
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
cp config.example.json ~/.config/opencode/smart-router.json
|
|
239
|
+
chmod 600 ~/.config/opencode/smart-router.json
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
A project may add `.opencode/smart-router.json`. A project file can tighten policy.
|
|
243
|
+
It cannot loosen global restrictions.
|
|
244
|
+
|
|
245
|
+
Worth knowing:
|
|
246
|
+
|
|
247
|
+
- Mode: `balanced`
|
|
248
|
+
- Premium: `ask`, $0 budget, one-shot approval available
|
|
249
|
+
- Training-enabled and free promotional models: blocked
|
|
250
|
+
- Purchased extra credits: blocked
|
|
251
|
+
- Prompt text storage: disabled
|
|
252
|
+
- Quota polling: every 5 minutes, with backoff
|
|
253
|
+
- Provider reserves: 10% monthly, 15% weekly, 10% five-hour
|
|
254
|
+
|
|
255
|
+
### Credential discovery
|
|
256
|
+
|
|
257
|
+
Credentials are read from explicit paths, never guessed from arbitrary files.
|
|
258
|
+
Nothing is logged. `safeError()` and `redact()` exist so a failure message cannot
|
|
259
|
+
leak a key. The full model is in [docs/INSTALL.md](docs/INSTALL.md#credentials).
|
|
260
|
+
|
|
261
|
+
## The portable core
|
|
262
|
+
|
|
263
|
+
`dist/allowance.js` is a standalone module with no host dependency. You can use it
|
|
264
|
+
without OpenCode, to answer "when does this plan run out":
|
|
265
|
+
|
|
266
|
+
```js
|
|
267
|
+
import { projectExhaustion } from "./dist/allowance.js"
|
|
268
|
+
|
|
269
|
+
projectExhaustion({
|
|
270
|
+
usedUsd: 12.4,
|
|
271
|
+
capUsd: 30,
|
|
272
|
+
windowMs: 7 * 24 * 60 * 60 * 1000,
|
|
273
|
+
elapsedMs: 3.1 * 24 * 60 * 60 * 1000,
|
|
274
|
+
label: "weekly",
|
|
275
|
+
})
|
|
276
|
+
// { willExhaust: true, exhaustsAt, explanation: "...runs out about 6.2 hours from now." }
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
It reads no files, opens no socket, inspects no environment variable, and imports
|
|
280
|
+
nothing from the host. A test enforces that, so it cannot drift.
|
|
281
|
+
|
|
282
|
+
## Tests
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
npm test # the suite
|
|
286
|
+
npm run verify # clean room: empty HOME, fresh install, typecheck, tests, builds
|
|
287
|
+
npm run mutate # prove each safety test fails when its property breaks
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
759 tests across 63 files cover classification, eligibility, quota parsing,
|
|
291
|
+
credential discovery and its security properties, ledger accounting, pool
|
|
292
|
+
reconciliation, documented peak pricing, cache-aware routing, premium protection,
|
|
293
|
+
purchased-credit blocking, manual overrides, failure classification, configuration
|
|
294
|
+
guardrails, context policy, quota honesty, the portable allowance API, and
|
|
295
|
+
controller integration.
|
|
296
|
+
|
|
297
|
+
Two commands exist because passing tests prove less than they appear to. `verify`
|
|
298
|
+
repeats everything in a clean room with an empty `HOME`, so a pass cannot depend on
|
|
299
|
+
this machine. `mutate` reverts each of 62 safety properties in turn and fails if the
|
|
300
|
+
suite still passes, which is the only way to know a test is guarding anything.
|
|
301
|
+
|
|
302
|
+
Live evidence is in [docs/QA.md](docs/QA.md).
|
|
303
|
+
|
|
304
|
+
## Design notes
|
|
305
|
+
|
|
306
|
+
A few decisions that are easy to get wrong, and why they are the way they are:
|
|
307
|
+
|
|
308
|
+
- **Offline enforcement is an allow-list of one provider**, not a deny-list of
|
|
309
|
+
known hosted ones. A deny-list fails open the moment someone adds a provider.
|
|
310
|
+
- **A saturated gateway is not a broken model.** Capacity refusals hold the
|
|
311
|
+
endpoint back without charging the failure to the single model behind it.
|
|
312
|
+
- **Quota that expires unused is a sunk cost**, so the router prices it near its
|
|
313
|
+
probability of being wasted instead of at sticker price.
|
|
314
|
+
- **A refusal outlives the reading that produced it.** An exhausted allowance stays
|
|
315
|
+
excluded until a fresh probe clears it.
|
|
316
|
+
|
|
317
|
+
More in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
318
|
+
|
|
319
|
+
## Contributing
|
|
320
|
+
|
|
321
|
+
Bug reports and pull requests are welcome. Before opening a PR:
|
|
322
|
+
|
|
323
|
+
```bash
|
|
324
|
+
npm run check # typecheck + full suite
|
|
325
|
+
npm run verify # the same, in a clean room with an empty HOME
|
|
326
|
+
npm run mutate # prove the safety tests still catch their properties
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
A change that alters routing behavior needs a test that fails without it. See
|
|
330
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) for conventions, and [SECURITY.md](SECURITY.md)
|
|
331
|
+
for reporting a vulnerability.
|
|
332
|
+
|
|
333
|
+
## License
|
|
334
|
+
|
|
335
|
+
MIT. See [LICENSE](LICENSE).
|
|
336
|
+
|
|
337
|
+
Copyright (c) 2026 Moe Qadan.
|
|
338
|
+
|
|
339
|
+
Use it, modify it, ship it, sell it. Keep the copyright notice and permission text
|
|
340
|
+
with any substantial copy, and understand that it comes with no warranty. The
|
|
341
|
+
routing decisions it makes are estimates from modelled quota data, not guarantees
|
|
342
|
+
from your providers.
|