@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +286 -0
  2. package/CONTRIBUTING.md +103 -0
  3. package/LICENSE +21 -0
  4. package/README.md +425 -2
  5. package/SECURITY.md +174 -0
  6. package/config.example.json +107 -0
  7. package/dist/npm/index.js +40 -0
  8. package/docs/ARCHITECTURE.md +104 -0
  9. package/docs/DEBUGGING-GUI.md +70 -0
  10. package/docs/INSTALL.md +151 -0
  11. package/docs/LIMITATIONS.md +230 -0
  12. package/docs/MODELS.md +124 -0
  13. package/docs/OPENDESIGN-INTEGRATION.md +635 -0
  14. package/docs/QA.md +142 -0
  15. package/docs/SHARED-CORE.md +114 -0
  16. package/install +75 -0
  17. package/package.json +94 -4
  18. package/scripts/install.sh +324 -0
  19. package/scripts/uninstall.sh +91 -0
  20. package/src/allowance.ts +135 -0
  21. package/src/core/classify/classifier.ts +305 -0
  22. package/src/core/config-writer.ts +167 -0
  23. package/src/core/config.ts +1047 -0
  24. package/src/core/context/strategy.ts +188 -0
  25. package/src/core/context-health.ts +39 -0
  26. package/src/core/failures.ts +398 -0
  27. package/src/core/history/opencode.ts +173 -0
  28. package/src/core/models/protocol.ts +22 -0
  29. package/src/core/models/registry.ts +204 -0
  30. package/src/core/models/seed.ts +781 -0
  31. package/src/core/offline/mode.ts +321 -0
  32. package/src/core/plan-page.ts +390 -0
  33. package/src/core/plans/catalog.ts +487 -0
  34. package/src/core/policy/calibration.ts +259 -0
  35. package/src/core/policy/eligibility.ts +630 -0
  36. package/src/core/providers/enablement.ts +143 -0
  37. package/src/core/providers/opendesign-credits.ts +206 -0
  38. package/src/core/providers/opendesign-models.ts +654 -0
  39. package/src/core/providers/opendesign.ts +474 -0
  40. package/src/core/quota/calibration.ts +166 -0
  41. package/src/core/quota/commandcode.ts +471 -0
  42. package/src/core/quota/credentials.ts +490 -0
  43. package/src/core/quota/ledger.ts +1304 -0
  44. package/src/core/quota/opencode-go.ts +148 -0
  45. package/src/core/quota/opendesign.ts +1043 -0
  46. package/src/core/quota/reconcile.ts +155 -0
  47. package/src/core/quota/report.ts +261 -0
  48. package/src/core/quota/service.ts +273 -0
  49. package/src/core/reprompt.ts +48 -0
  50. package/src/core/routing/engine.ts +978 -0
  51. package/src/core/types.ts +866 -0
  52. package/src/core/ui.ts +236 -0
  53. package/src/core/util.ts +114 -0
  54. package/src/gui/index.tsx +1675 -0
  55. package/src/gui/viewmodel.ts +456 -0
  56. package/src/index.ts +33 -0
  57. package/src/rpc.ts +204 -0
  58. package/src/server/containment.ts +131 -0
  59. package/src/server/controller.ts +3405 -0
  60. package/src/server/route-tracker.ts +60 -0
  61. package/src/server/shared-core.ts +701 -0
  62. 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.
@@ -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.