@qadanm/opencode-smart-router 0.4.0 → 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 +168 -0
- package/CONTRIBUTING.md +7 -0
- package/README.md +89 -5
- package/config.example.json +1 -2
- package/dist/npm/index.js +29 -29
- package/docs/INSTALL.md +35 -4
- package/docs/LIMITATIONS.md +19 -2
- package/docs/SHARED-CORE.md +47 -5
- package/package.json +3 -1
- package/src/core/config.ts +25 -8
- package/src/core/models/registry.ts +1 -0
- package/src/core/models/seed.ts +13 -3
- package/src/core/plans/catalog.ts +6 -0
- package/src/core/policy/eligibility.ts +53 -1
- package/src/core/providers/opendesign-models.ts +33 -7
- package/src/core/quota/ledger.ts +31 -0
- package/src/core/routing/engine.ts +61 -2
- package/src/core/types.ts +46 -5
- package/src/gui/index.tsx +5 -0
- package/src/index.ts +15 -30
- package/src/server/containment.ts +131 -0
- package/src/server/controller.ts +261 -137
- package/src/server/shared-core.ts +32 -2
- package/dist/allowance.js +0 -487
- package/dist/gui-experimental.cjs +0 -1
- package/dist/index.js +0 -11111
- package/dist/tui.js +0 -587
package/docs/INSTALL.md
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# Install, verify, and remove
|
|
2
2
|
|
|
3
|
-
# Install, verify, and remove
|
|
4
|
-
|
|
5
3
|
## Install
|
|
6
4
|
|
|
7
5
|
One command, from a checkout. Requires Node 20.12+, npm and git. No `jq`.
|
|
@@ -102,11 +100,44 @@ A safe smoke test:
|
|
|
102
100
|
2. Send a narrow CSS change with an explicit file path.
|
|
103
101
|
3. The session should switch to an economy model before the request, and the file should change.
|
|
104
102
|
4. Send an ordinary feature request; it should use a standard model at or above the intelligence floor.
|
|
105
|
-
5. Send a
|
|
106
|
-
|
|
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.
|
|
107
108
|
7. Select a model manually in the desktop picker; the next prompt must not be overridden, and `/router-status` should show `manual`.
|
|
108
109
|
8. Run `/router-auto` to resume.
|
|
109
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
|
+
|
|
110
141
|
## Configuration
|
|
111
142
|
|
|
112
143
|
No config file is required. To customize:
|
package/docs/LIMITATIONS.md
CHANGED
|
@@ -61,9 +61,26 @@ It is queried with OpenCode's existing OAuth access token from the `opencode` co
|
|
|
61
61
|
|
|
62
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
63
|
|
|
64
|
-
This is safe but not polished: the HTTP error is generic, so the title and notification are the user-facing explanation.
|
|
64
|
+
This is safe but not polished: the HTTP error is generic, so the title and notification are the user-facing explanation.
|
|
65
65
|
|
|
66
|
-
|
|
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.
|
|
67
84
|
|
|
68
85
|
## 5. Manual CLI selection at session creation
|
|
69
86
|
|
package/docs/SHARED-CORE.md
CHANGED
|
@@ -45,18 +45,60 @@ Session and step events are process-global in this SDK. A session is claimed by
|
|
|
45
45
|
|
|
46
46
|
The prompt hook checks `projectID` before claiming a session. Title writes are filtered by project. Commands and RPC remain location-scoped, so each project exposes its own 16 router commands.
|
|
47
47
|
|
|
48
|
-
##
|
|
48
|
+
## Failure policy
|
|
49
49
|
|
|
50
|
-
`routing.failClosed` defaults to
|
|
50
|
+
`routing.failClosed` defaults to **`false`**.
|
|
51
|
+
|
|
52
|
+
A defect in this plugin degrades to "do not route", never to "do not work". When
|
|
53
|
+
the router cannot produce a decision because something inside it failed, the
|
|
54
|
+
prompt is served with the model already selected, which is what would have
|
|
55
|
+
happened had the plugin not been installed. The reason is recorded in warnings
|
|
56
|
+
and in the decision record.
|
|
57
|
+
|
|
58
|
+
Blocking is available as an explicit opt-in. With `routing.failClosed: true`,
|
|
59
|
+
an unexpected routing failure:
|
|
51
60
|
|
|
52
61
|
1. writes a `BLOCKED` session title;
|
|
53
62
|
2. sends a nonintrusive notification where supported;
|
|
54
63
|
3. records a blocked decision with the reason;
|
|
55
64
|
4. throws `RouterBlockedError` from the prompt hook.
|
|
56
65
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
OpenCode exposes no typed rejection API, so the block travels as an exception.
|
|
67
|
+
The installed server was probed with a throwing prompt hook: the prompt returned
|
|
68
|
+
HTTP 500 and was not admitted. The HTTP error is generic, so the title and
|
|
69
|
+
notification are the user-facing explanation.
|
|
70
|
+
|
|
71
|
+
Version 0.6.0 defaulted this to `true` and treated every internal exception as a
|
|
72
|
+
blocking condition. That turned a missing ledger method into a total refusal to
|
|
73
|
+
send any prompt, including in manual mode, and the owner had to remove the
|
|
74
|
+
plugin by hand. The reasoning is in
|
|
75
|
+
[INCIDENT-2026-11-06-prompt-lockout.md](INCIDENT-2026-11-06-prompt-lockout.md).
|
|
76
|
+
|
|
77
|
+
`failClosed` does not govern quota safety. The rule "a provider configured for
|
|
78
|
+
probing but with no reading is refused" is unconditional, because it is a
|
|
79
|
+
spending decision about a provider the router cannot see.
|
|
80
|
+
|
|
81
|
+
## Containment at the host boundary
|
|
82
|
+
|
|
83
|
+
OpenCode does not catch a plugin hook that throws and does not time out a hook
|
|
84
|
+
that never settles. Both were verified against a real OpenCode 2.2.5 process on
|
|
85
|
+
2026-11-06 and are asserted by `scripts/isolated-e2e.sh`. The guarantee has to
|
|
86
|
+
come from this side of the boundary, so `src/server/containment.ts` provides:
|
|
87
|
+
|
|
88
|
+
- `containHook()`, which makes a hook's failure a recorded warning rather than a
|
|
89
|
+
failed request. A deliberate `RouterBlockedError` passes through only when the
|
|
90
|
+
caller opts in.
|
|
91
|
+
- `withDeadline()`, which bounds routing. If routing has not finished within the
|
|
92
|
+
budget (10 seconds by default), the prompt proceeds unrouted and the timeout is
|
|
93
|
+
reported.
|
|
94
|
+
|
|
95
|
+
## Shared core versioning
|
|
96
|
+
|
|
97
|
+
The process-wide core is stamped with `SHARED_CORE_API`. A bundle adopts a core
|
|
98
|
+
from `globalThis` only when the stamp matches. A core left by a different
|
|
99
|
+
version is a different class with a different method set, and adopting it is how
|
|
100
|
+
0.6.0 produced `this.ledger.planPoolSpent is not a function` on every prompt.
|
|
101
|
+
`init()` additionally replaces a ledger that is not a `LedgerStore` instance.
|
|
60
102
|
|
|
61
103
|
## Quota and persistence
|
|
62
104
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qadanm/opencode-smart-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Subscription-aware model routing for OpenCode: nested usage windows, multi-pool credit accounting, context policy, and self-healing routing that repairs prompts and fails over deterministic rejections.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"opencode",
|
|
@@ -76,7 +76,9 @@
|
|
|
76
76
|
"@opencode/plugin": "2.2.5"
|
|
77
77
|
},
|
|
78
78
|
"devDependencies": {
|
|
79
|
+
"@types/jsdom": "^30.0.0",
|
|
79
80
|
"@types/node": "^24.0.0",
|
|
81
|
+
"jsdom": "^27.4.0",
|
|
80
82
|
"solid-js": "^1.9.18",
|
|
81
83
|
"typescript": "^5.9.3",
|
|
82
84
|
"vite": "^7.3.7",
|
package/src/core/config.ts
CHANGED
|
@@ -105,8 +105,8 @@ export const DEFAULT_SETTINGS: RouterSettings = {
|
|
|
105
105
|
requireZdr: false,
|
|
106
106
|
},
|
|
107
107
|
premium: {
|
|
108
|
-
mode: "
|
|
109
|
-
monthlyBudgetUsd:
|
|
108
|
+
mode: "auto",
|
|
109
|
+
monthlyBudgetUsd: 120,
|
|
110
110
|
oneShotApproval: true,
|
|
111
111
|
},
|
|
112
112
|
routing: {
|
|
@@ -118,7 +118,12 @@ export const DEFAULT_SETTINGS: RouterSettings = {
|
|
|
118
118
|
largeContextHysteresisPct: 35,
|
|
119
119
|
maxEscalationsPerTask: 2,
|
|
120
120
|
preferSameProvider: true,
|
|
121
|
-
|
|
121
|
+
// A defect in this plugin must never make the editor unusable. Routing is
|
|
122
|
+
// an optimization; the host works without it. Failing open means an
|
|
123
|
+
// internal error leaves the current model in place and lets the prompt
|
|
124
|
+
// through. An owner who would rather be stopped than pay for an unrouted
|
|
125
|
+
// request can set this to true explicitly.
|
|
126
|
+
failClosed: false,
|
|
122
127
|
protectEscalationPool: true,
|
|
123
128
|
longContextFloorTokens: 1_000_000,
|
|
124
129
|
calibrateFloor: false,
|
|
@@ -156,7 +161,6 @@ export const DEFAULT_SETTINGS: RouterSettings = {
|
|
|
156
161
|
// bug, not a feature: the session title is the user's, and status belongs in
|
|
157
162
|
// the desktop panel or the terminal's status line. Turning this on is an
|
|
158
163
|
// explicit choice, and turning it back off restores the original names.
|
|
159
|
-
titleIndicator: false,
|
|
160
164
|
notifications: true,
|
|
161
165
|
compactMode: false,
|
|
162
166
|
compact: false,
|
|
@@ -200,6 +204,11 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
|
200
204
|
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
201
205
|
|
|
202
206
|
const asBoolean = (value: unknown, fallback: boolean, warnings: string[], path: string): boolean => {
|
|
207
|
+
// Absent and invalid are different. A key that was never written takes the
|
|
208
|
+
// fallback silently; only a value the user actually provided can be wrong.
|
|
209
|
+
// Collapsing the two produced dozens of warnings for a config that set one
|
|
210
|
+
// provider plan, which buried the real problems in the noise.
|
|
211
|
+
if (value === undefined) return fallback
|
|
203
212
|
if (typeof value === "boolean") return value
|
|
204
213
|
warnings.push(`${path} must be a boolean; using ${fallback}`)
|
|
205
214
|
return fallback
|
|
@@ -213,6 +222,7 @@ const asNumber = (
|
|
|
213
222
|
min: number,
|
|
214
223
|
max: number,
|
|
215
224
|
): number => {
|
|
225
|
+
if (value === undefined) return fallback
|
|
216
226
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
217
227
|
warnings.push(`${path} must be a finite number; using ${fallback}`)
|
|
218
228
|
return fallback
|
|
@@ -223,6 +233,7 @@ const asNumber = (
|
|
|
223
233
|
}
|
|
224
234
|
|
|
225
235
|
const asString = (value: unknown, fallback: string, warnings: string[], path: string): string => {
|
|
236
|
+
if (value === undefined) return fallback
|
|
226
237
|
if (typeof value === "string" && value.trim().length > 0) return value.trim()
|
|
227
238
|
warnings.push(`${path} must be a non-empty string; using ${fallback}`)
|
|
228
239
|
return fallback
|
|
@@ -235,6 +246,7 @@ const asEnum = <T extends string>(
|
|
|
235
246
|
warnings: string[],
|
|
236
247
|
path: string,
|
|
237
248
|
): T => {
|
|
249
|
+
if (value === undefined) return fallback
|
|
238
250
|
if (typeof value === "string" && (values as ReadonlyArray<string>).includes(value)) return value as T
|
|
239
251
|
warnings.push(`${path} must be one of ${values.join(", ")}; using ${fallback}`)
|
|
240
252
|
return fallback
|
|
@@ -510,6 +522,7 @@ const validateDefinitions = (value: unknown, warnings: string[]): ModelDefinitio
|
|
|
510
522
|
}
|
|
511
523
|
|
|
512
524
|
const validateModels = (value: unknown, base: ModelSettings, warnings: string[]): ModelSettings => {
|
|
525
|
+
if (value === undefined) return base
|
|
513
526
|
if (!isRecord(value)) {
|
|
514
527
|
warnings.push("models must be an object; using defaults")
|
|
515
528
|
return base
|
|
@@ -568,7 +581,7 @@ export const validateSettings = (input: unknown, base: RouterSettings = DEFAULT_
|
|
|
568
581
|
|
|
569
582
|
const premiumInput = isRecord(input.premium) ? input.premium : {}
|
|
570
583
|
const premium: PremiumSettings = {
|
|
571
|
-
mode: asEnum(premiumInput.mode, ["off", "ask", "allowed"] as const, base.premium.mode, warnings, "premium.mode"),
|
|
584
|
+
mode: asEnum(premiumInput.mode, ["off", "ask", "allowed", "auto"] as const, base.premium.mode, warnings, "premium.mode"),
|
|
572
585
|
monthlyBudgetUsd: asNumber(
|
|
573
586
|
premiumInput.monthlyBudgetUsd,
|
|
574
587
|
base.premium.monthlyBudgetUsd,
|
|
@@ -715,7 +728,6 @@ export const validateSettings = (input: unknown, base: RouterSettings = DEFAULT_
|
|
|
715
728
|
"ui.compactMode",
|
|
716
729
|
)
|
|
717
730
|
const ui = {
|
|
718
|
-
titleIndicator: asBoolean(uiInput.titleIndicator, base.ui.titleIndicator, warnings, "ui.titleIndicator"),
|
|
719
731
|
notifications: asBoolean(uiInput.notifications, base.ui.notifications, warnings, "ui.notifications"),
|
|
720
732
|
compactMode: compactToggle,
|
|
721
733
|
compact: compactToggle,
|
|
@@ -942,7 +954,9 @@ export const applyProjectGuardrails = (
|
|
|
942
954
|
? "off"
|
|
943
955
|
: globalSettings.premium.mode === "ask" || candidate.premium.mode === "ask"
|
|
944
956
|
? "ask"
|
|
945
|
-
: "
|
|
957
|
+
: globalSettings.premium.mode === "auto" && candidate.premium.mode === "auto"
|
|
958
|
+
? "auto"
|
|
959
|
+
: "allowed",
|
|
946
960
|
monthlyBudgetUsd: Math.min(globalSettings.premium.monthlyBudgetUsd, candidate.premium.monthlyBudgetUsd),
|
|
947
961
|
},
|
|
948
962
|
providers: Object.fromEntries(
|
|
@@ -964,7 +978,10 @@ export const applyProjectGuardrails = (
|
|
|
964
978
|
if (candidate.privacy.allowTrainingModels && !globalSettings.privacy.allowTrainingModels) {
|
|
965
979
|
warnings.push("Project config tried to enable training models; global privacy setting kept it disabled")
|
|
966
980
|
}
|
|
967
|
-
if (
|
|
981
|
+
if (
|
|
982
|
+
(candidate.premium.mode === "allowed" || candidate.premium.mode === "auto") &&
|
|
983
|
+
globalSettings.premium.mode !== candidate.premium.mode
|
|
984
|
+
) {
|
|
968
985
|
warnings.push("Project config tried to enable automatic premium routing; global setting kept it restricted")
|
|
969
986
|
}
|
|
970
987
|
return tightened
|
|
@@ -54,6 +54,7 @@ const fromSeed = (seed: SeedModel): ModelRecord => ({
|
|
|
54
54
|
intelligence: seed.intelligence,
|
|
55
55
|
design: seed.design,
|
|
56
56
|
designEvidence: seed.designEvidence ?? "curated",
|
|
57
|
+
...(seed.capabilities ? { capabilities: { ...seed.capabilities } } : {}),
|
|
57
58
|
...(seed.creditMultiplier && seed.creditMultiplier > 0 ? { creditMultiplier: seed.creditMultiplier } : {}),
|
|
58
59
|
...(seed.protocol ? { protocol: seed.protocol } : {}),
|
|
59
60
|
...(seed.escalationOnly ? { escalationOnly: true } : {}),
|
package/src/core/models/seed.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface SeedModel {
|
|
|
21
21
|
/** True when the model is funded from a pool reserved for escalation. */
|
|
22
22
|
escalationOnly?: boolean
|
|
23
23
|
premium?: boolean
|
|
24
|
+
/** Per-kind capability evidence, passed through with provenance intact. */
|
|
25
|
+
capabilities?: ModelRecord["capabilities"]
|
|
24
26
|
training?: boolean
|
|
25
27
|
free?: boolean
|
|
26
28
|
tier: 1 | 2 | 3
|
|
@@ -511,14 +513,16 @@ const BASE_SEED_MODELS: ReadonlyArray<SeedModel> = [
|
|
|
511
513
|
maxOutput: 128_000,
|
|
512
514
|
input: ["text", "image", "pdf"],
|
|
513
515
|
cost: tieredCost(cost(2, 10, 0.1, 2.5)[0]!, 272_000, { input: 4, output: 15, read: 0.2, write: 5 }),
|
|
514
|
-
intelligence:
|
|
516
|
+
intelligence: 55,
|
|
515
517
|
design: 88,
|
|
516
518
|
designEvidence: "unverified",
|
|
519
|
+
capabilities: { coding: 56, debugging: 65, agentic: 41, architecture: 86 },
|
|
517
520
|
premium: true,
|
|
518
521
|
tier: 3,
|
|
519
|
-
approved:
|
|
522
|
+
approved: true,
|
|
520
523
|
monthlyAllowanceUsd: 20,
|
|
521
|
-
notes:
|
|
524
|
+
notes:
|
|
525
|
+
"Intelligence 55 on Artificial Analysis v5.0.1 (2026-11-06). Capabilities: Terminal-Bench 4.0 56 percent, AutomationBench 65 percent, FrontierCode 2.0 41 percent, same index and configuration. Long-context tier doubles input to 4 and raises output to 15 above 272K. Auto-approved for tier-3 escalation by owner instruction 2026-11-06; the Command Code gateway advertises 1.05M but that window is unverified, so treat it as pending a passed probe.",
|
|
522
526
|
},
|
|
523
527
|
{
|
|
524
528
|
key: "commandcode/gpt-6-astra",
|
|
@@ -714,8 +718,14 @@ const OPENDESIGN_SEED_MODELS: ReadonlyArray<SeedModel> = OPENDESIGN_MODELS.map((
|
|
|
714
718
|
intelligence: model.intelligence,
|
|
715
719
|
design: model.design,
|
|
716
720
|
designEvidence: model.designEvidence,
|
|
721
|
+
// Per-kind benchmark evidence passes through with its provenance intact.
|
|
722
|
+
...(model.capabilities ? { capabilities: { ...model.capabilities } } : {}),
|
|
717
723
|
tier: model.tier,
|
|
718
724
|
approved: model.approved,
|
|
725
|
+
// Plan models (Sonnet, Sol, Opus, Astra) are premium: they draw from the
|
|
726
|
+
// plan-model pool, which premium policy and its budget govern. Design Plan
|
|
727
|
+
// models are ordinary allowance, never premium.
|
|
728
|
+
premium: !OPENDESIGN_DESIGN_PLAN_MODELS.has(model.modelID),
|
|
719
729
|
// Every OpenDesign allowance is a shared provider-level pool, so no model
|
|
720
730
|
// carries its own monthly allowance.
|
|
721
731
|
monthlyAllowanceUsd: 0,
|
|
@@ -102,6 +102,12 @@ export const OPENDESIGN_DESIGN_PLAN_MODELS: ReadonlySet<string> = new Set([
|
|
|
102
102
|
"claude-haiku-5.5",
|
|
103
103
|
"glm-5.3-flashx",
|
|
104
104
|
"gpt-6-luna",
|
|
105
|
+
// The gateway serves MiniMax M3.1 Flash both inside and outside the Design
|
|
106
|
+
// Plan group, and OpenDesign has not published which applies (see the model
|
|
107
|
+
// note). Treated as Design Plan: it is an ordinary tier-2 workhorse, and
|
|
108
|
+
// misclassifying it the other way would let a routine model drain the
|
|
109
|
+
// escalation pool.
|
|
110
|
+
"minimax-m3.1-flash",
|
|
105
111
|
])
|
|
106
112
|
|
|
107
113
|
export const OPENDESIGN_DESIGN_PLAN_POOL = "design-plan"
|
|
@@ -41,6 +41,34 @@ export const decidePremium = (input: {
|
|
|
41
41
|
if (mode === "efficiency") {
|
|
42
42
|
return { allowed: false, requiresApproval: false, reason: "Maximum Efficiency locks premium models unless you select one manually." }
|
|
43
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Standing escalation: mode "auto" is the owner's pre-granted approval for
|
|
46
|
+
* tier-3 work, funded from the plan-model pool while it lasts. Exhaustion
|
|
47
|
+
* degrades to an approval gate, never a hard block, because a routing refusal
|
|
48
|
+
* cannot put credits back.
|
|
49
|
+
*/
|
|
50
|
+
if (premium.mode === "auto") {
|
|
51
|
+
// Auto escalation is funded from the plan-model pool, and only from it:
|
|
52
|
+
// auto requires a resolvable pool. Without one (OpenDesign disabled or an
|
|
53
|
+
// unknown plan), falling back to the legacy premium budget would let auto
|
|
54
|
+
// spend premium models from other providers that no ledger attributes, so
|
|
55
|
+
// the gate degrades to an approval request instead.
|
|
56
|
+
if ((premium.planPoolRemainingUsd ?? 0) > 0) {
|
|
57
|
+
return {
|
|
58
|
+
allowed: true,
|
|
59
|
+
requiresApproval: false,
|
|
60
|
+
reason: "Auto escalation: the plan-model pool has remaining budget for premium-tier work.",
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
allowed: false,
|
|
65
|
+
requiresApproval: true,
|
|
66
|
+
reason:
|
|
67
|
+
premium.planPoolRemainingUsd === undefined
|
|
68
|
+
? "Auto escalation needs a resolvable plan-model pool (set providers.opendesign.plan); approval is required for this request."
|
|
69
|
+
: "Auto escalation is out of plan-model budget; approval is required for this request.",
|
|
70
|
+
}
|
|
71
|
+
}
|
|
44
72
|
if (session?.premiumOneShot && settings.premium.oneShotApproval) {
|
|
45
73
|
return { allowed: true, requiresApproval: false, reason: "A one-shot premium approval is active for this session." }
|
|
46
74
|
}
|
|
@@ -146,7 +174,12 @@ const providerLimitReached = (
|
|
|
146
174
|
// would break legitimate fallbacks to providers with no quota model at all.
|
|
147
175
|
const providerSettings = settings.providers[providerID]
|
|
148
176
|
const expectedProbe = providerSettings?.enabled === true && providerSettings?.probeUsage === true
|
|
149
|
-
|
|
177
|
+
// Unconditional, and deliberately not tied to routing.failClosed. That
|
|
178
|
+
// setting governs what happens when THIS PLUGIN breaks; this check governs
|
|
179
|
+
// whether it is safe to spend on a provider it cannot see. An owner asking
|
|
180
|
+
// the router to keep working through its own faults is not asking it to
|
|
181
|
+
// route into a provider that may be full.
|
|
182
|
+
if (expectedProbe && quotaIsKnown) {
|
|
150
183
|
return "Provider quota has not been probed yet; refusing rather than assuming it has room"
|
|
151
184
|
}
|
|
152
185
|
return undefined
|
|
@@ -391,6 +424,25 @@ export const evaluateEligibility = (input: EligibilityInput): EligibilityResult
|
|
|
391
424
|
}
|
|
392
425
|
}
|
|
393
426
|
|
|
427
|
+
// The mirror gate: while escalation is available, tier-3 work draws on the
|
|
428
|
+
// plan-model pool and ordinary models stand down. A tier-3 task served by a
|
|
429
|
+
// standard model runs at ~0.7 expected success by construction - that is
|
|
430
|
+
// precisely the situation the pool exists to prevent, and no cost ranking
|
|
431
|
+
// should be able to quietly trade 24 points of success probability for a
|
|
432
|
+
// few cents. "Insufficient benefit" is answered by the classifier (a task
|
|
433
|
+
// ordinary models serve well is not classified tier 3) and by the budget
|
|
434
|
+
// gate (escalation that cannot be paid for does not happen), not by letting
|
|
435
|
+
// a cheap model absorb hard work. When this gate leaves no route at all,
|
|
436
|
+
// the engine's premiumFallback deliberately picks the strongest ordinary
|
|
437
|
+
// model, so exhaustion degrades instead of failing the task.
|
|
438
|
+
if (task.tier === 3 && premium.allowed && !model.premium) {
|
|
439
|
+
return {
|
|
440
|
+
eligible: false,
|
|
441
|
+
reason: "Premium-tier work escalates to the plan-model pool while escalation budget remains",
|
|
442
|
+
warnings,
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
394
446
|
// An escalation pool is capacity, not routine allowance. When an
|
|
395
447
|
// ordinary-allowance route can already serve the task, spending the
|
|
396
448
|
// escalation pool on it converts a cheap request into a scarce one.
|
|
@@ -53,6 +53,20 @@ export interface OpenDesignModelSpec {
|
|
|
53
53
|
readonly tier: 1 | 2 | 3
|
|
54
54
|
readonly approved: boolean
|
|
55
55
|
readonly notes: string
|
|
56
|
+
/**
|
|
57
|
+
* Per-kind capability evidence. An absent axis means "not measured", never
|
|
58
|
+
* zero, and each entry's provenance is named alongside it.
|
|
59
|
+
*/
|
|
60
|
+
readonly capabilities?: {
|
|
61
|
+
/** Agentic coding benchmark, percent. */
|
|
62
|
+
readonly coding?: number
|
|
63
|
+
/** Complex-debugging / automation benchmark, percent. */
|
|
64
|
+
readonly debugging?: number
|
|
65
|
+
/** Long-horizon autonomous-execution benchmark, percent. */
|
|
66
|
+
readonly agentic?: number
|
|
67
|
+
/** Curated architectural-reasoning score, same 0-100 scale. */
|
|
68
|
+
readonly architecture?: number
|
|
69
|
+
}
|
|
56
70
|
}
|
|
57
71
|
|
|
58
72
|
const cost = (input: number, output: number, read: number, write = 0): CostTier[] => [
|
|
@@ -107,6 +121,7 @@ const spec = (
|
|
|
107
121
|
tier: extra.tier,
|
|
108
122
|
approved: extra.approved,
|
|
109
123
|
notes: extra.notes,
|
|
124
|
+
...(extra.capabilities ? { capabilities: extra.capabilities } : {}),
|
|
110
125
|
}
|
|
111
126
|
}
|
|
112
127
|
|
|
@@ -298,7 +313,11 @@ export const OPENDESIGN_MODELS: ReadonlyArray<OpenDesignModelSpec> = [
|
|
|
298
313
|
// windows. Only available on Plus, Pro and Max.
|
|
299
314
|
// ---------------------------------------------------------------------------
|
|
300
315
|
spec("claude-sonnet-5.5", "Claude Sonnet 5.5", {
|
|
301
|
-
|
|
316
|
+
// Probed 2026-11-06: one request with 819,383 random-filler prompt tokens
|
|
317
|
+
// returned 200, finish stop, all three mid-document sentinels retrieved,
|
|
318
|
+
// $1.62. The catalog is raised to the smallest probed-verified figure,
|
|
319
|
+
// rounded down; the advertised 1M is NOT assumed from an 800K pass.
|
|
320
|
+
context: 800_000,
|
|
302
321
|
maxOutput: 128_000,
|
|
303
322
|
input: ["text", "image", "pdf"],
|
|
304
323
|
cost: cost(2, 10, 0.2, 2.5),
|
|
@@ -306,9 +325,10 @@ export const OPENDESIGN_MODELS: ReadonlyArray<OpenDesignModelSpec> = [
|
|
|
306
325
|
design: 90,
|
|
307
326
|
designEvidence: "curated",
|
|
308
327
|
tier: 3,
|
|
309
|
-
approved:
|
|
328
|
+
approved: true,
|
|
329
|
+
capabilities: { coding: 64, debugging: 72, agentic: 42, architecture: 88 },
|
|
310
330
|
notes:
|
|
311
|
-
"Plan model. Premier design and architecture quality. Funded from plan model credits, so routing here consumes escalation capacity rather than routine allowance.",
|
|
331
|
+
"Plan model. Premier design and architecture quality. Funded from plan model credits, so routing here consumes escalation capacity rather than routine allowance. Capabilities: Terminal-Bench 4.0 64 percent, AutomationBench 72 percent, FrontierCode 2.0 42 percent (Artificial Analysis v5.0.1, 2026-11-06, max reasoning; independent evaluations, not guarantees); architecture 88 curated. Auto-approved for tier-3 escalation by owner instruction 2026-11-06. Context probed 2026-11-06: 800K verified end-to-end (819,383 prompt tokens accepted, sentinels at start/middle/end retrieved, no truncation, $1.62); advertised 1M remains unverified.",
|
|
312
332
|
}),
|
|
313
333
|
spec("gpt-6-sol", "GPT-6 Sol", {
|
|
314
334
|
context: 304_000,
|
|
@@ -323,16 +343,22 @@ export const OPENDESIGN_MODELS: ReadonlyArray<OpenDesignModelSpec> = [
|
|
|
323
343
|
notes: "Plan model. Unscored in the router's curated set. Explicit approval required.",
|
|
324
344
|
}),
|
|
325
345
|
spec("gpt-6.1-sol", "GPT-6.1 Sol", {
|
|
326
|
-
|
|
346
|
+
// Probed 2026-11-06: one request with 820,701 prompt tokens returned 200,
|
|
347
|
+
// finish stop, all three sentinels retrieved, $2.74 - and the long-context
|
|
348
|
+
// surcharge (input $4/M above 272K) was billed, confirming tiered pricing
|
|
349
|
+
// applies exactly as cataloged. Raised to 800K (verified), not 1M.
|
|
350
|
+
context: 800_000,
|
|
327
351
|
maxOutput: 128_000,
|
|
328
352
|
input: ["text", "image", "pdf"],
|
|
329
353
|
cost: tieredCost(cost(2, 10, 0.1, 2.5)[0]!, 272_000, { input: 4, output: 15, read: 0.2, write: 5 }),
|
|
330
|
-
intelligence:
|
|
354
|
+
intelligence: 55,
|
|
331
355
|
design: 88,
|
|
332
356
|
designEvidence: "unverified",
|
|
333
357
|
tier: 3,
|
|
334
|
-
approved:
|
|
335
|
-
|
|
358
|
+
approved: true,
|
|
359
|
+
capabilities: { coding: 56, debugging: 65, agentic: 41, architecture: 86 },
|
|
360
|
+
notes:
|
|
361
|
+
"Plan model. Intelligence 55 on Artificial Analysis v5.0.1 (2026-11-06), tied with Sonnet 5.5 on that index; the per-kind capabilities break the tie toward Sonnet for agentic coding. Sol used far fewer generated tokens on the same suite, so it stays the cheaper tier-3 route on short fresh contexts. Capabilities: Terminal-Bench 4.0 56 percent, AutomationBench 65 percent, FrontierCode 2.0 41 percent, same index and configuration. Architecture 86 curated. Long-context tier doubles input to 4 and raises output to 15 above 272K. Auto-approved for tier-3 escalation by owner instruction 2026-11-06. Context probed 2026-11-06: 800K verified end-to-end (820,701 prompt tokens accepted with sentinels retrieved, no truncation, $2.74 billed at the tiered long-context rate); advertised 1M remains unverified.",
|
|
336
362
|
}),
|
|
337
363
|
spec("gpt-6-astra", "GPT-6 Astra", {
|
|
338
364
|
context: 400_000,
|
package/src/core/quota/ledger.ts
CHANGED
|
@@ -83,6 +83,7 @@ export const emptyMetrics = (now = Date.now()): Metrics => ({
|
|
|
83
83
|
dryRunDecisions: 0,
|
|
84
84
|
premiumRequests: 0,
|
|
85
85
|
premiumUsd: 0,
|
|
86
|
+
planPoolUsd: 0,
|
|
86
87
|
latencyMsTotal: 0,
|
|
87
88
|
latencySamples: 0,
|
|
88
89
|
failures: emptyFailureCounts(),
|
|
@@ -108,6 +109,7 @@ export const emptyLedger = (now = Date.now()): Ledger => ({
|
|
|
108
109
|
version: LEDGER_VERSION,
|
|
109
110
|
usage: [],
|
|
110
111
|
premiumSpend: [],
|
|
112
|
+
planPoolSpend: [],
|
|
111
113
|
sessions: {},
|
|
112
114
|
decisions: [],
|
|
113
115
|
metrics: emptyMetrics(now),
|
|
@@ -374,6 +376,12 @@ export const hydrateLedger = (value: unknown): { ledger: Ledger; warnings: strin
|
|
|
374
376
|
.map((entry) => ({ month: String(entry.month ?? ""), usd: Math.max(0, finite(entry.usd)) }))
|
|
375
377
|
.filter((entry) => entry.month)
|
|
376
378
|
: [],
|
|
379
|
+
planPoolSpend: Array.isArray(value.planPoolSpend)
|
|
380
|
+
? value.planPoolSpend
|
|
381
|
+
.filter(isRecord)
|
|
382
|
+
.map((entry) => ({ month: String(entry.month ?? ""), usd: Math.max(0, finite(entry.usd)) }))
|
|
383
|
+
.filter((entry) => entry.month)
|
|
384
|
+
: [],
|
|
377
385
|
sessions,
|
|
378
386
|
decisions: decisions.slice(0, MAX_DECISIONS),
|
|
379
387
|
metrics: {
|
|
@@ -392,6 +400,7 @@ export const hydrateLedger = (value: unknown): { ledger: Ledger; warnings: strin
|
|
|
392
400
|
dryRunDecisions: Math.max(0, finite(metrics.dryRunDecisions)),
|
|
393
401
|
premiumRequests: Math.max(0, finite(metrics.premiumRequests)),
|
|
394
402
|
premiumUsd: Math.max(0, finite(metrics.premiumUsd)),
|
|
403
|
+
planPoolUsd: Math.max(0, finite(metrics.planPoolUsd)),
|
|
395
404
|
latencyMsTotal: Math.max(0, finite(metrics.latencyMsTotal)),
|
|
396
405
|
latencySamples: Math.max(0, finite(metrics.latencySamples)),
|
|
397
406
|
lastLatencyMs: typeof metrics.lastLatencyMs === "number" ? metrics.lastLatencyMs : undefined,
|
|
@@ -417,6 +426,8 @@ export interface StepUsage {
|
|
|
417
426
|
readonly at?: number
|
|
418
427
|
readonly latencyMs?: number
|
|
419
428
|
readonly premium?: boolean
|
|
429
|
+
/** True when this step drew from the plan-model pool (escalation allowance). */
|
|
430
|
+
readonly planPool?: boolean
|
|
420
431
|
}
|
|
421
432
|
|
|
422
433
|
export interface BootstrapSession {
|
|
@@ -565,6 +576,25 @@ export class LedgerStore {
|
|
|
565
576
|
return this.ledger.premiumSpend.find((entry) => entry.month === month)?.usd ?? 0
|
|
566
577
|
}
|
|
567
578
|
|
|
579
|
+
/**
|
|
580
|
+
* Spend against the plan-model pool (the escalation allowance), tracked in
|
|
581
|
+
* its own monthly buckets so the $120 pool gate reads one number that
|
|
582
|
+
* survives restarts. Premium spend that is not plan-pool spend - Command
|
|
583
|
+
* Code GOAT flagships on their own allowances - never lands here.
|
|
584
|
+
*/
|
|
585
|
+
planPoolSpent(month = monthKey()) {
|
|
586
|
+
return this.ledger.planPoolSpend.find((entry) => entry.month === month)?.usd ?? 0
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
private addPlanPoolSpend(usd: number, at: number) {
|
|
590
|
+
if (usd <= 0) return
|
|
591
|
+
const month = monthKey(at)
|
|
592
|
+
const existing = this.ledger.planPoolSpend.find((entry) => entry.month === month)
|
|
593
|
+
if (existing) existing.usd += usd
|
|
594
|
+
else this.ledger.planPoolSpend.push({ month, usd })
|
|
595
|
+
this.ledger.metrics.planPoolUsd += usd
|
|
596
|
+
}
|
|
597
|
+
|
|
568
598
|
private addPremiumSpend(usd: number) {
|
|
569
599
|
if (usd <= 0) return
|
|
570
600
|
const month = monthKey()
|
|
@@ -626,6 +656,7 @@ export class LedgerStore {
|
|
|
626
656
|
}
|
|
627
657
|
}
|
|
628
658
|
if (input.premium) this.addPremiumSpend(event.usd)
|
|
659
|
+
if (input.planPool) this.addPlanPoolSpend(event.usd, at)
|
|
629
660
|
|
|
630
661
|
const session = this.ensureSession(input.sessionID)
|
|
631
662
|
session.lastModel = input.model
|