@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.
@@ -209,6 +209,35 @@ export const estimateTaskCost = (input: {
209
209
  }
210
210
  }
211
211
 
212
+ /**
213
+ * Which capability axis each task kind reads, and the benchmark-neutral point
214
+ * on that axis. Kinds with no axis get no nudge. Neutral points sit at the
215
+ * middle of the observed benchmark band, so a score above the band's middle
216
+ * helps and one below it hurts - by at most CAPABILITY_MAX_NUDGE.
217
+ */
218
+ const CAPABILITY_AXIS: Partial<Record<TaskClass["kind"], keyof NonNullable<ModelRecord["capabilities"]>>> = {
219
+ feature: "coding",
220
+ debug: "debugging",
221
+ refactor: "coding",
222
+ design: "architecture",
223
+ architecture: "architecture",
224
+ security: "architecture",
225
+ test: "coding",
226
+ docs: undefined,
227
+ deterministic: undefined,
228
+ mechanical: undefined,
229
+ unknown: undefined,
230
+ }
231
+
232
+ const CAPABILITY_NEUTRAL: Record<"coding" | "debugging" | "agentic" | "architecture", number> = {
233
+ coding: 60,
234
+ debugging: 68,
235
+ agentic: 55,
236
+ architecture: 80,
237
+ }
238
+
239
+ const CAPABILITY_MAX_NUDGE = 0.04
240
+
212
241
  const observedSuccess = (model: ModelRecord, session: EngineInput["session"], _key: string): number => {
213
242
  // Only capability failures should reduce a model's expected success rate.
214
243
  // Provider, network, auth, tool, and context-overflow errors are handled by
@@ -249,6 +278,21 @@ export const estimateSuccess = (input: {
249
278
  const threshold = task.sophisticatedDesign ? 75 : 60
250
279
  if (model.design < threshold) probability *= 0.72
251
280
  }
281
+ // Per-kind capability evidence. A model with no measured capability for the
282
+ // task's kind stays neutral: an unscored model is not a zero-capability
283
+ // model, and inventing a score would poison every ranking it entered. With
284
+ // evidence, the benchmark gap moves the probability by at most a few points
285
+ // either way - enough to break a close ranking in favor of the model that
286
+ // measurably does this kind of work better, never enough to overrule a
287
+ // context, quota, or floor gate.
288
+ if (model.capabilities && CAPABILITY_AXIS[task.kind]) {
289
+ const axis = CAPABILITY_AXIS[task.kind]!
290
+ const score = model.capabilities[axis]
291
+ if (typeof score === "number") {
292
+ const edge = score - CAPABILITY_NEUTRAL[axis]
293
+ probability += Math.max(-CAPABILITY_MAX_NUDGE, Math.min(CAPABILITY_MAX_NUDGE, edge / 500))
294
+ }
295
+ }
252
296
  const floor = input.intelligenceFloor ?? 39.5
253
297
  if (task.tier >= 2 && model.intelligence < floor) probability *= 0.7
254
298
  if (model.context > 0 && input.contextTokens > model.context * 0.8) probability *= 0.9
@@ -619,7 +663,16 @@ export const routeTask = (input: EngineInput): EngineResult => {
619
663
  // Only protect the escalation pool when an ordinary-allowance route can
620
664
  // actually serve the task. A task that needs escalation must still be
621
665
  // able to escalate.
622
- const ordinaryAvailable = unprotected.some((entry) => !entry.model.escalationOnly && !entry.model.premium)
666
+ // A tier-3 task served by a standard model runs at ~0.7 expected
667
+ // success by construction (estimateSuccess). That is below the bar a
668
+ // tier-3 model meets, so for tier-3 work no ordinary route "serves" the
669
+ // task and the pool stays available for it. Tier 1/2 work meets the bar
670
+ // at 0.93+, so the pool stays protected for ordinary tasks and can never
671
+ // be drained by routine work.
672
+ if (input.task.tier >= 3) return unprotected
673
+ const ordinaryAvailable = unprotected.some(
674
+ (entry) => !entry.model.escalationOnly && !entry.model.premium && entry.probability >= 0.85,
675
+ )
623
676
  if (!ordinaryAvailable) return unprotected
624
677
  return run(true)
625
678
  }
@@ -873,8 +926,14 @@ export const routeTask = (input: EngineInput): EngineResult => {
873
926
  )
874
927
  reasons.push(...quotaNotes)
875
928
 
929
+ // The trace keeps the twelve cheapest eligible routes plus, always, the
930
+ // chosen model: on an escalation the flagship is the most expensive row, so
931
+ // a bare cost-ordered slice would truncate exactly the entry an escalation
932
+ // exists to show.
933
+ const chosenEntry = chosen
876
934
  const candidates = evaluated
877
- .slice(0, 12)
935
+ .sort((a, b) => a.candidate.effectiveCost - b.candidate.effectiveCost)
936
+ .filter((entry, index) => index < 12 || entry === chosenEntry)
878
937
  .map((entry) => ({
879
938
  ...entry.candidate,
880
939
  retained: keptForAffinity ? entry.model === currentEval?.model : entry.model === chosen.model,
package/src/core/types.ts CHANGED
@@ -99,6 +99,21 @@ export interface ModelRecord {
99
99
  readonly intelligence: number
100
100
  /** Curated front-end and UX quality score. */
101
101
  readonly design: number
102
+ /**
103
+ * Per-kind capability evidence, when measured benchmarks exist. Absent
104
+ * axes are unknown, never zero: an unscored model stays neutral in
105
+ * ranking rather than being treated as incapable.
106
+ */
107
+ readonly capabilities?: {
108
+ /** Agentic coding benchmark, percent. */
109
+ readonly coding?: number
110
+ /** Complex-debugging / automation benchmark, percent. */
111
+ readonly debugging?: number
112
+ /** Long-horizon autonomous-execution benchmark, percent. */
113
+ readonly agentic?: number
114
+ /** Curated architectural-reasoning score, same 0-100 scale. */
115
+ readonly architecture?: number
116
+ }
102
117
  readonly designEvidence: "benchmark" | "curated" | "observed" | "unverified"
103
118
  /**
104
119
  * Credit multiplier applied to this model's usage value. One for every
@@ -283,10 +298,16 @@ export interface ProviderQuotaSnapshot {
283
298
  }
284
299
 
285
300
  export interface PremiumState {
286
- readonly mode: "off" | "ask" | "allowed"
301
+ readonly mode: "off" | "ask" | "allowed" | "auto"
287
302
  readonly monthlyBudgetUsd: number
288
303
  readonly spentUsd: number
289
304
  readonly remainingUsd: number
305
+ /**
306
+ * Remaining plan-model pool allowance for the month. In auto mode this, not
307
+ * `remainingUsd`, is what escalation draws against; undefined when the
308
+ * caller has no pool identity for the model in play.
309
+ */
310
+ readonly planPoolRemainingUsd?: number
290
311
  readonly oneShotGranted: boolean
291
312
  readonly oneShotGrantedAt?: number
292
313
  }
@@ -402,6 +423,8 @@ export interface Metrics {
402
423
  dryRunDecisions: number
403
424
  premiumRequests: number
404
425
  premiumUsd: number
426
+ /** Lifetime spend against the plan-model pool (escalation allowance). */
427
+ planPoolUsd: number
405
428
  latencyMsTotal: number
406
429
  latencySamples: number
407
430
  lastLatencyMs?: number
@@ -454,6 +477,13 @@ export interface Ledger {
454
477
  /** Hourly aggregates for anything older than the raw-event horizon. */
455
478
  usageRollup?: UsageRollup
456
479
  premiumSpend: Array<{ month: string; usd: number }>
480
+ /**
481
+ * Monthly spend against the plan-model pool (the escalation allowance),
482
+ * separate from `premiumSpend` so the pool gate reads exactly what the
483
+ * pool funded. The hydrator always materializes this array, so ledgers
484
+ * written before the field existed read as zero spend.
485
+ */
486
+ planPoolSpend: Array<{ month: string; usd: number }>
457
487
  sessions: Record<string, SessionState>
458
488
  decisions: RoutingDecision[]
459
489
  metrics: Metrics
@@ -597,7 +627,7 @@ export interface PrivacySettings {
597
627
  }
598
628
 
599
629
  export interface PremiumSettings {
600
- readonly mode: "off" | "ask" | "allowed"
630
+ readonly mode: "off" | "ask" | "allowed" | "auto"
601
631
  readonly monthlyBudgetUsd: number
602
632
  readonly oneShotApproval: boolean
603
633
  }
@@ -611,7 +641,20 @@ export interface RoutingSettings {
611
641
  readonly largeContextHysteresisPct: number
612
642
  readonly maxEscalationsPerTask: number
613
643
  readonly preferSameProvider: boolean
614
- /** Reject a prompt when the current model is unqualified and no eligible route exists. */
644
+ /**
645
+ * Block a prompt when this plugin cannot produce a routing decision.
646
+ *
647
+ * Default false, and that default is the point: routing is an optimization,
648
+ * and a defect inside the router must degrade to "do not route", never to
649
+ * "do not work". An internal error leaves the current model in place and lets
650
+ * the prompt through.
651
+ *
652
+ * This does NOT govern quota safety. A provider that is configured to be
653
+ * probed but has no reading is always refused, because that is a spending
654
+ * decision about a provider the router cannot see, not a response to its own
655
+ * failure. Set this true only if you would rather be stopped than pay for an
656
+ * unrouted request.
657
+ */
615
658
  readonly failClosed: boolean
616
659
  /**
617
660
  * Refuse to route a task to a model funded from a pool reserved for
@@ -686,8 +729,6 @@ export interface SpeedSettings {
686
729
  }
687
730
 
688
731
  export interface UiSettings {
689
- /** Prefix the session title with AUTO / MANUAL / PAUSED / DRY RUN and the current model. */
690
- readonly titleIndicator: boolean
691
732
  /** Send a native macOS notification when the provider or model changes. */
692
733
  readonly notifications: boolean
693
734
  /** Use a shorter, denser indicator title and UI chrome (compact mode). */
package/src/gui/index.tsx CHANGED
@@ -1443,6 +1443,8 @@ function SmartRouterPanel(props: { sessionID: string | undefined }) {
1443
1443
  * happens within a frame or two; everywhere else the answer is "no" and
1444
1444
  * nothing changes.
1445
1445
  */
1446
+
1447
+
1446
1448
  void (async () => {
1447
1449
  try {
1448
1450
  const state: any = await Promise.race([
@@ -1456,9 +1458,11 @@ function SmartRouterPanel(props: { sessionID: string | undefined }) {
1456
1458
  }
1457
1459
  })()
1458
1460
  const timer = setInterval(() => void load(), 3_000)
1461
+ // The panel only reflects the window-scoped controller's state.
1459
1462
  onCleanup(() => clearInterval(timer))
1460
1463
  })
1461
1464
 
1465
+
1462
1466
  const view = (): DecisionView | undefined => summariseDecision(status()?.lastDecision as never)
1463
1467
  const providers = (): ProviderRow[] =>
1464
1468
  ((status()?.quota ?? []) as never[]).map((snapshot) => toProviderRow(snapshot))
@@ -1651,6 +1655,7 @@ export default Plugin.define({
1651
1655
  */
1652
1656
  const autoOpenTimer = setInterval(tryAutoOpen, 800)
1653
1657
 
1658
+
1654
1659
  ctx.add(Slot, {
1655
1660
  at: "window.bottom",
1656
1661
  render: () => (
package/src/index.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { Plugin } from "@opencode/plugin"
2
- import { RouterBlockedError, RouterController } from "./server/controller.js"
3
- import { stripTitlePrefix } from "./core/ui.js"
2
+ import { RouterController } from "./server/controller.js"
4
3
  import { sharedCore } from "./server/shared-core.js"
5
4
 
6
5
  const describe = (error: unknown) => (error instanceof Error ? error.message : String(error))
@@ -12,36 +11,22 @@ export default Plugin.define({
12
11
  try {
13
12
  return await controller.initialize()
14
13
  } catch (error) {
15
- // A broken location must not take down the host or the other bridges.
14
+ // A broken location must not take down the host or the other bridges, and
15
+ // it must not take the owner's ability to send a prompt with it.
16
+ //
17
+ // This branch used to install a "minimal fail-closed hook" that rejected
18
+ // every prompt until the next reload. That turned a failed setup into a
19
+ // disabled editor, which is the worst possible response to a plugin that
20
+ // is already not working. Verified in scripts/isolated-e2e.sh: a plugin
21
+ // hook that throws fails the request outright, so a hook here is not a
22
+ // safety net.
23
+ //
24
+ // The correct outcome is that this location has no router. OpenCode then
25
+ // behaves exactly as it would with the plugin uninstalled, which is the
26
+ // guarantee the plugin owes its host. The failure stays visible in
27
+ // diagnostics and in the status RPC of any bridge that did start.
16
28
  sharedCore.recordError(`setup ${ctx.location?.directory ?? ""}: ${describe(error)}`)
17
29
  await controller.dispose().catch(() => undefined)
18
- // A broken location must not silently execute prompts with no routing.
19
- // Keep a minimal fail-closed hook until the next reload.
20
- try {
21
- await ctx.session.hook("prompt", async (event: any) => {
22
- const sessionID = event?.sessionID
23
- if (!sessionID) throw new RouterBlockedError("Smart Router setup failed")
24
- let info: any
25
- try {
26
- info = await ctx.session.get({ sessionID })
27
- } catch {
28
- info = undefined
29
- }
30
- const localID = ctx.location?.project?.id
31
- if (info?.projectID && localID && info.projectID !== localID) return
32
- if (info) {
33
- try {
34
- const base = stripTitlePrefix(typeof info.title === "string" ? info.title : "") || "Session"
35
- await ctx.session.update({ sessionID, title: `BLOCKED · router setup failed — ${base}` })
36
- } catch {
37
- // The rejection below still stops admission.
38
- }
39
- }
40
- throw new RouterBlockedError("Smart Router setup failed; routing is unavailable")
41
- })
42
- } catch (hookError) {
43
- sharedCore.recordError(`setup fallback hook ${ctx.location?.directory ?? ""}: ${describe(hookError)}`)
44
- }
45
30
  return () => undefined
46
31
  }
47
32
  },
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Containment for the host boundary.
3
+ *
4
+ * WHAT THIS IS FOR. OpenCode does not protect itself from a plugin: a hook that
5
+ * throws fails the request, and a hook that never settles leaves the editor
6
+ * waiting forever. Verified against a real OpenCode process on 2026-11-06, in
7
+ * `scripts/isolated-e2e.sh` -- see the `hook-throws` and `hook-hangs`
8
+ * scenarios.
9
+ *
10
+ * That makes "the host is robust" an assumption this plugin cannot rely on.
11
+ * The only place the guarantee can come from is here: whatever the router does
12
+ * internally, the function OpenCode calls must return, and must not throw.
13
+ *
14
+ * TWO RULES.
15
+ *
16
+ * 1. A hook never propagates an exception. The prompt is served unrouted, which
17
+ * is exactly what would have happened if the plugin were not installed.
18
+ *
19
+ * 2. A hook never waits forever. Routing runs against a deadline; if the router
20
+ * is still working when it expires, the prompt proceeds without it. The
21
+ * router is an optimization, and an optimization that cannot finish is
22
+ * worth nothing -- but only if it gets out of the way.
23
+ *
24
+ * This module has no dependencies and no state, so it cannot be part of the
25
+ * thing that fails.
26
+ */
27
+
28
+ /** The narrow shape a contained hook needs from the plugin's own error type. */
29
+ interface BlockedLike {
30
+ readonly name?: string
31
+ }
32
+
33
+ const isBlocked = (error: unknown): boolean =>
34
+ typeof error === "object" &&
35
+ error !== null &&
36
+ ((error as BlockedLike).name === "RouterBlockedError" || (error as any).routerBlocked === true)
37
+
38
+ export interface ContainOptions {
39
+ /** What to call this hook in a diagnostic. Never user-facing. */
40
+ readonly label: string
41
+ /** Called with the reason a hook was contained. Must not throw. */
42
+ readonly onContained?: (message: string) => void
43
+ /**
44
+ * Whether an exception is a deliberate refusal that must reach the host.
45
+ * Deliberate refusals are the one thing that may still stop a prompt,
46
+ * because they are a decision rather than a defect.
47
+ */
48
+ readonly allowBlocked?: boolean
49
+ }
50
+
51
+ const describe = (error: unknown): string => (error instanceof Error ? error.message : String(error))
52
+
53
+ /**
54
+ * Wrap a hook so it can never throw and never hang.
55
+ *
56
+ * The returned function always resolves. A `RouterBlockedError` is re-thrown
57
+ * when `allowBlocked` is set, because that is the router saying "I refuse",
58
+ * which is a supported outcome. Everything else is a defect, and a defect
59
+ * leaves the prompt alone.
60
+ */
61
+ export const containHook = <TEvent>(
62
+ handler: (event: TEvent) => Promise<unknown> | unknown,
63
+ options: ContainOptions,
64
+ ): ((event: TEvent) => Promise<void>) => {
65
+ const report = (message: string) => {
66
+ try {
67
+ options.onContained?.(message)
68
+ } catch {
69
+ // A diagnostic sink that throws must not resurrect the failure.
70
+ }
71
+ }
72
+
73
+ return async (event: TEvent): Promise<void> => {
74
+ try {
75
+ await handler(event)
76
+ } catch (error) {
77
+ if (options.allowBlocked && isBlocked(error)) throw error
78
+ report(`${options.label}: ${describe(error)}`)
79
+ }
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Run work against a deadline, resolving to `undefined` when it is exceeded.
85
+ *
86
+ * `Promise.race` would leave the slow work running and unobserved, which can
87
+ * produce an unhandled rejection later and, worse, keep doing work whose result
88
+ * nobody will read. This variant attaches handlers to the original promise so a
89
+ * late rejection is swallowed deliberately, and reports the timeout so the
90
+ * owner can see that routing was skipped.
91
+ *
92
+ * The timer is cleared on the normal path: a deadline that has already been met
93
+ * must not keep the process alive or fire against a later request.
94
+ */
95
+ export const withDeadline = async <T>(input: {
96
+ readonly work: () => Promise<T> | T
97
+ readonly ms: number
98
+ readonly onTimeout: () => void
99
+ }): Promise<T | undefined> => {
100
+ let timer: ReturnType<typeof setTimeout> | undefined
101
+ let settled = false
102
+
103
+ const deadline = new Promise<undefined>((resolve) => {
104
+ timer = setTimeout(() => {
105
+ settled = true
106
+ try {
107
+ input.onTimeout()
108
+ } catch {
109
+ // Reporting the timeout must not replace it with another failure.
110
+ }
111
+ resolve(undefined)
112
+ }, input.ms)
113
+ // Never hold the event loop open on a deadline alone.
114
+ ;(timer as any)?.unref?.()
115
+ })
116
+
117
+ const work = Promise.resolve()
118
+ .then(input.work)
119
+ .catch((error) => {
120
+ // The caller's handler is responsible for the failure; this only stops a
121
+ // rejection from escaping after the deadline has already won the race.
122
+ if (settled) return undefined
123
+ throw error
124
+ })
125
+
126
+ try {
127
+ return (await Promise.race([work, deadline])) as T | undefined
128
+ } finally {
129
+ if (timer) clearTimeout(timer)
130
+ }
131
+ }