@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/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 redesign with a screenshot; it should require a vision model and record a premium approval suggestion.
106
- 6. Run `/router-approve` and repeat the redesign; a premium model may be selected once.
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:
@@ -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. `routing.failClosed` defaults to true. Manual selections and paused routing are outside the strict block.
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
- A routing exception must also block. The earlier catch-and-continue path was a fail-open defect and is being removed.
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
 
@@ -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
- ## Fail-closed policy
48
+ ## Failure policy
49
49
 
50
- `routing.failClosed` defaults to `true`. When no eligible route exists and the current model is unqualified, the router:
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
- The installed server was probed with a throwing prompt hook: the prompt returned HTTP 500 and was not admitted; the session had no model and no request was sent. OpenCode does not expose a typed rejection API. The HTTP error is generic, so the title and notification are the user-facing explanation. This is a documented limitation, not a silent execution path. Manual selections and paused routing are outside the strict block.
58
-
59
- Every unexpected routing exception is now converted into a blocked decision, a `BLOCKED` title, and a `RouterBlockedError` before admission. A malformed model definition or an engine bug can no longer fall through to an unqualified model. The opt-out is explicit: `routing.failClosed: false`.
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.4.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",
@@ -105,8 +105,8 @@ export const DEFAULT_SETTINGS: RouterSettings = {
105
105
  requireZdr: false,
106
106
  },
107
107
  premium: {
108
- mode: "ask",
109
- monthlyBudgetUsd: 0,
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
- failClosed: true,
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
- : "allowed",
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 (candidate.premium.mode === "allowed" && globalSettings.premium.mode !== "allowed") {
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 } : {}),
@@ -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: 0,
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: false,
522
+ approved: true,
520
523
  monthlyAllowanceUsd: 20,
521
- notes: "Unscored in the current model cards. Explicit approval required.",
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
- if (expectedProbe && quotaIsKnown && settings.routing?.failClosed !== false) {
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
- context: 328_000,
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: false,
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
- context: 400_000,
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: 0,
354
+ intelligence: 55,
331
355
  design: 88,
332
356
  designEvidence: "unverified",
333
357
  tier: 3,
334
- approved: false,
335
- notes: "Plan model. Unscored. Explicit approval required.",
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,
@@ -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