@qadanm/opencode-smart-router 0.0.0-stage → 0.6.1
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 +286 -0
- package/CONTRIBUTING.md +103 -0
- package/LICENSE +21 -0
- package/README.md +425 -2
- package/SECURITY.md +174 -0
- package/config.example.json +107 -0
- package/dist/npm/index.js +40 -0
- package/docs/ARCHITECTURE.md +104 -0
- package/docs/DEBUGGING-GUI.md +70 -0
- package/docs/INSTALL.md +151 -0
- package/docs/LIMITATIONS.md +230 -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 +114 -0
- package/install +75 -0
- package/package.json +94 -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 +1047 -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 +204 -0
- package/src/core/models/seed.ts +781 -0
- package/src/core/offline/mode.ts +321 -0
- package/src/core/plan-page.ts +390 -0
- package/src/core/plans/catalog.ts +487 -0
- package/src/core/policy/calibration.ts +259 -0
- package/src/core/policy/eligibility.ts +630 -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 +654 -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 +1304 -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 +978 -0
- package/src/core/types.ts +866 -0
- package/src/core/ui.ts +236 -0
- package/src/core/util.ts +114 -0
- package/src/gui/index.tsx +1675 -0
- package/src/gui/viewmodel.ts +456 -0
- package/src/index.ts +33 -0
- package/src/rpc.ts +204 -0
- package/src/server/containment.ts +131 -0
- package/src/server/controller.ts +3405 -0
- package/src/server/route-tracker.ts +60 -0
- package/src/server/shared-core.ts +701 -0
- package/src/tui/index.tsx +168 -0
package/README.md
CHANGED
|
@@ -1,3 +1,426 @@
|
|
|
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="https://www.npmjs.com/package/@qadanm/opencode-smart-router/v/0.4.0"><img src="https://img.shields.io/npm/v/%40qadanm%2Fopencode-smart-router?label=npm&color=cb3837&logo=npm&logoColor=white" alt="npm version"></a>
|
|
11
|
+
<a href="https://www.npmjs.com/package/@qadanm/opencode-smart-router"><img src="https://img.shields.io/npm/dm/%40qadanm%2Fopencode-smart-router?label=installs&color=cb3837&logo=npm&logoColor=white" alt="npm monthly installs"></a>
|
|
12
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
|
|
13
|
+
<img src="https://img.shields.io/badge/OpenCode-2.x-000000.svg" alt="OpenCode 2.x">
|
|
14
|
+
<img src="https://img.shields.io/badge/tests-759%20passing-2ea44f.svg" alt="759 tests">
|
|
15
|
+
<img src="https://img.shields.io/badge/runtime-Node%2020.12%2B-339933.svg" alt="Node 20.12+">
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
<p>
|
|
19
|
+
<a href="#install"><b>Install</b></a> ·
|
|
20
|
+
<a href="#how-it-decides">How it decides</a> ·
|
|
21
|
+
<a href="#commands">Commands</a> ·
|
|
22
|
+
<a href="docs/ARCHITECTURE.md">Architecture</a> ·
|
|
23
|
+
<a href="docs/LIMITATIONS.md">Limitations</a>
|
|
24
|
+
</p>
|
|
25
|
+
|
|
26
|
+
<br>
|
|
27
|
+
|
|
28
|
+
<a href="assets/smart-router-demo.gif"><img alt="One session, 22 seconds: a prompt arrives, the router shows the decision card with its reasons and blocked candidates, the budget windows fill in, and a rejected turn moves itself to the next best model and continues" src="assets/smart-router-demo.gif" width="880"></a>
|
|
29
|
+
|
|
30
|
+
<br>
|
|
31
|
+
|
|
32
|
+
<picture>
|
|
33
|
+
<source media="(prefers-color-scheme: dark)" srcset="assets/architecture-dark.svg">
|
|
34
|
+
<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">
|
|
35
|
+
</picture>
|
|
36
|
+
|
|
37
|
+
<br>
|
|
38
|
+
|
|
39
|
+
<p>
|
|
40
|
+
<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>
|
|
41
|
+
</p>
|
|
42
|
+
|
|
43
|
+
<table>
|
|
44
|
+
<tr>
|
|
45
|
+
<td align="center" width="50%">
|
|
46
|
+
<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>
|
|
47
|
+
<br><sub>The choice, laid out with its runner-ups</sub>
|
|
48
|
+
</td>
|
|
49
|
+
<td align="center" width="50%">
|
|
50
|
+
<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>
|
|
51
|
+
<br><sub>What is left, per window and per pool</sub>
|
|
52
|
+
</td>
|
|
53
|
+
</tr>
|
|
54
|
+
</table>
|
|
55
|
+
|
|
56
|
+
<p>
|
|
57
|
+
<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>
|
|
58
|
+
</p>
|
|
59
|
+
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
If you pay for a coding plan, you are not buying tokens. You are buying a set of
|
|
65
|
+
windows that expire. A five-hour window sits inside a seven-day window inside a
|
|
66
|
+
monthly ceiling, some windows are metered per model and do not pool, and a few
|
|
67
|
+
plans run two credit pools that draw at different rates.
|
|
68
|
+
|
|
69
|
+
Token-based routers cannot see any of that. They price every request the same way
|
|
70
|
+
and happily spend the last dollar of an allowance that was about to reset anyway.
|
|
71
|
+
This router models the plan you actually bought.
|
|
72
|
+
|
|
73
|
+
## The problem, concretely
|
|
74
|
+
|
|
75
|
+
You have three subscriptions. One has 90% of its five-hour window free but resets
|
|
76
|
+
in forty minutes. Another has a huge untouched monthly pool. A third is nearly
|
|
77
|
+
exhausted and expires tonight.
|
|
78
|
+
|
|
79
|
+
A price-per-token router picks the same model every time. Whichever is cheapest on
|
|
80
|
+
paper. It ignores that the first allowance is about to evaporate, that the second
|
|
81
|
+
is your real safety margin, and that the third will be wasted if nobody spends it.
|
|
82
|
+
|
|
83
|
+
The smart router ranks by what a request actually costs *you*: the modelled
|
|
84
|
+
allowance it consumes, adjusted for how much of that allowance would otherwise be
|
|
85
|
+
lost. When a pool is about to expire and mostly unspent, spending it is close to
|
|
86
|
+
free. When a pool is nearly gone, it stops being attractive.
|
|
87
|
+
|
|
88
|
+
<p align="center">
|
|
89
|
+
<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">
|
|
90
|
+
</p>
|
|
91
|
+
|
|
92
|
+
## Install
|
|
93
|
+
|
|
94
|
+
Requires Node 20.12+, npm and git. No `jq`, no global CLI, nothing to configure.
|
|
95
|
+
|
|
96
|
+
### From npm
|
|
97
|
+
|
|
98
|
+
Add the plugin to your `opencode.json`; OpenCode installs it from npm at startup:
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"$schema": "https://opencode.ai/config.json",
|
|
103
|
+
"plugin": ["@qadanm/opencode-smart-router"]
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
That is the routing core in one self-contained file. The full experience (desktop
|
|
108
|
+
panel, terminal commands, history import, first-run onboarding) comes from the
|
|
109
|
+
installer below.
|
|
110
|
+
|
|
111
|
+
### Full experience (recommended)
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
curl -fsSL https://raw.githubusercontent.com/qadanm/opencode-smart-router/main/install | bash
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Or from a clone:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
git clone https://github.com/qadanm/opencode-smart-router.git
|
|
121
|
+
cd opencode-smart-router && ./install
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
It validates the source, runs the full suite, backs up your config, builds, installs,
|
|
125
|
+
and confirms the bundle loads. Every check runs before anything is written, so a
|
|
126
|
+
failed install leaves your machine exactly as it was.
|
|
127
|
+
|
|
128
|
+
Non-interactive, for CI or an agent:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
./install --yes
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
See what it would do without touching anything:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
./install --dry-run
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Restart OpenCode, then run `/router-status` in any session.
|
|
141
|
+
|
|
142
|
+
Already using it? Run `./install` again to update. Installs are versioned, so a
|
|
143
|
+
running session keeps the module it loaded and picks up the new one on next start.
|
|
144
|
+
|
|
145
|
+
**[Full install, verify and rollback guide →](docs/INSTALL.md)**
|
|
146
|
+
|
|
147
|
+
## How it decides
|
|
148
|
+
|
|
149
|
+
<p align="center">
|
|
150
|
+
<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">
|
|
151
|
+
</p>
|
|
152
|
+
|
|
153
|
+
Every prompt is classified locally, in about a millisecond, from its structure
|
|
154
|
+
rather than its wording. The router reads scope, attachments, agent type, context
|
|
155
|
+
size and recent failures, then holds substantive engineering work to an
|
|
156
|
+
intelligence floor and requires native image input for anything visual.
|
|
157
|
+
|
|
158
|
+
Then it does the part that matters:
|
|
159
|
+
|
|
160
|
+
1. **Eligibility first.** A model is discarded if a plan cannot fund it, if a
|
|
161
|
+
provider allowance is exhausted, if the task needs vision the model lacks, or
|
|
162
|
+
if the intelligence floor excludes it. Refusals are recorded with the reason.
|
|
163
|
+
2. **Price against the plan.** Cost is the modelled allowance a request consumes
|
|
164
|
+
in the windows that will actually be charged, not a per-token rate.
|
|
165
|
+
3. **Discount what is expiring.** Prepaid allowance about to reset is priced near
|
|
166
|
+
its probability of being wasted. It is a bounded discount, never free, because
|
|
167
|
+
a pool with no headroom cannot carry the work.
|
|
168
|
+
4. **Prefer staying put.** Switching discards a warm prompt cache, which costs real
|
|
169
|
+
money. The router only moves when the saving clears a margin.
|
|
170
|
+
|
|
171
|
+
When a genuinely near-expiry window and a cheap provider disagree, the router picks
|
|
172
|
+
the option that spends money you have already committed to losing.
|
|
173
|
+
|
|
174
|
+
## Self-healing routing
|
|
175
|
+
|
|
176
|
+
Some providers reject a rebuilt session outright. OpenCode turns mid-session
|
|
177
|
+
notices into system-role messages, and the Anthropic protocol only accepts user
|
|
178
|
+
and assistant roles, so one notice kills the whole turn with a 400 error.
|
|
179
|
+
|
|
180
|
+
The router handles this before it can happen. For Anthropic-protocol models it
|
|
181
|
+
folds system entries after the first message into user messages on the way out,
|
|
182
|
+
so the request leaves in a shape the provider accepts. Utility requests, like
|
|
183
|
+
title generation, are left alone.
|
|
184
|
+
|
|
185
|
+
When a provider still says no, a 400 that would reproduce on every retry gets
|
|
186
|
+
its own failure class and the host's retry is refused. The router holds the
|
|
187
|
+
rejecting model back for five minutes, re-routes the same task to the next best
|
|
188
|
+
eligible choice, and continues the turn with a single `[router]` notice. One
|
|
189
|
+
failover per prompt, never a loop. The hold covers every session, because a
|
|
190
|
+
gateway that rejects one rebuilt session will reject others like it. The
|
|
191
|
+
panel's last decision records what was rejected and what took over.
|
|
192
|
+
|
|
193
|
+
## What it will not do
|
|
194
|
+
|
|
195
|
+
- Never buys credits, changes billing, or enables top-ups.
|
|
196
|
+
- Never routes to a provider you did not approve.
|
|
197
|
+
- Never spends the plan-model pool without the budget you set; in the default
|
|
198
|
+
auto mode tier-3 work escalates to it automatically, and when it runs out the
|
|
199
|
+
router falls back to the strongest ordinary model instead of failing.
|
|
200
|
+
- Never switches a session to a model OpenCode cannot currently resolve.
|
|
201
|
+
- Never fails open on missing quota data when other providers already reported.
|
|
202
|
+
- Never blocks your other work when one provider is at its limit.
|
|
203
|
+
- **Never stops you sending a message.** If the router breaks, it is skipped.
|
|
204
|
+
|
|
205
|
+
### When the plugin is wrong, the editor still works
|
|
206
|
+
|
|
207
|
+
Routing is an optimization. OpenCode works without it, and it keeps working when
|
|
208
|
+
the router is wrong. That is a contract, not a best effort:
|
|
209
|
+
|
|
210
|
+
- an internal defect degrades to "do not route", never to "do not work". The
|
|
211
|
+
prompt is served with the model already selected and the reason is recorded in
|
|
212
|
+
warnings;
|
|
213
|
+
- every hook is wrapped so it cannot throw into OpenCode, and routing runs
|
|
214
|
+
against a deadline so it cannot hold a prompt indefinitely;
|
|
215
|
+
- if setup fails for a location, that location simply has no router;
|
|
216
|
+
- a shared core left behind by a different plugin version is replaced rather
|
|
217
|
+
than reused.
|
|
218
|
+
|
|
219
|
+
Blocking a prompt is available, but it is opt-in through
|
|
220
|
+
`routing.failClosed: true`, and it covers plugin faults only. Quota safety is
|
|
221
|
+
separate and always on: a provider configured for probing with no reading is
|
|
222
|
+
refused, because that is a spending decision rather than a response to failure.
|
|
223
|
+
|
|
224
|
+
This was made a contract the hard way. Version 0.6.0 turned an internal
|
|
225
|
+
`TypeError` into a refusal to send any prompt, in every mode, and the plugin had
|
|
226
|
+
to be removed by hand. The account is in
|
|
227
|
+
[docs/INCIDENT-2026-11-06-prompt-lockout.md](docs/INCIDENT-2026-11-06-prompt-lockout.md),
|
|
228
|
+
and the guarantee is tested against a real OpenCode process by
|
|
229
|
+
`scripts/isolated-e2e.sh`, not only by unit tests.
|
|
230
|
+
|
|
231
|
+
## Status
|
|
232
|
+
|
|
233
|
+
Version 0.6.1. Routing runs on the maintainer's machine in automatic mode;
|
|
234
|
+
tier-3 tasks escalate to the plan-model pool automatically within its budget. Current behavior
|
|
235
|
+
and open questions are in
|
|
236
|
+
[docs/PRO-MIGRATION-2026-10-29.md](docs/PRO-MIGRATION-2026-10-29.md).
|
|
237
|
+
|
|
238
|
+
Published on npm as `@qadanm/opencode-smart-router`. The unscoped name
|
|
239
|
+
`opencode-smart-router` belongs to an unrelated GPL project, so this package
|
|
240
|
+
lives under a scope.
|
|
241
|
+
|
|
242
|
+
## Commands
|
|
243
|
+
|
|
244
|
+
Everything is a native slash command. No GUI needed, and routing works with the
|
|
245
|
+
command panel closed.
|
|
246
|
+
|
|
247
|
+
| Command | Purpose |
|
|
248
|
+
|---|---|
|
|
249
|
+
| `/router-status` | Runtime mode, selected model, task tier, quota, warnings |
|
|
250
|
+
| `/router-explain` | Last decision, signals, candidate costs, reasons |
|
|
251
|
+
| `/router-why` | Why this model, for the prompt you just sent |
|
|
252
|
+
| `/router-report` | Local usage, switches, premium prevention, retries, latency |
|
|
253
|
+
| `/router-floor` | The intelligence floor in force and what it admits |
|
|
254
|
+
| `/router-mode efficiency\|balanced\|premium` | Change routing mode |
|
|
255
|
+
| `/router-dry-run on\|off` | Explain decisions without changing the model |
|
|
256
|
+
| `/router-auto` | Clear a manual override, resume automatic routing |
|
|
257
|
+
| `/router-pin provider/model` | Pin a model for this session |
|
|
258
|
+
| `/router-exclude provider/model` | Exclude a model from automatic routing |
|
|
259
|
+
| `/router-approve` | Approve one premium request for this session |
|
|
260
|
+
| `/router-pause` / `/router-resume` | Pause or resume routing globally |
|
|
261
|
+
| `/router-premium-budget <usd>` | Set the premium budget |
|
|
262
|
+
| `/router-reserve provider monthly weekly fiveHour` | Adjust provider reserves |
|
|
263
|
+
| `/router-reset-metrics --confirm` | Reset metrics and routing state, not conversations |
|
|
264
|
+
| `/router-title off` | Strip prefixes this plugin wrote into titles before 0.5.0 |
|
|
265
|
+
|
|
266
|
+
Selecting a model in the desktop picker is detected and respected. The session
|
|
267
|
+
reports `manual` until you run `/router-auto`.
|
|
268
|
+
|
|
269
|
+
## Configuration
|
|
270
|
+
|
|
271
|
+
The defaults are safe and need no file. To change them:
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
cp config.example.json ~/.config/opencode/smart-router.json
|
|
275
|
+
chmod 600 ~/.config/opencode/smart-router.json
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
A project may add `.opencode/smart-router.json`. A project file can tighten policy.
|
|
279
|
+
It cannot loosen global restrictions.
|
|
280
|
+
|
|
281
|
+
Worth knowing:
|
|
282
|
+
|
|
283
|
+
- Mode: `balanced`
|
|
284
|
+
- Premium: `auto`, $120/month plan-model pool, one-shot approval available.
|
|
285
|
+
Auto mode escalates tier-3 tasks to the plan-model pool (Sonnet 5.5,
|
|
286
|
+
GPT-6.1 Sol) without asking, and degrades to an approval gate when the pool
|
|
287
|
+
is spent. Tier-1 and tier-2 work always stays on the ordinary pool.
|
|
288
|
+
- Training-enabled and free promotional models: blocked
|
|
289
|
+
- Purchased extra credits: blocked
|
|
290
|
+
- Prompt text storage: disabled
|
|
291
|
+
- Quota polling: every 5 minutes, with backoff
|
|
292
|
+
- Provider reserves: 10% monthly, 15% weekly, 10% five-hour
|
|
293
|
+
|
|
294
|
+
### Credential discovery
|
|
295
|
+
|
|
296
|
+
Credentials are read from explicit paths, never guessed from arbitrary files.
|
|
297
|
+
Nothing is logged. `safeError()` and `redact()` exist so a failure message cannot
|
|
298
|
+
leak a key. The full model is in [docs/INSTALL.md](docs/INSTALL.md#credentials).
|
|
299
|
+
|
|
300
|
+
## The portable core
|
|
301
|
+
|
|
302
|
+
`dist/allowance.js` is a standalone module with no host dependency. You can use it
|
|
303
|
+
without OpenCode, to answer "when does this plan run out":
|
|
304
|
+
|
|
305
|
+
```js
|
|
306
|
+
import { projectExhaustion } from "./dist/allowance.js"
|
|
307
|
+
|
|
308
|
+
projectExhaustion({
|
|
309
|
+
usedUsd: 12.4,
|
|
310
|
+
capUsd: 30,
|
|
311
|
+
windowMs: 7 * 24 * 60 * 60 * 1000,
|
|
312
|
+
elapsedMs: 3.1 * 24 * 60 * 60 * 1000,
|
|
313
|
+
label: "weekly",
|
|
314
|
+
})
|
|
315
|
+
// { willExhaust: true, exhaustsAt, explanation: "...runs out about 6.2 hours from now." }
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
It reads no files, opens no socket, inspects no environment variable, and imports
|
|
319
|
+
nothing from the host. A test enforces that, so it cannot drift.
|
|
320
|
+
|
|
321
|
+
## Tests
|
|
322
|
+
|
|
323
|
+
```bash
|
|
324
|
+
npm test # the suite
|
|
325
|
+
npm run verify # clean room: empty HOME, fresh install, typecheck, tests, builds
|
|
326
|
+
npm run mutate # prove each safety test fails when its property breaks
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
822 tests across 75 files cover classification, eligibility, quota parsing,
|
|
330
|
+
credential discovery and its security properties, ledger accounting, pool
|
|
331
|
+
reconciliation, documented peak pricing, cache-aware routing, premium protection,
|
|
332
|
+
purchased-credit blocking, manual overrides, failure classification, configuration
|
|
333
|
+
guardrails, context policy, quota honesty, the portable allowance API, and
|
|
334
|
+
controller integration.
|
|
335
|
+
|
|
336
|
+
Two commands exist because passing tests prove less than they appear to. `verify`
|
|
337
|
+
repeats everything in a clean room with an empty `HOME`, so a pass cannot depend on
|
|
338
|
+
this machine. `mutate` reverts each of 62 safety properties in turn and fails if the
|
|
339
|
+
suite still passes, which is the only way to know a test is guarding anything.
|
|
340
|
+
|
|
341
|
+
Live evidence is in [docs/QA.md](docs/QA.md).
|
|
342
|
+
|
|
343
|
+
## Design notes
|
|
344
|
+
|
|
345
|
+
A few decisions that are easy to get wrong, and why they are the way they are:
|
|
346
|
+
|
|
347
|
+
- **Offline enforcement is an allow-list of one provider**, not a deny-list of
|
|
348
|
+
known hosted ones. A deny-list fails open the moment someone adds a provider.
|
|
349
|
+
- **A saturated gateway is not a broken model.** Capacity refusals hold the
|
|
350
|
+
endpoint back without charging the failure to the single model behind it.
|
|
351
|
+
- **Quota that expires unused is a sunk cost**, so the router prices it near its
|
|
352
|
+
probability of being wasted instead of at sticker price.
|
|
353
|
+
- **A refusal outlives the reading that produced it.** An exhausted allowance stays
|
|
354
|
+
excluded until a fresh probe clears it.
|
|
355
|
+
|
|
356
|
+
More in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
357
|
+
|
|
358
|
+
## Contributing
|
|
359
|
+
|
|
360
|
+
Bug reports and pull requests are welcome. Before opening a PR:
|
|
361
|
+
|
|
362
|
+
```bash
|
|
363
|
+
npm run check # typecheck + full suite
|
|
364
|
+
npm run verify # the same, in a clean room with an empty HOME
|
|
365
|
+
npm run mutate # prove the safety tests still catch their properties
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
A change that alters routing behavior needs a test that fails without it. See
|
|
369
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) for conventions, and [SECURITY.md](SECURITY.md)
|
|
370
|
+
for reporting a vulnerability.
|
|
371
|
+
|
|
372
|
+
## Releases
|
|
373
|
+
|
|
374
|
+
Releases are cut from `main` and published by CI, not from a laptop. The package
|
|
375
|
+
is published with [npm trusted publishing](https://docs.npmjs.com/trusted-publishers),
|
|
376
|
+
which exchanges a GitHub Actions identity for a short-lived publish credential, so
|
|
377
|
+
there is no `NPM_TOKEN` in this repository and no one-time password to relay.
|
|
378
|
+
|
|
379
|
+
1. Bump `version` in `package.json` and add the matching `CHANGELOG.md` section.
|
|
380
|
+
2. Run `npm run check`, commit, and push to `main`.
|
|
381
|
+
3. Tag and push the tag:
|
|
382
|
+
|
|
383
|
+
```bash
|
|
384
|
+
git tag -a v0.6.1 -m "0.6.1: <one line>"
|
|
385
|
+
git push origin main --tags
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
4. `.github/workflows/release.yml` checks the tag against `package.json`,
|
|
389
|
+
re-runs the full suite, and publishes.
|
|
390
|
+
5. Create the GitHub release from the changelog section:
|
|
391
|
+
|
|
392
|
+
```bash
|
|
393
|
+
gh release create v0.6.1 --title "0.6.1: <one line>" --notes-file notes.md
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
If the tag and `package.json` disagree, the workflow refuses to publish: a
|
|
397
|
+
version that reaches the registry can never be replaced.
|
|
398
|
+
|
|
399
|
+
### One-time npm setup
|
|
400
|
+
|
|
401
|
+
Trusted publishing is authorized on the npm side, not in this repository. On the
|
|
402
|
+
package settings page for `@qadanm/opencode-smart-router`, add a trusted publisher:
|
|
403
|
+
|
|
404
|
+
| Field | Value |
|
|
405
|
+
|---|---|
|
|
406
|
+
| Provider | GitHub Actions |
|
|
407
|
+
| Organization or user | `qadanm` |
|
|
408
|
+
| Repository | `opencode-smart-router` |
|
|
409
|
+
| Workflow filename | `release.yml` |
|
|
410
|
+
| Environment | (leave empty) |
|
|
411
|
+
|
|
412
|
+
Until that entry exists, the release workflow fails at the publish step with a
|
|
413
|
+
`404 Not Found`, which is npm's way of saying the OIDC identity is not on the
|
|
414
|
+
package's trusted list. The check, build, and tag verification still run and
|
|
415
|
+
still gate the release.
|
|
416
|
+
|
|
417
|
+
## License
|
|
418
|
+
|
|
419
|
+
MIT. See [LICENSE](LICENSE).
|
|
420
|
+
|
|
421
|
+
Copyright (c) 2026 Moe Qadan.
|
|
422
|
+
|
|
423
|
+
Use it, modify it, ship it, sell it. Keep the copyright notice and permission text
|
|
424
|
+
with any substantial copy, and understand that it comes with no warranty. The
|
|
425
|
+
routing decisions it makes are estimates from modelled quota data, not guarantees
|
|
426
|
+
from your providers.
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
## Reporting a vulnerability
|
|
4
|
+
|
|
5
|
+
Open a private security advisory at
|
|
6
|
+
`https://github.com/qadanm/opencode-smart-router/security/advisories/new`, or
|
|
7
|
+
email the maintainer listed in `package.json`. Please don't open a public issue
|
|
8
|
+
for a suspected vulnerability.
|
|
9
|
+
|
|
10
|
+
Include what you can: the version or commit, your configuration, what you saw,
|
|
11
|
+
and the smallest reproduction. You'll get an acknowledgment within a few days.
|
|
12
|
+
This is a single-maintainer project with no paid support, so those dates are
|
|
13
|
+
intentions, not a contract.
|
|
14
|
+
|
|
15
|
+
## What this software does with credentials
|
|
16
|
+
|
|
17
|
+
A tool that reads credentials from disk owes you a precise account of which files
|
|
18
|
+
it touches, why, and what happens to the values.
|
|
19
|
+
|
|
20
|
+
### The short version
|
|
21
|
+
|
|
22
|
+
- The router reads **bearer credentials you already store on your own machine**,
|
|
23
|
+
for providers you already connected.
|
|
24
|
+
- It sends a credential **only** to that credential's own provider, in an
|
|
25
|
+
`Authorization` header, either to read usage or to make an inference request you
|
|
26
|
+
asked for.
|
|
27
|
+
- It **never** logs, prints, persists, or transmits a credential anywhere else.
|
|
28
|
+
- Automatic discovery from predictable paths is **off by default**.
|
|
29
|
+
|
|
30
|
+
### Discovery requires opt-in
|
|
31
|
+
|
|
32
|
+
Reading well-known credential files requires either:
|
|
33
|
+
|
|
34
|
+
```jsonc
|
|
35
|
+
// ~/.config/opencode/smart-router.json
|
|
36
|
+
{ "providers": { "commandcode": { "credentialSource": "auto" } } }
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
or the environment flag `SMART_ROUTER_ALLOW_KEY_DISCOVERY=1`.
|
|
40
|
+
|
|
41
|
+
Without one of those, the router reads only:
|
|
42
|
+
|
|
43
|
+
- credentials named by `credentialPath` in configuration, and
|
|
44
|
+
- credentials in the environment (`COMMAND_CODE_API_KEY`, `OPENDESIGN_API_KEY`)
|
|
45
|
+
or in the provider settings the host already holds.
|
|
46
|
+
|
|
47
|
+
`credentialSource: "off"` disables filesystem reads entirely.
|
|
48
|
+
|
|
49
|
+
### It does not read other applications' credential stores
|
|
50
|
+
|
|
51
|
+
Only paths belonging to this router's configuration directory or to the named
|
|
52
|
+
provider are considered. The router **does not read OpenCode's own `auth.json`**.
|
|
53
|
+
That file is another application's unified store: it holds OAuth material for
|
|
54
|
+
every provider you connected, and reading it would mean handling tokens the
|
|
55
|
+
router was never asked about.
|
|
56
|
+
|
|
57
|
+
If you find a code path that reads a foreign credential store, treat it as a
|
|
58
|
+
vulnerability and report it.
|
|
59
|
+
|
|
60
|
+
### Filesystem hardening
|
|
61
|
+
|
|
62
|
+
Each credential file is:
|
|
63
|
+
|
|
64
|
+
- opened **once**, with `O_NOFOLLOW`, so a symlink cannot redirect the read;
|
|
65
|
+
- checked against the **open descriptor** instead of a separate `stat` call,
|
|
66
|
+
which closes a time-of-check/time-of-use race where the path could be swapped
|
|
67
|
+
between the permission check and the read;
|
|
68
|
+
- required to be a **regular file** with **no group or world permission bits**.
|
|
69
|
+
Anything else is refused, because on a shared machine another account could
|
|
70
|
+
otherwise influence which credential the router picks;
|
|
71
|
+
- **capped at 64 KiB** before any read is attempted;
|
|
72
|
+
- always **closed**, on every path out of the function.
|
|
73
|
+
|
|
74
|
+
A file found by discovery must also be owned by the current user. See the next
|
|
75
|
+
section for why that check is narrower than it sounds.
|
|
76
|
+
|
|
77
|
+
#### Containers and bind mounts
|
|
78
|
+
|
|
79
|
+
The ownership check compares a **discovered** file's uid with `process.getuid()`.
|
|
80
|
+
Inside a container the process often runs as a different uid than the one that
|
|
81
|
+
owns the mounted file, so discovery refuses it. That is deliberate. When scanning
|
|
82
|
+
well-known locations, the router cannot tell "my own secret under another uid"
|
|
83
|
+
from "someone else's secret."
|
|
84
|
+
|
|
85
|
+
A file you **name** (`credentialPath`, `controlKeyPath`) skips the uid comparison,
|
|
86
|
+
because naming a path is already a deliberate trust decision. The group and world
|
|
87
|
+
permission check still applies to named files.
|
|
88
|
+
|
|
89
|
+
If discovery refuses a credential in a container, you have three options:
|
|
90
|
+
|
|
91
|
+
1. run the process as the file's owner (`--user $(id -u):$(id -g)`);
|
|
92
|
+
2. `chown` the mounted secret to the container's uid;
|
|
93
|
+
3. name the file with `credentialPath`, or pass the credential through the
|
|
94
|
+
environment (`COMMAND_CODE_API_KEY`, `OPENDESIGN_API_KEY`).
|
|
95
|
+
|
|
96
|
+
No configuration switch disables the permission check itself.
|
|
97
|
+
|
|
98
|
+
### Secrets do not reach output
|
|
99
|
+
|
|
100
|
+
- No credential is written to the ledger, a routing decision, a session title, a
|
|
101
|
+
diagnostic bundle, an event, or a log line.
|
|
102
|
+
- Error strings from a credential read pass through `redact()`.
|
|
103
|
+
- Diagnostic output identifies a credential source by a fixed label and, for
|
|
104
|
+
files, the **final path segment only**: `file (commandcode.key)`. Never a path
|
|
105
|
+
prefix, never a value. An unrecognized source string is reported as `configured`
|
|
106
|
+
instead of being echoed, so a caller cannot turn that field into an arbitrary
|
|
107
|
+
channel into a persisted note.
|
|
108
|
+
- Warnings *do* name the path that was rejected, for example "Ignoring Command
|
|
109
|
+
Code credential file with group/world permissions: /path/to/key". You need the
|
|
110
|
+
path to fix the permission. It is not a secret, but on a multi-user machine it
|
|
111
|
+
does reveal part of a directory layout. Keep credentials inside your home
|
|
112
|
+
directory if that matters.
|
|
113
|
+
- `test/credentials-security.test.ts` asserts each of these properties.
|
|
114
|
+
|
|
115
|
+
### The privileged control key
|
|
116
|
+
|
|
117
|
+
An OpenDesign account-control key can read billing state, so it is treated as a
|
|
118
|
+
privileged credential:
|
|
119
|
+
|
|
120
|
+
- it is **never** discovered from the environment or by scanning;
|
|
121
|
+
- it is read **only** when you name a file with `controlKeyPath`;
|
|
122
|
+
- the router never requests, generates, or acquires one.
|
|
123
|
+
|
|
124
|
+
### No spending
|
|
125
|
+
|
|
126
|
+
The router is subscription-only by default. It does not spend wallet balance,
|
|
127
|
+
does not enable automatic top-up, and does not fall back to pay-as-you-go pricing
|
|
128
|
+
unless `allowExtraUsage: true` is set explicitly.
|
|
129
|
+
|
|
130
|
+
## Data this software stores
|
|
131
|
+
|
|
132
|
+
The local ledger records, per request: timestamp, provider, model, token counts
|
|
133
|
+
(input, output, cache read, cache write), an estimated dollar cost, and an
|
|
134
|
+
outcome. It lives in the host's local database.
|
|
135
|
+
|
|
136
|
+
It does **not** store prompt text, file contents, model responses, or
|
|
137
|
+
credentials. Prompt text storage is a separate setting (`logging.storePromptText`)
|
|
138
|
+
that is off by default and that this project does not recommend turning on.
|
|
139
|
+
|
|
140
|
+
Aged usage events fold into hourly aggregates with the same fields, so totals
|
|
141
|
+
survive without keeping per-request detail forever.
|
|
142
|
+
|
|
143
|
+
## Network endpoints
|
|
144
|
+
|
|
145
|
+
The router contacts only:
|
|
146
|
+
|
|
147
|
+
| Purpose | Host |
|
|
148
|
+
|---|---|
|
|
149
|
+
| Model inference | whichever provider the host selected |
|
|
150
|
+
| OpenCode Go usage | `opencode.ai` |
|
|
151
|
+
| Command Code usage | `api.commandcode.ai` (read-only `GET` on undocumented alpha routes) |
|
|
152
|
+
| OpenDesign usage | the configured gateway host (read-only, only with a control key) |
|
|
153
|
+
|
|
154
|
+
There is no telemetry, no analytics endpoint, and no update check. Two
|
|
155
|
+
qualifications on that:
|
|
156
|
+
|
|
157
|
+
- **`status` and `usage` make no inference call.** They spend no tokens and cost
|
|
158
|
+
you nothing.
|
|
159
|
+
- **`status` may start a background quota probe** if a cached snapshot is older
|
|
160
|
+
than `quota.probeIntervalMinutes` (5 by default). The probe is a `GET` to a
|
|
161
|
+
provider's usage endpoint. It runs asynchronously and the command does not wait
|
|
162
|
+
for it. With `probeUsage: false` on a provider, no probe happens.
|
|
163
|
+
|
|
164
|
+
## Supply chain
|
|
165
|
+
|
|
166
|
+
The runtime has one dependency, `@opencode/plugin`, which is the host's own
|
|
167
|
+
plugin SDK. The portable allowance core
|
|
168
|
+
(`@qadanm/opencode-smart-router/allowance`) bundles to a single file with **zero
|
|
169
|
+
external imports**, and a test enforces that it imports no Node built-ins and no
|
|
170
|
+
host modules.
|
|
171
|
+
|
|
172
|
+
## Supported versions
|
|
173
|
+
|
|
174
|
+
This is pre-1.0 software. Security fixes land on the latest version only.
|