@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
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
## Runtime path
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
OpenCode prompt admission
|
|
7
|
+
→ session.hook("prompt")
|
|
8
|
+
→ load session model and context tokens
|
|
9
|
+
→ local classifier
|
|
10
|
+
→ hard-requirement eligibility
|
|
11
|
+
→ provider and per-model quota snapshots
|
|
12
|
+
→ expected-cost ranking with cache-loss and hysteresis
|
|
13
|
+
→ session.switchModel(...) when a switch is justified
|
|
14
|
+
→ admission completes
|
|
15
|
+
→ request dispatch
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The plugin does not modify the prompt text, attachments, tools, or agent. It never injects a classification prompt and never calls a model to decide routing.
|
|
19
|
+
|
|
20
|
+
## Modules
|
|
21
|
+
|
|
22
|
+
| Module | Responsibility |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `src/core/models/seed.ts` | Curated model intelligence, design, tier, privacy, and allowance metadata |
|
|
25
|
+
| `src/core/models/registry.ts` | Merges live OpenCode metadata, approval state, exclusions, and tier overrides |
|
|
26
|
+
| `src/core/classify/classifier.ts` | Deterministic task tier, kind, vision, design, scope, and constraints |
|
|
27
|
+
| `src/core/policy/eligibility.ts` | Hard requirements, privacy, premium policy, context fit, quota blocks |
|
|
28
|
+
| `src/core/quota/ledger.ts` | Bounded local usage ledger, rolling windows, per-model estimates, premium spend |
|
|
29
|
+
| `src/core/quota/commandcode.ts` | Read-only Command Code alpha billing adapter |
|
|
30
|
+
| `src/core/quota/opencode-go.ts` | Read-only Go usage adapter |
|
|
31
|
+
| `src/core/quota/credentials.ts` | Owner-only credential discovery; never logs or persists keys |
|
|
32
|
+
| `src/core/quota/service.ts` | Caching, polling, stale detection, per-provider backoff |
|
|
33
|
+
| `src/core/routing/engine.ts` | Cost estimation, success probability, quota pressure, affinity, decision record |
|
|
34
|
+
| `src/core/failures.ts` | Failure classification and bounded escalation |
|
|
35
|
+
| `src/core/config.ts` | Defaults, validation, global/project precedence, project guardrails |
|
|
36
|
+
| `src/server/shared-core.ts` | One process-wide core: ledger, quota service, persistence, event stream, session ownership, catalog cache |
|
|
37
|
+
| `src/server/controller.ts` | Per-location bridge: hooks, RPC, commands, title queue, session-local state |
|
|
38
|
+
| `src/index.ts` | Plugin definition; creates one bridge per loaded location |
|
|
39
|
+
|
|
40
|
+
## Prompt timing
|
|
41
|
+
|
|
42
|
+
The V2 prompt hook runs once during admission, before attachment and skill resolution and before the durable inbox admission. The plugin awaits `ctx.session.switchModel` inside that hook. Integration tests and live desktop tests confirmed that the request-context hook observes the new model for the same prompt.
|
|
43
|
+
|
|
44
|
+
The router skips switching when:
|
|
45
|
+
|
|
46
|
+
- routing is paused or disabled;
|
|
47
|
+
- the session is manually overridden or pinned;
|
|
48
|
+
- another routing decision is already in flight for the same session;
|
|
49
|
+
- the session is busy in an active model loop;
|
|
50
|
+
- no eligible route exists.
|
|
51
|
+
|
|
52
|
+
It never switches in the middle of a response.
|
|
53
|
+
|
|
54
|
+
## Session state and affinity
|
|
55
|
+
|
|
56
|
+
Each session tracks the last model, last routed model, context size, cache-read share, step count, failures, retry count, manual override, premium one-shot, and pause state.
|
|
57
|
+
|
|
58
|
+
The engine prices a model switch as a cache loss. It keeps the current model when the expected savings are below the configured hysteresis. Above 300K context tokens the hysteresis is wider. The router never truncates or rewrites the conversation to make a route cheaper.
|
|
59
|
+
|
|
60
|
+
## Quota model
|
|
61
|
+
|
|
62
|
+
Two layers are kept separate:
|
|
63
|
+
|
|
64
|
+
1. **Provider-reported plan state**: Go percentages, Command Code remaining credits, windows, resets, plan, and billing period.
|
|
65
|
+
2. **Local per-model ledger**: observed step costs and tokens, used where the provider has no per-model breakdown.
|
|
66
|
+
|
|
67
|
+
Provider values override the ledger for plan-level pressure. Local values supply per-model estimates. Every displayed value carries its source (`provider`, `estimate`, or `unknown`). Polling is read-only and spends no inference allowance.
|
|
68
|
+
|
|
69
|
+
The ledger keeps 2,000 *raw* usage events for per-request detail, but nothing is discarded when that cap is exceeded. Events older than a 48-hour horizon, or pushed out by the cap, are folded into hourly `UsageBucket` aggregates keyed by provider, model and billing period, retained for 400 days. Aggregation reads both sources, so a heavy week stays fully accounted at a few hundred rows instead of losing its own beginning.
|
|
70
|
+
|
|
71
|
+
History is also preserved across an upgrade: hydrating a ledger written before aggregates existed folds the overflow into buckets rather than applying the old truncation.
|
|
72
|
+
|
|
73
|
+
Decisions (120) and sessions (250) remain bounded, and writes are debounced and JSON-sanitized.
|
|
74
|
+
|
|
75
|
+
Plan definitions live in `src/core/plans/catalog.ts` as data, not as branches in routing or quota code. A plan is a set of credit pools plus the window rule for each pool, so supporting a different user's tier requires no source change, and a provider's plan strings never appear in the routing or policy layers.
|
|
76
|
+
|
|
77
|
+
## Concurrency
|
|
78
|
+
|
|
79
|
+
- One shared core is active per server process; each loaded location registers a lightweight bridge.
|
|
80
|
+
- A reload disposes only that location’s bridge. The core persists across reloads, stops its timers after a grace period, and revives on the next setup.
|
|
81
|
+
- Prompt routing is serialized per session. Concurrent submissions for the same session do not double-switch.
|
|
82
|
+
- Different sessions can route concurrently.
|
|
83
|
+
- The ledger is updated in one event loop; persistence is debounced. There is no cross-process writer.
|
|
84
|
+
- Provider probes are single-flight per provider and back off independently.
|
|
85
|
+
|
|
86
|
+
## Failure handling
|
|
87
|
+
|
|
88
|
+
- Context overflow → no escalation; recommend compaction or a handoff session.
|
|
89
|
+
- Network, rate limit, provider outage → retry/failover, never premium escalation.
|
|
90
|
+
- Auth → terminal retry decision, cooldown, no model escalation.
|
|
91
|
+
- Tool or command failure → no model escalation.
|
|
92
|
+
- Invalid image → no text-only fallback; require a verified vision route.
|
|
93
|
+
- Capability failure → one bounded escalation opportunity, then stop.
|
|
94
|
+
- Partial generation or side effects → never automatically replayed; the next prompt is routed with the failure history.
|
|
95
|
+
|
|
96
|
+
## Security
|
|
97
|
+
|
|
98
|
+
- No API keys or tokens are logged, persisted in router state, or included in RPC output.
|
|
99
|
+
- Command Code keys are read only from owner-only files or the connected integration and sent only to `https://api.commandcode.ai`.
|
|
100
|
+
- Go OAuth tokens are used only for `https://opencode.ai/zen/go/v1/usage`.
|
|
101
|
+
- Training-enabled and free promotional models are blocked by default.
|
|
102
|
+
- Project config files cannot loosen global privacy, extra-credit, or premium policy.
|
|
103
|
+
- The router does not read repository files to classify a prompt; it uses prompt structure and attachments only.
|
|
104
|
+
- Prompt text is not stored by default. Decision records contain signals and reasons, not the prompt.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Debugging the desktop panel
|
|
2
|
+
|
|
3
|
+
Written after a session where the panel vanished and four plausible causes were
|
|
4
|
+
investigated before the real one. Read this before touching GUI code.
|
|
5
|
+
|
|
6
|
+
## Check the server version first
|
|
7
|
+
|
|
8
|
+
The desktop panel needs **OpenCode Desktop 2.2.x or newer**. A 2.1.x server has no
|
|
9
|
+
GUI plugin API: it will not report `gui` in `/api/plugin`, and it cannot load
|
|
10
|
+
`gui-experimental.cjs` at all.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
opencode api get /api/info # must say 2.2.x
|
|
14
|
+
opencode api get /api/plugin # opcode-smart-router must have features.gui: true
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
**The common trap:** `opencode` on `PATH` may be an older Homebrew install than
|
|
18
|
+
the app. Running `opencode service restart` then restarts the *old* server, and it
|
|
19
|
+
takes the port the app was using. The app reconnects to it, and the panel stops
|
|
20
|
+
loading with no useful error.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
which opencode && opencode --version # CLI on PATH
|
|
24
|
+
ls ~/Library/Application\ Support/ai.opencode.desktop/cli/ # app's bundled CLI
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
They must both be 2.2.x. If they are not, either `brew upgrade opencode-v2` or
|
|
28
|
+
restart the app itself (`⌘Q`, then relaunch) instead of using the service command.
|
|
29
|
+
The app spawns its own bundled server, which is the one that can load a GUI.
|
|
30
|
+
|
|
31
|
+
## Then check the id rules
|
|
32
|
+
|
|
33
|
+
Documented in the GUI guide, and easy to misread:
|
|
34
|
+
|
|
35
|
+
> A plugin whose id "nests under one with a dot, has one nested under it, or is in
|
|
36
|
+
> the `opencode` namespace" does not load its GUI.
|
|
37
|
+
|
|
38
|
+
Concretely, this is wrong:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
server part id: "opencode-smart-router"
|
|
42
|
+
tui part id: "opencode-smart-router.tui" ◄ nests under the server id
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The TUI id must be a sibling, not a child. `test/first-run.test.ts` asserts that no
|
|
46
|
+
two plugin ids nest, because the failure costs the entire panel and produces only
|
|
47
|
+
a generic request failure in the app.
|
|
48
|
+
|
|
49
|
+
## What the symptoms actually mean
|
|
50
|
+
|
|
51
|
+
| Symptom | Usual cause |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `Request failed / UnsupportedContentType` | Client and server versions disagree, or the GUI never registered |
|
|
54
|
+
| Tab strip resets to the new-tab page | No GUI entry was loaded for that plugin |
|
|
55
|
+
| `features` has no `gui` | Server too old, or the plugin id nests |
|
|
56
|
+
| Panel renders but RPC fails | Server part failed to load — a plugin with no server part has no GUI |
|
|
57
|
+
| Panel blank, no error | Something in `setup()` threw. A GUI whose setup throws loses the plugin, not just the panel |
|
|
58
|
+
|
|
59
|
+
## Rules that keep this debuggable
|
|
60
|
+
|
|
61
|
+
1. **`features.gui: true` is the single fact to check first.** It answers "did the
|
|
62
|
+
app even find my GUI" before any speculation about bundles, exports or code.
|
|
63
|
+
2. **Never gate a required surface on an optional check.** The panel once rendered
|
|
64
|
+
as an empty box because it waited for `onboarding()` to settle, and that call
|
|
65
|
+
did not. Show the normal state; let optional data arrive later.
|
|
66
|
+
3. **A convenience must never be able to break the product.** The auto-open path
|
|
67
|
+
touches four host APIs inside `setup()`; it is wrapped so a throw cannot remove
|
|
68
|
+
the plugin.
|
|
69
|
+
4. **Test the version you think you are testing.** `/api/info` reports the server
|
|
70
|
+
actually running, which is not necessarily the one you started.
|
package/docs/INSTALL.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Install, verify, and remove
|
|
2
|
+
|
|
3
|
+
## Install
|
|
4
|
+
|
|
5
|
+
One command, from a checkout. Requires Node 20.12+, npm and git. No `jq`.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
./install # interactive
|
|
9
|
+
./install --yes # non-interactive, for CI or an LLM
|
|
10
|
+
./install --dry-run # print every step, change nothing
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
It validates the source (typecheck and the full suite), backs up your config,
|
|
14
|
+
builds the bundle, installs it, adds exactly one entry to `plugins`, and confirms
|
|
15
|
+
the bundle loads. Any failure exits non-zero and leaves the machine untouched,
|
|
16
|
+
because every check runs before anything is written.
|
|
17
|
+
|
|
18
|
+
The install is **versioned and additive**. Each run writes to its own directory:
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
~/.config/opencode/plugins/opencode-smart-router/<timestamp>/index.js
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
and only the config pointer changes. A running OpenCode server keeps the module it
|
|
25
|
+
already loaded; the new version takes effect on the next start. This matters more
|
|
26
|
+
than it sounds: replacing files inside the watched plugin directory invalidates the
|
|
27
|
+
plugin module mid-session and makes the provider's model list go empty, which shows
|
|
28
|
+
up as `Model unavailable: <provider>/<model>` for a model that is perfectly fine.
|
|
29
|
+
|
|
30
|
+
## Verify
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
./install --dry-run # reports the current state without changing it
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Then restart OpenCode and run `/router-status` in a session. It prints the active
|
|
37
|
+
plan, the allowance windows, and the model the router would pick.
|
|
38
|
+
|
|
39
|
+
## Disable without uninstalling
|
|
40
|
+
|
|
41
|
+
Set `"enabled": false` in `~/.config/opencode/smart-router.json` and restart. The
|
|
42
|
+
plugin stays installed and inert; your config, credentials and providers are
|
|
43
|
+
untouched either way.
|
|
44
|
+
|
|
45
|
+
## Uninstall
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
./scripts/uninstall.sh
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Removes the plugin directory and every `plugins` entry under it, then offers the
|
|
52
|
+
exact pre-removal config as a restore point. Restart OpenCode afterwards.
|
|
53
|
+
|
|
54
|
+
## Update
|
|
55
|
+
|
|
56
|
+
Run `./install` again. It installs a new versioned directory and repoints the
|
|
57
|
+
config, so old versions can be deleted safely once the new one is confirmed:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
ls ~/.config/opencode/plugins/opencode-smart-router/
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Rollback
|
|
64
|
+
|
|
65
|
+
Every install prints the backup it made. Two options:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
./scripts/uninstall.sh # clean removal
|
|
69
|
+
./scripts/uninstall.sh --restore-backup <file> # restore an exact prior config
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Verify quota and routing
|
|
73
|
+
|
|
74
|
+
The router exposes an RPC status method:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
opencode api POST /api/rpc/smart-router/status -d '{"input":{}}'
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Use `opencode api` so authentication is handled by OpenCode. The output labels each provider quota source as `provider`, `estimate`, or `unknown`.
|
|
81
|
+
|
|
82
|
+
For durable usage history, per-model totals and rolling-window forecasts:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
opencode api POST /api/rpc/smart-router/usage -d '{"input":{}}'
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
This reports each provider's five-hour, seven-day and monthly consumption with input, output, cache-read and cache-write tokens, the cache share, per-model totals, and a forecast per window. A plan with no five-hour window reports that row as not applicable rather than as 0% used. It makes no inference request.
|
|
89
|
+
|
|
90
|
+
For a safe dry-run of a routing decision without changing the model:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
opencode api POST /api/rpc/smart-router/preview \
|
|
94
|
+
-d '{"input":{"sessionID":"ses_…","text":"Implement a small feature in src/app.ts"}}'
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
A safe smoke test:
|
|
98
|
+
|
|
99
|
+
1. Create a new session with a cheap or free model.
|
|
100
|
+
2. Send a narrow CSS change with an explicit file path.
|
|
101
|
+
3. The session should switch to an economy model before the request, and the file should change.
|
|
102
|
+
4. Send an ordinary feature request; it should use a standard model at or above the intelligence floor.
|
|
103
|
+
5. Send a tier-3 request (architecture, sophisticated redesign); with the 0.6.x default
|
|
104
|
+
`premium.mode: "auto"` it escalates to a plan model (Sonnet 5.5 or GPT-6.1 Sol)
|
|
105
|
+
automatically — no approval step, paid from the plan-model pool.
|
|
106
|
+
6. Run `/router-why` on the escalated prompt; the reason should name auto escalation
|
|
107
|
+
and the candidate trace should show the flagship row.
|
|
108
|
+
7. Select a model manually in the desktop picker; the next prompt must not be overridden, and `/router-status` should show `manual`.
|
|
109
|
+
8. Run `/router-auto` to resume.
|
|
110
|
+
|
|
111
|
+
## Automatic flagship escalation (0.6.0 and later)
|
|
112
|
+
|
|
113
|
+
The default premium mode is `auto`. How it behaves:
|
|
114
|
+
|
|
115
|
+
- **Tier-1/2 work never touches the plan-model pool.** Routine tasks stay on the
|
|
116
|
+
ordinary Design Plan models exactly as before.
|
|
117
|
+
- **Tier-3 work escalates by itself.** Architecture, sophisticated redesigns, and
|
|
118
|
+
other tier-3 classifications route to Claude Sonnet 5.5 or GPT-6.1 Sol without an
|
|
119
|
+
approval prompt, while the pool has budget.
|
|
120
|
+
- **The pool is the budget.** Auto escalation is funded from the OpenDesign
|
|
121
|
+
plan-model pool ($120/month on Pro), read from the plan catalog and tracked in a
|
|
122
|
+
ledger that survives restarts. When the pool is spent, escalation degrades to an
|
|
123
|
+
approval gate (`/router-approve`) and the router serves the task with the strongest
|
|
124
|
+
ordinary model — a routing refusal never fails a serviceable task.
|
|
125
|
+
- **Auto requires a resolvable pool.** If `providers.opendesign.plan` does not
|
|
126
|
+
resolve to a known plan, auto refuses rather than falling back to the legacy
|
|
127
|
+
premium budget, so spend is always attributable to a pool.
|
|
128
|
+
|
|
129
|
+
To change the behavior:
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{
|
|
133
|
+
"premium": { "mode": "auto", "monthlyBudgetUsd": 120 }
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`mode` may be `off` (never premium), `ask` (one-shot approval per request),
|
|
138
|
+
`allowed` (session approval unlocks the legacy premium budget), or `auto`.
|
|
139
|
+
A project-level `.opencode/smart-router.json` can tighten this but not loosen it.
|
|
140
|
+
|
|
141
|
+
## Configuration
|
|
142
|
+
|
|
143
|
+
No config file is required. To customize:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
cp ~/Documents/opencode-smart-router/config.example.json \
|
|
147
|
+
~/.config/opencode/smart-router.json
|
|
148
|
+
chmod 600 ~/.config/opencode/smart-router.json
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
A project may add `.opencode/smart-router.json`. It can tighten privacy and spending policy but cannot loosen global restrictions.
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# Limitations and precise blockers
|
|
2
|
+
|
|
3
|
+
This file records what is proven, what is estimated, and what the installed OpenCode version cannot support.
|
|
4
|
+
|
|
5
|
+
## 1. Desktop GUI
|
|
6
|
+
|
|
7
|
+
Installed OpenCode Desktop is **2.1.4**. Its `app.asar` does not contain the experimental GUI plugin loader or the `./gui-experimental` export. The GUI plugin SDK is present in `@opencode/plugin` 2.2.0 and later.
|
|
8
|
+
|
|
9
|
+
Therefore the deployed interface is:
|
|
10
|
+
|
|
11
|
+
- native slash commands registered by the server plugin (`/router-status`, `/router-explain`, `/router-report`, `/router-mode`, `/router-approve`, `/router-auto`, `/router-pin`, `/router-exclude`, `/router-pause`, `/router-resume`, `/router-dry-run`, `/router-premium-budget`, `/router-reserve`, `/router-reset-metrics`);
|
|
12
|
+
- a typed RPC surface at `rpc.smart-router.*`, suitable for a future official GUI part.
|
|
13
|
+
|
|
14
|
+
The router engine does not depend on any panel being open. A side panel was not claimed or faked. On OpenCode 2.2.0 or later, the same RPC surface can back a native settings page or side panel without changing routing core.
|
|
15
|
+
|
|
16
|
+
## 2. Command Code billing endpoints
|
|
17
|
+
|
|
18
|
+
Command Code exposes no stable public usage API in its documented Provider API. The plugin reads the same read-only alpha routes the official CLI uses:
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
/alpha/whoami?limits=1
|
|
22
|
+
/alpha/billing/credits
|
|
23
|
+
/alpha/billing/subscriptions
|
|
24
|
+
/alpha/usage/summary
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
These are **undocumented and unstable**. They can change without notice. The adapter:
|
|
28
|
+
|
|
29
|
+
- uses only `GET`;
|
|
30
|
+
- never discovers or calls mutation endpoints;
|
|
31
|
+
- validates numeric and timestamp shapes;
|
|
32
|
+
- preserves `orgId` when the account belongs to an organization;
|
|
33
|
+
- limits polling to a conservative interval with exponential backoff;
|
|
34
|
+
- keeps the last good snapshot marked `stale` on failure;
|
|
35
|
+
- falls back to a clearly labeled local ledger estimate;
|
|
36
|
+
- never reports missing data as unlimited usage.
|
|
37
|
+
|
|
38
|
+
The provider API key that OpenCode uses for inference is **not authorized** for these billing routes. The adapter discovers the CLI-compatible key instead. In this installation the working source is:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
~/.config/opencode/commandcode.key
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The key is read only from owner-only files, is never logged or persisted by the plugin, and is sent only to `https://api.commandcode.ai`.
|
|
45
|
+
|
|
46
|
+
Provider-reported values are account-level: remaining included credits, purchased/free extras, five-hour and weekly used/cap/reset, plan, and billing-period spend. There is no per-model provider breakdown. Per-model usage and remaining model allowance are local estimates from observed step costs.
|
|
47
|
+
|
|
48
|
+
The billing-period usage summary and the remaining-credit balance are **separate accounting pools** and do not reconcile exactly. The adapter treats the remaining-credit balance as authoritative for monthly usage and allowance protection; the summary is kept only as a diagnostic cross-check. The exact reason for the difference is not documented by the provider.
|
|
49
|
+
|
|
50
|
+
## 3. OpenCode Go usage endpoint
|
|
51
|
+
|
|
52
|
+
The Go meter endpoint is a read-only, best-effort integration:
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
GET https://opencode.ai/zen/go/v1/usage
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
It is queried with OpenCode's existing OAuth access token from the `opencode` connection. It returns percentages and reset times, not dollar balances. It is not part of the public inference schema and may change. On failure, the router uses the local ledger and labels the source `estimate` or `unknown`.
|
|
59
|
+
|
|
60
|
+
## 4. Prompt rejection is untyped
|
|
61
|
+
|
|
62
|
+
The V2 prompt admission hook can mutate a prompt but exposes no typed rejection operation. The router blocks admission by throwing `RouterBlockedError` from the prompt hook. On the installed 2.1.4 server this was verified to reject the prompt with HTTP 500 and no provider request; the session title changes to `BLOCKED` and the reason is recorded in the decision.
|
|
63
|
+
|
|
64
|
+
This is safe but not polished: the HTTP error is generic, so the title and notification are the user-facing explanation.
|
|
65
|
+
|
|
66
|
+
### What may and may not block a prompt
|
|
67
|
+
|
|
68
|
+
Blocking is **opt-in**. `routing.failClosed` defaults to `false`, and the default is the contract, not a tuning knob.
|
|
69
|
+
|
|
70
|
+
A defect in this plugin degrades to "do not route", never to "do not work". When the router cannot produce a decision because something inside it failed, the prompt is served with the model already selected, which is what would have happened if the plugin were not installed. The reason is recorded in warnings and in the decision record, so the failure is visible without being fatal.
|
|
71
|
+
|
|
72
|
+
`routing.failClosed: true` is available for an owner who has decided they would rather be stopped than pay for an unrouted request. It applies to plugin faults only. It does **not** govern quota safety: a provider configured for probing but with no reading is always refused, because that is a spending decision about a provider the router cannot see, not a response to its own failure.
|
|
73
|
+
|
|
74
|
+
Every hook is wrapped in `containHook()` and routing runs against a deadline. A hook cannot throw to the host and cannot hold a prompt indefinitely; if routing has not finished within the budget (10 seconds by default), the prompt proceeds unrouted. The reasoning is in [INCIDENT-2026-11-06-prompt-lockout.md](INCIDENT-2026-11-06-prompt-lockout.md).
|
|
75
|
+
|
|
76
|
+
## 4a. OpenCode does not contain a plugin hook
|
|
77
|
+
|
|
78
|
+
Verified against a real OpenCode 2.2.5 process on 2026-11-06:
|
|
79
|
+
|
|
80
|
+
- a plugin hook that **throws** fails the prompt with HTTP 500;
|
|
81
|
+
- a plugin hook that **never settles** blocks until the caller gives up.
|
|
82
|
+
|
|
83
|
+
OpenCode has no timeout and no catch of its own at this boundary, so a plugin cannot rely on the host to survive it. Every guarantee therefore has to come from the plugin, which is what `src/server/containment.ts` is for. `scripts/isolated-e2e.sh` asserts both limitations so the reason for that module stays visible.
|
|
84
|
+
|
|
85
|
+
## 5. Manual CLI selection at session creation
|
|
86
|
+
|
|
87
|
+
Manual model changes made while the plugin is active are detected through `session.model.selected` and respected. A model passed only at CLI session creation, before the plugin has seen a prompt, is indistinguishable from the session default. The first prompt may therefore be routed normally. Selecting a model in the desktop picker after the session exists is detected.
|
|
88
|
+
|
|
89
|
+
## 6. Subagents
|
|
90
|
+
|
|
91
|
+
Child sessions are routed independently. Premium is never propagated from a parent task into a child session; premium mode is capped at balanced for child sessions and one-shot approvals are not inherited. This prevents a premium task from multiplying premium usage through subagent calls. A child can still use a standard model appropriate to its own prompt.
|
|
92
|
+
|
|
93
|
+
## 7. Billing caps
|
|
94
|
+
|
|
95
|
+
Client-side quota estimates are not a mathematically guaranteed billing cap. Provider-side enforcement remains authoritative. The router:
|
|
96
|
+
|
|
97
|
+
- never purchases credits;
|
|
98
|
+
- never enables top-ups;
|
|
99
|
+
- never changes billing settings;
|
|
100
|
+
- blocks automatic routing when provider-reported windows are at the configured reserve;
|
|
101
|
+
- treats a reported zero remaining balance as exhausted even if the provider has not set its `exceeded` flag, so it blocks before knowingly entering purchased credits;
|
|
102
|
+
- blocks when purchased credits would be consumed unless `allowExtraUsage` is explicitly enabled;
|
|
103
|
+
- cannot guarantee that an undocumented provider endpoint or a provider-side fallback will never charge an extra balance.
|
|
104
|
+
|
|
105
|
+
If Command Code's billing routes fail and the local ledger is incomplete, the router labels the data estimated and fails conservatively rather than assuming capacity.
|
|
106
|
+
|
|
107
|
+
The purchased-credit guard is unit-tested, including the explicitly-allowed case. It is a client-side policy guarantee, not a provider-side billing lock.
|
|
108
|
+
|
|
109
|
+
## 8. Cache and context accounting
|
|
110
|
+
|
|
111
|
+
Prompt caches are provider- and model-specific. A model switch is priced as a cache loss in the router's estimate. The router keeps the current model when the estimated savings are below the hysteresis threshold, and uses a wider threshold above 300K context tokens. It never truncates, summarizes, or deletes conversation history to make a model eligible.
|
|
112
|
+
|
|
113
|
+
Command Code DeepSeek V4.1 Flash has documented time-of-day pricing: off-peak rates for 17 hours per day and 2x rates from 01:00 to 04:00 and 06:00 to 10:00 UTC, Monday through Friday. The router applies that multiplier to input, output, cache-read, and cache-loss estimates. Models without documented peak metadata use flat catalog rates; their time-of-day pricing is not assumed.
|
|
114
|
+
|
|
115
|
+
Context-health recommendations are non-destructive: they advise a new session and never compact anything on their own.
|
|
116
|
+
|
|
117
|
+
Router-driven compaction is governed by `contextManagement.mode`:
|
|
118
|
+
|
|
119
|
+
- `native` (default) and `conservative` never compact except when the transcript would genuinely exceed the model's verified accepted window. The provider's native handling is preferred; the router steps in only for that case, and announces it.
|
|
120
|
+
- `budget-optimized` preserves the legacy behavior: it compacts early to keep a session on the cheapest qualified route. It exists for small plans and small-window models.
|
|
121
|
+
|
|
122
|
+
In every mode the window used is the one the provider actually accepts, never a theoretical maximum, and the current model's window is used rather than another provider's variant of the same model. Provider-side prompt caching is never disabled by any mode.
|
|
123
|
+
|
|
124
|
+
## 8a. OpenDesign plan accounting
|
|
125
|
+
|
|
126
|
+
OpenDesign Pro exposes two independent credit pools: Design Plan credits ($360/month, $90 per rolling seven days, no five-hour window) and plan model credits ($120/month, no usage windows). They are reported side by side and **never summed**. The advertised $480 total is not a single spendable balance, and Design Plan credit multipliers apply only inside the Design Plan pool.
|
|
127
|
+
|
|
128
|
+
These figures are **estimated**, not provider-confirmed. An OpenDesign inference key cannot read quota: `GET /v1/usage` and `/v1/me` return 404 and the account-control host rejects an inference key. A control key would enable verified reads; none is configured, and the router never acquires one automatically. Because usage from the OpenDesign app is invisible to the router, the estimate is a lower bound.
|
|
129
|
+
|
|
130
|
+
`minimax-m3.1-flash`'s Design Plan multiplier is unpublished, so a neutral 1x is applied and the uncertainty is reported rather than hidden. The same applies to the GLM cache-read price.
|
|
131
|
+
|
|
132
|
+
OpenDesign quota cannot be verified without an account control key. Re-probed on
|
|
133
|
+
2026-10-30 against the live gateway: `/v1/models` returns 200 with the inference
|
|
134
|
+
key, while `/v1/usage`, `/v1/me`, `/v1/credits`, `/v1/billing`, `/v1/account`,
|
|
135
|
+
`/v1/subscription`, `/v1/limits` and `/v1/quota` all return 404. The key is valid
|
|
136
|
+
and authorized for inference; no usage surface exists for it. Quota therefore
|
|
137
|
+
reports `estimated`, derived from this router's own consumption against the
|
|
138
|
+
published plan caps with a configurable safety margin.
|
|
139
|
+
|
|
140
|
+
That estimate is a lower bound, because usage from the OpenDesign desktop app and
|
|
141
|
+
any other client on the same subscription is invisible to the router. The state
|
|
142
|
+
label is the honest part: `estimated` never becomes `verified` without a control
|
|
143
|
+
key, and `test/opendesign-quota-honesty.test.ts` pins that so it cannot drift.
|
|
144
|
+
|
|
145
|
+
## 8b. Measured speed
|
|
146
|
+
|
|
147
|
+
Generation speed is recorded as output tokens per second, which separates a slow provider from a model that reasons for longer. It is compared against the fastest eligible route rather than an absolute figure, is neutral below a configurable sample floor, and raises effective cost without ever making a route ineligible. With few samples it does not change decisions. It is machinery that needs production traffic before it can discriminate.
|
|
148
|
+
|
|
149
|
+
## 9. Precision of scores
|
|
150
|
+
|
|
151
|
+
The intelligence floor and design thresholds are planning inputs, not guarantees. Intelligence values come from published model cards where available; design scores are curated and explicitly separate. Models without credible scores are not approved for automatic substantive work. The router does not invent benchmark results.
|
|
152
|
+
|
|
153
|
+
## 10. What was not done
|
|
154
|
+
|
|
155
|
+
- No separate dashboard or daemon was installed.
|
|
156
|
+
- No third-party classification service receives prompts, code, or screenshots.
|
|
157
|
+
- No provider billing settings, credentials, or model connections were changed.
|
|
158
|
+
- No project configuration outside the router's optional project override was modified.
|
|
159
|
+
|
|
160
|
+
## 11. Why a session may stop using your default model
|
|
161
|
+
|
|
162
|
+
Two independent reasons can move a session off DeepSeek V4.1 Flash, and both are
|
|
163
|
+
correct behavior rather than faults.
|
|
164
|
+
|
|
165
|
+
**The transcript outgrew the window.** DeepSeek V4.1 Flash has a 328k window, and
|
|
166
|
+
presence in a request requires about 20k of headroom inside 95% of it, so it
|
|
167
|
+
stops being eligible at roughly 291,600 tokens of transcript. Past that point the
|
|
168
|
+
router names the model it dropped, both token counts, and the fact that a new
|
|
169
|
+
session restores it.
|
|
170
|
+
|
|
171
|
+
**Another model is genuinely cheaper right now.** A session can sit inside a
|
|
172
|
+
model's window and still lose on cost, because credit multipliers and peak
|
|
173
|
+
pricing differ per model. On OpenDesign, Minimax M3.1 Flash carries a neutral
|
|
174
|
+
multiplier while DeepSeek's is 0.5, and DeepSeek doubles during 01:00 to 04:00
|
|
175
|
+
and 06:00 to 10:00 UTC on weekdays. At a 309k transcript on a weekday, MiniMax
|
|
176
|
+
can therefore be about 1.3x cheaper, and the router picks it on merit. In that
|
|
177
|
+
case the context warning is deliberately silent, because nothing was displaced:
|
|
178
|
+
the model never would have won.
|
|
179
|
+
|
|
180
|
+
Observed live on 2026-10-30, and superseded later the same day. A 309,321-token
|
|
181
|
+
session selected `opendesign/minimax-m3.1-flash` because three conditions held at
|
|
182
|
+
once:
|
|
183
|
+
|
|
184
|
+
- OpenCode Go was at 90% of a 10% monthly reserve, so it was blocked.
|
|
185
|
+
- Command Code was at 86% of a 15% weekly reserve, so it was blocked.
|
|
186
|
+
- On OpenDesign, DeepSeek was judged against a 328,000 window and needed 329,321.
|
|
187
|
+
|
|
188
|
+
The first two were correct. The third was not: the 328,000 window was an advisory
|
|
189
|
+
gateway budget that the gateway does not enforce, and DeepSeek would now be
|
|
190
|
+
chosen for that session. The quotas remain real; the context rejection was a
|
|
191
|
+
consequence of the wrong window, which is documented in the section below.
|
|
192
|
+
|
|
193
|
+
### OpenDesign's context budget is advisory, and was measured
|
|
194
|
+
|
|
195
|
+
DeepSeek V4.1 Flash shows a 1,048,000-token window in the router. It used to show
|
|
196
|
+
328,000, because the gateway publishes `context_budget: 328000` alongside
|
|
197
|
+
`context_limit: 1048000` and the router treated the budget as the ceiling.
|
|
198
|
+
|
|
199
|
+
That was wrong. The gateway does not enforce `context_budget`. Measured on
|
|
200
|
+
2026-10-30 with six single requests on `deepseek-v4.1-flash`, `max_tokens: 1`,
|
|
201
|
+
synthetic unique filler, so nothing could be prefix-cached:
|
|
202
|
+
|
|
203
|
+
| Prompt tokens | Result |
|
|
204
|
+
|---|---|
|
|
205
|
+
| 355,588 | 200 |
|
|
206
|
+
| 753,476 | 200 |
|
|
207
|
+
| 999,593 | 200 |
|
|
208
|
+
| 1,043,337 | 200 |
|
|
209
|
+
| about 1,060,000 | 400 `context_length_exceeded` |
|
|
210
|
+
| about 2.4M | 400 `context_length_exceeded` |
|
|
211
|
+
|
|
212
|
+
The enforced ceiling is the vendor `context_limit` of 1,048,000, confirmed on both
|
|
213
|
+
sides of the boundary. Total cost of the probes was $0.006.
|
|
214
|
+
|
|
215
|
+
One control mattered: the first probe reported 355,328 of 355,588 tokens as
|
|
216
|
+
cached, which is implausible for a payload that had never been sent. Repeating it
|
|
217
|
+
with random text produced `cached_tokens: 0`, proving the gateway really did
|
|
218
|
+
tokenize and accept the full input. Without that, a 200 on mostly-cached input
|
|
219
|
+
would have been weak evidence.
|
|
220
|
+
|
|
221
|
+
The router now uses the vendor limit for a model whose ceiling has been measured,
|
|
222
|
+
and keeps the conservative budget for every other model. That is a per-model
|
|
223
|
+
allowlist rather than a blanket rule, because guessing a larger window than a
|
|
224
|
+
provider accepts fails mid-session with an opaque rejection, which is the failure
|
|
225
|
+
mode this project already hit once.
|
|
226
|
+
|
|
227
|
+
The router still keeps a 20,000-token margin and a 95% safety fraction, so the
|
|
228
|
+
usable transcript is about 975,600 tokens rather than exactly 1,048,000. That
|
|
229
|
+
margin is not part of the gateway's limit; it is the router refusing to send a
|
|
230
|
+
request that has no room left for the reply.
|