@qadanm/opencode-smart-router 0.0.0-stage → 0.4.0

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 (65) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/CONTRIBUTING.md +96 -0
  3. package/LICENSE +21 -0
  4. package/README.md +341 -2
  5. package/SECURITY.md +174 -0
  6. package/config.example.json +108 -0
  7. package/dist/allowance.js +487 -0
  8. package/dist/gui-experimental.cjs +1 -0
  9. package/dist/index.js +11111 -0
  10. package/dist/npm/index.js +40 -0
  11. package/dist/tui.js +587 -0
  12. package/docs/ARCHITECTURE.md +104 -0
  13. package/docs/DEBUGGING-GUI.md +70 -0
  14. package/docs/INSTALL.md +120 -0
  15. package/docs/LIMITATIONS.md +213 -0
  16. package/docs/MODELS.md +124 -0
  17. package/docs/OPENDESIGN-INTEGRATION.md +635 -0
  18. package/docs/QA.md +142 -0
  19. package/docs/SHARED-CORE.md +72 -0
  20. package/install +75 -0
  21. package/package.json +92 -4
  22. package/scripts/install.sh +324 -0
  23. package/scripts/uninstall.sh +91 -0
  24. package/src/allowance.ts +135 -0
  25. package/src/core/classify/classifier.ts +305 -0
  26. package/src/core/config-writer.ts +167 -0
  27. package/src/core/config.ts +1030 -0
  28. package/src/core/context/strategy.ts +188 -0
  29. package/src/core/context-health.ts +39 -0
  30. package/src/core/failures.ts +398 -0
  31. package/src/core/history/opencode.ts +173 -0
  32. package/src/core/models/protocol.ts +22 -0
  33. package/src/core/models/registry.ts +203 -0
  34. package/src/core/models/seed.ts +771 -0
  35. package/src/core/offline/mode.ts +321 -0
  36. package/src/core/plan-page.ts +390 -0
  37. package/src/core/plans/catalog.ts +481 -0
  38. package/src/core/policy/calibration.ts +259 -0
  39. package/src/core/policy/eligibility.ts +578 -0
  40. package/src/core/providers/enablement.ts +143 -0
  41. package/src/core/providers/opendesign-credits.ts +206 -0
  42. package/src/core/providers/opendesign-models.ts +628 -0
  43. package/src/core/providers/opendesign.ts +474 -0
  44. package/src/core/quota/calibration.ts +166 -0
  45. package/src/core/quota/commandcode.ts +471 -0
  46. package/src/core/quota/credentials.ts +490 -0
  47. package/src/core/quota/ledger.ts +1273 -0
  48. package/src/core/quota/opencode-go.ts +148 -0
  49. package/src/core/quota/opendesign.ts +1043 -0
  50. package/src/core/quota/reconcile.ts +155 -0
  51. package/src/core/quota/report.ts +261 -0
  52. package/src/core/quota/service.ts +273 -0
  53. package/src/core/reprompt.ts +48 -0
  54. package/src/core/routing/engine.ts +919 -0
  55. package/src/core/types.ts +825 -0
  56. package/src/core/ui.ts +236 -0
  57. package/src/core/util.ts +114 -0
  58. package/src/gui/index.tsx +1670 -0
  59. package/src/gui/viewmodel.ts +456 -0
  60. package/src/index.ts +48 -0
  61. package/src/rpc.ts +204 -0
  62. package/src/server/controller.ts +3281 -0
  63. package/src/server/route-tracker.ts +60 -0
  64. package/src/server/shared-core.ts +671 -0
  65. package/src/tui/index.tsx +168 -0
@@ -0,0 +1,3281 @@
1
+ import { appendFileSync, existsSync } from "node:fs"
2
+ import { homedir } from "node:os"
3
+ import { resolve } from "node:path"
4
+ import type {
5
+ ModelRecord,
6
+ RuntimeMode,
7
+ ModelRef,
8
+ PremiumState,
9
+ ProviderQuotaSnapshot,
10
+ RouterSettings,
11
+ RouterStatus,
12
+ RoutingDecision,
13
+ SessionState,
14
+ TaskClass,
15
+ } from "../core/types.js"
16
+ import { classifyTask, type ClassifierFile } from "../core/classify/classifier.js"
17
+ import { computeContextHealth } from "../core/context-health.js"
18
+ import { decideCompaction, describeMode } from "../core/context/strategy.js"
19
+ import { repairMessages } from "../core/reprompt.js"
20
+ import { providerProtocol } from "../core/models/protocol.js"
21
+ import { PromptRouteTracker } from "./route-tracker.js"
22
+ import { buildProviderUsageReport } from "../core/quota/report.js"
23
+ import { resolvePlan, resolvePlanWithReason } from "../core/plans/catalog.js"
24
+ import { loadSettings, validateSettings, type SettingsLoadResult } from "../core/config.js"
25
+ import { writeSettingsPatch } from "../core/config-writer.js"
26
+ import { describeConsoleReading, parseConsolePage, type UsageReading } from "../core/plan-page.js"
27
+ import { reconcileUsage } from "../core/quota/reconcile.js"
28
+ import {
29
+ applyCredentialDefaults,
30
+ credentialEnvVars,
31
+ credentialFileCandidates,
32
+ describeEnablement,
33
+ type ProviderEvidence,
34
+ } from "../core/providers/enablement.js"
35
+ import { classifyFailure } from "../core/failures.js"
36
+ import { buildRegistry } from "../core/models/registry.js"
37
+ import type { QuotaService } from "../core/quota/service.js"
38
+ import { LedgerStore, type BootstrapSession } from "../core/quota/ledger.js"
39
+ import {
40
+ discoverOpenDesignControlKey,
41
+ discoverOpenDesignCredentials,
42
+ } from "../core/quota/credentials.js"
43
+ import { routeTask } from "../core/routing/engine.js"
44
+ import {
45
+ CALIBRATION_WINDOW_MS,
46
+ MIN_REQUESTS_FOR_COST_EVIDENCE,
47
+ resolveIntelligenceFloor,
48
+ type CalibrationResult,
49
+ } from "../core/policy/calibration.js"
50
+ import { decidePremium, evaluateEligibility } from "../core/policy/eligibility.js"
51
+ import { sharedCore, type RuntimeOverrides } from "./shared-core.js"
52
+ import {
53
+ ChangeNotifier,
54
+ buildIndicatorTitle,
55
+ formatQuotaLines,
56
+ indicatorState,
57
+ providerShort,
58
+ quotaStateLabel,
59
+ resolveBaseTitle,
60
+ stripTitlePrefix,
61
+ type Indicator,
62
+ } from "../core/ui.js"
63
+ import {
64
+ discoverOpenDesignModels,
65
+ normalizeOpenDesignBaseURL,
66
+ } from "../core/providers/opendesign.js"
67
+ import { OPEN_DESIGN_MODEL_BY_ID } from "../core/providers/opendesign-models.js"
68
+ import { creditMultiplierFor } from "../core/providers/opendesign-credits.js"
69
+ import { SmartRouterRpc } from "../rpc.js"
70
+ import {
71
+ describePlan,
72
+ keyOfRef,
73
+ modelKey,
74
+ nowId,
75
+ parseModelKey,
76
+ percent,
77
+ redact,
78
+ safeError,
79
+ truncate,
80
+ unique,
81
+ usd,
82
+ } from "../core/util.js"
83
+
84
+ interface ControllerOptions {
85
+ readonly ctx: any
86
+ readonly pluginOptions?: unknown
87
+ }
88
+
89
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
90
+ typeof value === "object" && value !== null && !Array.isArray(value)
91
+
92
+ /**
93
+ * How long one reported fault is treated as the same fault. The host reports
94
+ * a failed attempt through several channels in quick succession, so this only
95
+ * needs to span one attempt, not one session.
96
+ */
97
+ /**
98
+ * How long a computed floor is reused.
99
+ *
100
+ * Calibration reads the usage rollup, which changes per request rather than per
101
+ * candidate. Recomputing it for every routing decision would be wasted work in a
102
+ * multi-step loop; this is short enough that a spend change is picked up within
103
+ * a session.
104
+ */
105
+ const FLOOR_CACHE_MS = 30_000
106
+
107
+ const FAULT_DEDUPE_WINDOW_MS = 30_000
108
+
109
+ /**
110
+ * How long the router waits after triggering a compaction before it may
111
+ * trigger another for the same session. The strategy's configured cooldown
112
+ * supersedes this; it is retained as the documented upper bound.
113
+ */
114
+ const ROUTER_COMPACT_COOLDOWN_MS = 10 * 60_000
115
+
116
+ /**
117
+ * Fallbacks for the context constants. The strategy module owns the real
118
+ * values; these names are kept so existing call sites read the same way.
119
+ */
120
+ const CONTEXT_ELIGIBILITY_MARGIN_TOKENS = 20_000
121
+ const CONTEXT_SAFE_WINDOW_FRACTION = 0.95
122
+
123
+ /**
124
+ * How many attempts a provider-scoped failure may have before the router
125
+ * stops retrying. Each attempt is refused before a model runs, so it costs
126
+ * no allowance. The delay is the only price, and it is far cheaper than a
127
+ * turn dying and waiting for the owner to restart it.
128
+ */
129
+ const PROVIDER_SCOPED_RETRY_ATTEMPTS = 5
130
+
131
+ /**
132
+ * How long to avoid a provider whose allowance is spent, when nothing is known
133
+ * about when it resets.
134
+ *
135
+ * Deliberately longer than a transient backoff: an empty allowance does not
136
+ * recover in seconds, and re-probing it on every prompt costs a failed turn
137
+ * each time to relearn the same fact. A known reset time always wins over this.
138
+ */
139
+ const QUOTA_EXHAUSTED_FALLBACK_MS = 30 * 60 * 1000
140
+
141
+ const unwrap = <T>(value: unknown): T[] => {
142
+ if (Array.isArray(value)) return value as T[]
143
+ if (isRecord(value) && Array.isArray(value.data)) return value.data as T[]
144
+ return []
145
+ }
146
+
147
+ const modelRefFrom = (value: unknown): ModelRef | undefined => {
148
+ if (!isRecord(value)) return undefined
149
+ if (typeof value.providerID !== "string" || typeof value.id !== "string") return undefined
150
+ return {
151
+ providerID: value.providerID,
152
+ id: value.id,
153
+ ...(typeof value.variant === "string" ? { variant: value.variant } : {}),
154
+ }
155
+ }
156
+
157
+ const tokensFrom = (value: unknown) => {
158
+ if (!isRecord(value)) return { input: 0, output: 0, cache: { read: 0, write: 0 } }
159
+ const cache = isRecord(value.cache) ? value.cache : {}
160
+ return {
161
+ input: typeof value.input === "number" ? value.input : 0,
162
+ output: typeof value.output === "number" ? value.output : 0,
163
+ cache: {
164
+ read: typeof cache.read === "number" ? cache.read : 0,
165
+ write: typeof cache.write === "number" ? cache.write : 0,
166
+ },
167
+ }
168
+ }
169
+
170
+ const applyRuntime = (base: RouterSettings, runtime: RuntimeOverrides): RouterSettings => {
171
+ const next = structuredClone(base) as any
172
+ if (runtime.mode) next.mode = runtime.mode
173
+ if (runtime.paused === true) next.enabled = false
174
+ if (typeof runtime.dryRun === "boolean") next.dryRun = runtime.dryRun
175
+ if (typeof runtime.titleIndicator === "boolean") next.ui.titleIndicator = runtime.titleIndicator
176
+ if (typeof runtime.compactMode === "boolean") {
177
+ next.ui.compactMode = runtime.compactMode
178
+ next.ui.compact = runtime.compactMode
179
+ next.compactMode = runtime.compactMode
180
+ next.compact = runtime.compactMode
181
+ }
182
+ if (typeof runtime.compact === "boolean") {
183
+ next.ui.compactMode = runtime.compact
184
+ next.ui.compact = runtime.compact
185
+ next.compactMode = runtime.compact
186
+ next.compact = runtime.compact
187
+ }
188
+ if (typeof runtime.premiumBudgetUsd === "number") next.premium.monthlyBudgetUsd = runtime.premiumBudgetUsd
189
+ if (runtime.reserves) {
190
+ for (const [providerID, reserve] of Object.entries(runtime.reserves)) {
191
+ const provider = next.providers[providerID]
192
+ if (!provider) continue
193
+ if (typeof reserve.monthlyPct === "number") provider.reserveMonthlyPct = reserve.monthlyPct
194
+ if (typeof reserve.weeklyPct === "number") provider.reserveWeeklyPct = reserve.weeklyPct
195
+ if (typeof reserve.fiveHourPct === "number") provider.reserveFiveHourPct = reserve.fiveHourPct
196
+ }
197
+ }
198
+ const excluded = unique([...(next.models.excluded as string[]), ...(runtime.excluded ?? [])])
199
+ const approved = unique([...(next.models.approved as string[]), ...(runtime.approved ?? [])])
200
+ next.models.excluded = excluded
201
+ next.models.approved = approved
202
+ return next as RouterSettings
203
+ }
204
+
205
+ export class RouterBlockedError extends Error {
206
+ constructor(message: string) {
207
+ super(message)
208
+ this.name = "RouterBlockedError"
209
+ }
210
+ }
211
+
212
+ export class RouterController {
213
+ private readonly ctx: any
214
+ private settingsBase: RouterSettings
215
+ private settingsWarnings: string[]
216
+ private settingsSources: string[]
217
+ private runtime: RuntimeOverrides = {}
218
+ private ledger: LedgerStore
219
+ private registry = buildRegistry({
220
+ live: [],
221
+ settings: loadSettings({ options: {} }).settings,
222
+ })
223
+ private quota!: QuotaService
224
+ private bridgeID = ""
225
+ private readonly expectedSwitch = new Map<string, { key: string; at: number }>()
226
+ /**
227
+ * Request-kind latch keyed by session, written by the `model.request` hook
228
+ * and consumed one-shot by the `context` hook (whose event carries no kind).
229
+ * The agent loop folds prompts; title and compaction runs do not.
230
+ */
231
+ private readonly requestKinds = new Map<string, { kind: string; at: number }>()
232
+ /** The routed task per session's current prompt, for prompt-rejection failover. */
233
+ private readonly promptRoutes = new PromptRouteTracker()
234
+ /**
235
+ * Failovers parked because the session was still busy at rejection time,
236
+ * flushed once the host marks the turn over. One entry, one prompt.
237
+ */
238
+ private readonly pendingHeals = new Map<string, { chosen: ModelRecord; base: RoutingDecision; rejectedLabel: string }>()
239
+ /** Memoized calibrated floor; see routingFloor(). */
240
+ private floorCache?: { at: number; value: { floor: number; calibration?: CalibrationResult } }
241
+ /**
242
+ * Guards against counting one logical provider failure several times.
243
+ *
244
+ * The host reports a single failed attempt through several channels: the
245
+ * retry hook, `session.step.failed`, `session.execution.failed` and
246
+ * `session.idle`. Without this guard each channel added its own strike,
247
+ * so one fault could count three times and inflate the failure tally and
248
+ * the cooldown that is derived from it.
249
+ *
250
+ * The key is the session plus the identity of the attempt itself, so a
251
+ * genuinely new failure later in the session still counts separately.
252
+ */
253
+ private readonly recordedFaults = new Map<string, number>()
254
+ /**
255
+ * Every model a session has been seen on, keyed by model key.
256
+ *
257
+ * The desktop client sends the model it has saved for a session with each
258
+ * prompt. When the router has switched that session to something else, the
259
+ * client's saved model is stale, so the next prompt switches the session
260
+ * back and the host reports a model change the router did not make. That
261
+ * change is always to a model the session has already been on, which is
262
+ * what distinguishes it from the owner picking a model they have not used.
263
+ */
264
+ private readonly modelsSeen = new Map<string, Set<string>>()
265
+ /** When the router last triggered a compaction, per session. */
266
+ private readonly routerCompactAt = new Map<string, number>()
267
+ /** Last context-management decision per session, for diagnostics. */
268
+ private readonly lastCompactionDecision = new Map<
269
+ string,
270
+ ReturnType<typeof decideCompaction>
271
+ >()
272
+ private readonly inflight = new Set<string>()
273
+ private readonly promptSeen = new Set<string>()
274
+ private readonly created = new Map<string, number>()
275
+ private readonly stepStarted = new Map<string, number>()
276
+ private readonly contextLoaded = new Set<string>()
277
+ private readonly contextInflight = new Map<string, Promise<void>>()
278
+ /** What credential detection found on this machine, for the first-run screen. */
279
+ private detectedProviders: ReadonlyArray<{ providerID: string; hasCredential: boolean; source?: string }> = []
280
+ /** Set once the user has seen the first-run screen. */
281
+ private onboardingComplete = false
282
+ private readonly explicitProviderEnable: ReadonlySet<string>
283
+ private readonly titleQueue = new Map<string, Promise<void>>()
284
+ private readonly notifier = new ChangeNotifier()
285
+ private rpcRegistration: any
286
+ private readonly registrations: any[] = []
287
+ private readonly dirCache = new Map<string, string>()
288
+ private readonly localDir: string
289
+ private cleanupDone = false
290
+
291
+ constructor(options: ControllerOptions) {
292
+ this.ctx = options.ctx
293
+ this.localDir = this.canonicalDir(options.ctx.location?.directory)
294
+ const loaded = loadSettings({
295
+ options: options.pluginOptions,
296
+ directory: this.ctx.location?.directory,
297
+ projectDirectory: this.ctx.location?.directory,
298
+ })
299
+ this.settingsBase = loaded.settings
300
+ this.settingsWarnings = loaded.warnings
301
+ this.settingsSources = loaded.sources
302
+ // Which provider enable/disable flags the user wrote themselves. Detection
303
+ // fills in the ones they never mentioned and never overrules these.
304
+ this.explicitProviderEnable = loaded.explicitProviderEnable
305
+ this.ledger = new LedgerStore()
306
+
307
+ // A plan id that matches nothing must never resolve silently to whatever
308
+ // tier this repository defaults to: that could overstate the allowance a
309
+ // routing decision is allowed to spend.
310
+ for (const [providerID, provider] of Object.entries(loaded.settings.providers)) {
311
+ if (!provider?.enabled) continue
312
+ const resolved = resolvePlanWithReason(providerID, provider.plan)
313
+ if (resolved.resolution === "unknown") {
314
+ this.settingsWarnings.push(
315
+ `providers.${providerID}.plan is "${provider.plan}", which is not a known plan for ${providerID}. Using ${resolved.planID ?? "no plan"}, so quota may be miscalculated. Set a plan id from the catalog.`,
316
+ )
317
+ }
318
+ }
319
+ }
320
+
321
+ private get settings(): RouterSettings {
322
+ return applyRuntime(this.settingsBase, this.runtime)
323
+ }
324
+
325
+ /**
326
+ * The runtime mode for one session.
327
+ *
328
+ * A session can be paused on its own, without touching any other session and
329
+ * without disabling the plugin. `SessionState.paused` existed but nothing ever
330
+ * read it, so the switch in the panel would have done nothing. Keeping the
331
+ * rule in one place is what stops the prompt path, the status panel and the
332
+ * compaction path from disagreeing about whether a session is routed.
333
+ */
334
+ private runtimeFor(session: SessionState | undefined): RuntimeMode {
335
+ if (!this.settings.enabled) return "paused"
336
+ if (this.runtime.paused === true) return "paused"
337
+ if (session?.paused === true) return "paused"
338
+ return session?.manualOverride ? "manual" : "automatic"
339
+ }
340
+
341
+ /** RPC and storage require plain JSON values; optional undefined is not JSON. */
342
+ private safe<T>(value: T): any {
343
+ return JSON.parse(JSON.stringify(value))
344
+ }
345
+
346
+ private persistLedgerSoon() {
347
+ sharedCore.persistSoon()
348
+ }
349
+
350
+ private persistRuntime() {
351
+ sharedCore.runtime = this.runtime
352
+ sharedCore.persistRuntimeSoon()
353
+ }
354
+
355
+ private async emit(name: "decision" | "status" | "quota", data: Record<string, unknown>) {
356
+ try {
357
+ await this.rpcRegistration?.events.emit(name, this.safe(data))
358
+ } catch {
359
+ // Events are advisory; a missing GUI connection must not affect routing.
360
+ }
361
+ }
362
+
363
+ private async loadModels() {
364
+ try {
365
+ this.modelList = await sharedCore.getLiveModels()
366
+ this.rebuildRegistry()
367
+ } catch (error) {
368
+ this.settingsWarnings.push(`Could not refresh the live model catalog: ${safeError(error)}`)
369
+ }
370
+ }
371
+
372
+ private rebuildRegistry() {
373
+ const live = unwrap<any>(this.modelList ?? [])
374
+ this.registry = buildRegistry({
375
+ live,
376
+ settings: this.settings,
377
+ definitions: this.settings.models.definitions as any,
378
+ })
379
+ }
380
+
381
+ private modelList: unknown = []
382
+
383
+ async initialize(): Promise<() => void | Promise<void>> {
384
+ // Before anything reads settings: a provider the user never configured
385
+ // should be on only if this machine can actually reach it.
386
+ await this.applyProviderDetection()
387
+ await sharedCore.init({
388
+ ctx: this.ctx,
389
+ getSettings: () => this.settings,
390
+ getCredentials: () => this.resolveQuotaCredentials(),
391
+ })
392
+ this.ledger = sharedCore.ledger
393
+ this.runtime = sharedCore.runtime
394
+ const quota = sharedCore.quota
395
+ if (!quota) throw new Error("Shared quota service is not initialized")
396
+ this.quota = quota
397
+ this.bridgeID = sharedCore.addBridge(this.ctx.location?.directory ?? "", this)
398
+ this.modelList = []
399
+ this.rebuildRegistry()
400
+
401
+ // The shared core owns quota probes, session bootstrap, and ledger writes.
402
+ // Register the user-facing surface without awaiting any network work.
403
+ await this.registerRpc()
404
+ await this.registerCommands()
405
+ await this.registerHooks()
406
+ void this.loadModels()
407
+
408
+ return () => this.dispose()
409
+ }
410
+
411
+ /**
412
+ * Decide which providers this machine can actually reach.
413
+ *
414
+ * Runs before the shared core reads settings. A provider the user never
415
+ * mentioned is enabled only when a credential or connection exists for it, so
416
+ * a stranger stops getting "authentication is failing" for an account they
417
+ * never claimed. A provider the user did mention is left exactly as they set
418
+ * it, and any disagreement is reported rather than acted on.
419
+ *
420
+ * Nothing here reads a secret. Existence is enough to answer the question.
421
+ */
422
+ private async applyProviderDetection(): Promise<void> {
423
+ try {
424
+ const evidence: ProviderEvidence[] = []
425
+
426
+ const connected = async (integration: string): Promise<boolean> => {
427
+ try {
428
+ const connection = await this.ctx.integration.connection.active(integration as never)
429
+ return Boolean(connection)
430
+ } catch {
431
+ return false
432
+ }
433
+ }
434
+
435
+ const providerKey = async (providerID: string): Promise<boolean> => {
436
+ try {
437
+ const provider: any = await this.ctx.provider.get({ providerID })
438
+ const info = provider?.data ?? provider
439
+ return typeof info?.settings?.apiKey === "string" && info.settings.apiKey.length > 0
440
+ } catch {
441
+ return false
442
+ }
443
+ }
444
+
445
+ const fileOrEnv = (providerID: string): { has: boolean; source?: string } => {
446
+ for (const name of credentialEnvVars(providerID)) {
447
+ if (process.env[name]?.trim()) return { has: true, source: name }
448
+ }
449
+ for (const path of credentialFileCandidates(providerID, homedir())) {
450
+ try {
451
+ if (existsSync(path)) return { has: true, source: path.replace(homedir(), "~") }
452
+ } catch {
453
+ // Unreadable path is not evidence either way.
454
+ }
455
+ }
456
+ return { has: false }
457
+ }
458
+
459
+ const goConnected = await connected("opencode")
460
+ if (goConnected) evidence.push({ providerID: "opencode-go", hasCredential: true, source: "OpenCode connection" })
461
+
462
+ const commandCodeConnection = await connected("commandcode")
463
+ const commandCodeFiles = fileOrEnv("commandcode")
464
+ const commandCodeKey = await providerKey("commandcode")
465
+ evidence.push({
466
+ providerID: "commandcode",
467
+ hasCredential: commandCodeConnection || commandCodeFiles.has || commandCodeKey,
468
+ ...(commandCodeConnection
469
+ ? { source: "Command Code integration" }
470
+ : commandCodeFiles.source
471
+ ? { source: commandCodeFiles.source }
472
+ : commandCodeKey
473
+ ? { source: "OpenCode provider settings" }
474
+ : {}),
475
+ })
476
+
477
+ const openDesignFiles = fileOrEnv("opendesign")
478
+ const openDesignKey = await providerKey("opendesign")
479
+ evidence.push({
480
+ providerID: "opendesign",
481
+ hasCredential: openDesignFiles.has || openDesignKey,
482
+ ...(openDesignFiles.source
483
+ ? { source: openDesignFiles.source }
484
+ : openDesignKey
485
+ ? { source: "OpenCode provider settings" }
486
+ : {}),
487
+ })
488
+
489
+ // Keep the evidence: onboarding shows what was found, not a form.
490
+ this.detectedProviders = evidence
491
+ const result = applyCredentialDefaults(this.settingsBase, this.explicitProviderEnable, evidence)
492
+ if (result.settings !== this.settingsBase) {
493
+ this.settingsBase = result.settings
494
+ this.rebuildRegistry()
495
+ }
496
+ for (const line of describeEnablement(result)) {
497
+ if (!this.settingsWarnings.includes(line)) this.settingsWarnings.push(line)
498
+ }
499
+ for (const providerID of result.keptOnByChoice) {
500
+ const line = `providers.${providerID} stays enabled with no credential found — you asked for it`
501
+ if (!this.settingsWarnings.includes(line)) this.settingsWarnings.push(line)
502
+ }
503
+ for (const providerID of result.keptOffByChoice) {
504
+ const line = `providers.${providerID} credentials found but the provider stays disabled — you asked for it`
505
+ if (!this.settingsWarnings.includes(line)) this.settingsWarnings.push(line)
506
+ }
507
+ } catch (error) {
508
+ // Detection is a convenience. A failure must never stop routing.
509
+ this.settingsWarnings.push(`Provider detection skipped: ${safeError(error)}`)
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Everything a first-run screen needs, in one call.
515
+ *
516
+ * The shape is deliberate: evidence first, then a single action. A new user
517
+ * should be able to read this and know three things without clicking — what
518
+ * the router found on their machine, what it will do with it, and whether it
519
+ * needs anything from them (it usually does not).
520
+ */
521
+ private onboardingState(): {
522
+ firstRun: boolean
523
+ providers: ReadonlyArray<{ providerID: string; label: string; found: boolean; source?: string; enabled: boolean }>
524
+ historyImported: number
525
+ hasHistory: boolean
526
+ needsReading: boolean
527
+ mode: string
528
+ version: string
529
+ } {
530
+ const enabled = this.settings.providers
531
+ const providers = this.detectedProviders.map((entry) => ({
532
+ providerID: entry.providerID,
533
+ label: entry.providerID === "opencode-go" ? "OpenCode Go" : entry.providerID === "commandcode" ? "Command Code GOAT" : entry.providerID === "opendesign" ? "OpenDesign" : entry.providerID,
534
+ found: entry.hasCredential,
535
+ ...(entry.source ? { source: entry.source } : {}),
536
+ enabled: enabled[entry.providerID]?.enabled ?? false,
537
+ }))
538
+
539
+ const imported = this.ledger
540
+ .usageEvents()
541
+ .filter((event) => String(event.id).startsWith("hist:"))
542
+ return {
543
+ // First run is "nothing has been chosen yet": no baseline, no explicit
544
+ // provider flags. Reading a file nobody wrote is not a first run.
545
+ firstRun: !this.onboardingComplete && this.explicitProviderEnable.size === 0,
546
+ providers,
547
+ historyImported: imported.length,
548
+ hasHistory: imported.length > 0,
549
+ needsReading: this.quotaSnapshots().some((snapshot) => snapshot.source === "estimate"),
550
+ mode: this.settings.mode,
551
+ version: "0.2.0",
552
+ }
553
+ }
554
+
555
+ /**
556
+ * Record what the account page showed.
557
+ *
558
+ * The whole point is that the user pastes what they see. They should not have
559
+ * to know that Pro's seven-day window is $90, or divide 42% by anything — the
560
+ * server owns the caps, so the server does the arithmetic.
561
+ *
562
+ * Writes through config-writer, which preserves every other key, validates the
563
+ * result and keeps a backup, so a mistake here is recoverable.
564
+ */
565
+ /**
566
+ * Read whatever console page was pasted.
567
+ *
568
+ * One entry point for both page types, because the user should not have to
569
+ * decide which page we want. An allowance page seeds the estimate; a usage
570
+ * page checks the cost model against what was actually charged. Both preview
571
+ * before writing, because a paste is not a promise that it was understood.
572
+ */
573
+ private async recordPlanPage(input: {
574
+ page?: string
575
+ confirm?: boolean
576
+ }): Promise<{
577
+ ok: boolean
578
+ message: string
579
+ preview?: {
580
+ kind: "allowance" | "usage"
581
+ summary: string
582
+ found: string[]
583
+ missing: string[]
584
+ /** For a usage page: whether the catalog agrees with what was charged. */
585
+ reconciliation?: { summary: string; agree: number; differ: number; rows: ReadonlyArray<unknown> }
586
+ apply: Record<string, unknown>
587
+ }
588
+ backup?: string
589
+ }> {
590
+ const reading = parseConsolePage(input.page ?? "")
591
+ if (!reading) {
592
+ return {
593
+ ok: false,
594
+ message:
595
+ "That page could not be read. Paste either the Plan & balance page or the Usage page, including its model table.",
596
+ }
597
+ }
598
+
599
+ if (reading.kind === "usage") {
600
+ const reconciliation = reconcileUsage(reading as UsageReading)
601
+ const summary = describeConsoleReading(reading)
602
+ // A usage page tells us whether our arithmetic is right. It does not tell
603
+ // us how much allowance is left, so nothing is written from it — writing a
604
+ // baseline from a token table would be inventing a percentage.
605
+ return {
606
+ ok: true,
607
+ message: reconciliation.differ > 0 ? `${summary} ${reconciliation.summary}` : summary,
608
+ preview: {
609
+ kind: "usage",
610
+ summary,
611
+ found: [...reading.found],
612
+ missing: [...reading.missing],
613
+ reconciliation: {
614
+ summary: reconciliation.summary,
615
+ agree: reconciliation.agree,
616
+ differ: reconciliation.differ,
617
+ rows: [...reconciliation.rows] as ReadonlyArray<unknown>,
618
+ },
619
+ apply: {},
620
+ },
621
+ }
622
+ }
623
+
624
+ if (reading.designPlanUsedPct === undefined) {
625
+ return { ok: false, message: describeConsoleReading(reading) }
626
+ }
627
+
628
+ const at = Date.now()
629
+ const baseline: Record<string, unknown> = {
630
+ at,
631
+ designPlanUsedPct: reading.designPlanUsedPct,
632
+ pctOf: reading.pctOf ?? "weekly",
633
+ }
634
+ if (reading.resetsAt !== undefined) baseline.weeklyResetsAt = reading.resetsAt
635
+ if (reading.periodEndsAt !== undefined) baseline.monthlyResetsAt = reading.periodEndsAt
636
+
637
+ /**
638
+ * Pasting a balance is an opt-in.
639
+ *
640
+ * Nobody records their OpenDesign usage in order to leave it switched off, so
641
+ * the write states `enabled: true` explicitly. That also makes the intent
642
+ * survive: an explicit flag is something detection will never overrule, which
643
+ * is the correct relationship between a config file and a guess.
644
+ */
645
+ const patch: Record<string, unknown> = { providers: { opendesign: { baseline, enabled: true } } }
646
+ if (reading.plan && resolvePlan("opendesign", reading.plan.toLowerCase())) {
647
+ ;(patch.providers as any).opendesign.plan = reading.plan.toLowerCase()
648
+ }
649
+
650
+ const preview = {
651
+ kind: "allowance" as const,
652
+ summary: describeConsoleReading(reading),
653
+ found: [...reading.found],
654
+ missing: [...reading.missing],
655
+ apply: patch,
656
+ }
657
+
658
+ if (input.confirm !== true) {
659
+ return { ok: true, message: preview.summary, preview }
660
+ }
661
+
662
+ try {
663
+ const result = writeSettingsPatch({
664
+ patch,
665
+ validate: (merged) => {
666
+ const loaded = validateSettings(merged)
667
+ if (loaded.settings.providers.opendesign?.baseline === undefined) {
668
+ return "That reading could not be applied. Check the page and try again."
669
+ }
670
+ return undefined
671
+ },
672
+ })
673
+ await this.reloadSettingsFromDisk()
674
+ return {
675
+ ok: true,
676
+ message: `Recorded. ${preview.summary}.`,
677
+ preview,
678
+ ...(result.backup ? { backup: result.backup } : {}),
679
+ }
680
+ } catch (error) {
681
+ return { ok: false, message: safeError(error) }
682
+ }
683
+ }
684
+
685
+ /** Re-read settings after the GUI writes, so the panel reflects the change at once. */
686
+ private async reloadSettingsFromDisk(): Promise<void> {
687
+ const loaded = loadSettings({
688
+ options: this.ctx.options,
689
+ directory: this.ctx.location?.directory,
690
+ projectDirectory: this.ctx.location?.directory,
691
+ })
692
+ this.settingsBase = loaded.settings
693
+ this.settingsWarnings = loaded.warnings
694
+ this.settingsSources = loaded.sources
695
+
696
+ /**
697
+ * Detection must run again after a reload.
698
+ *
699
+ * Without this, writing any config fragment that omits a provider's
700
+ * `enabled` flag silently disables it: the file no longer says "enabled",
701
+ * DEFAULT_SETTINGS says disabled, and the only thing that had been turning it
702
+ * back on was startup detection. The visible symptom was a provider showing
703
+ * as disabled immediately after the user pasted their balance into it.
704
+ */
705
+ await this.applyProviderDetection()
706
+ this.rebuildRegistry()
707
+ // Snapshots in the cache were computed from the settings that just changed,
708
+ // so they must go before the re-probe rather than being served as stale.
709
+ this.quota?.invalidate()
710
+ // Re-probe what can be probed, so the panel reflects the new reading at once.
711
+ await Promise.allSettled([
712
+ this.quota?.refresh("opencode-go", true),
713
+ this.quota?.refresh("commandcode", true),
714
+ this.quota?.refresh("opendesign", true),
715
+ ])
716
+ }
717
+
718
+ /**
719
+ * When this provider's next window opens, from the best data available.
720
+ *
721
+ * Prefers the tightest window that has a reset time, because that is the one
722
+ * that would unblock the account soonest. Falls back to a modest hold when
723
+ * nothing is known, which is far better than re-probing on every prompt.
724
+ */
725
+ private providerResetAt(providerID: string, now = Date.now()): number {
726
+ const fallback = now + QUOTA_EXHAUSTED_FALLBACK_MS
727
+ const snapshot = this.quotaSnapshots().find((entry) => entry.providerID === providerID)
728
+ if (!snapshot) return fallback
729
+ const resets = [snapshot.rolling?.resetsAt, snapshot.weekly?.resetsAt, snapshot.monthly?.resetsAt]
730
+ .filter((value): value is number => typeof value === "number" && value > now)
731
+ .sort((left, right) => left - right)
732
+ return resets[0] ?? fallback
733
+ }
734
+
735
+ private async resolveQuotaCredentials() { let goAccessToken: string | undefined
736
+ let commandCodeIntegrationKey: string | undefined
737
+ try {
738
+ const connection = await this.ctx.integration.connection.active("opencode")
739
+ if (connection) {
740
+ const credential: any = await this.ctx.integration.connection.resolve(connection)
741
+ goAccessToken = credential?.type === "oauth" ? credential.access : credential?.type === "key" ? credential.key : undefined
742
+ }
743
+ } catch {
744
+ // The Go connection is optional.
745
+ }
746
+ try {
747
+ const connection = await this.ctx.integration.connection.active("commandcode")
748
+ if (connection) {
749
+ const credential: any = await this.ctx.integration.connection.resolve(connection)
750
+ commandCodeIntegrationKey = credential?.type === "key" ? credential.key : credential?.type === "oauth" ? credential.access : undefined
751
+ }
752
+ } catch {
753
+ // The Command Code integration may not be connected; file discovery remains.
754
+ }
755
+ let commandCodeProviderKey: string | undefined
756
+ try {
757
+ const provider = await this.ctx.provider.get({ providerID: "commandcode" })
758
+ const info: any = (provider as any)?.data ?? provider
759
+ commandCodeProviderKey = info?.settings?.apiKey
760
+ } catch {
761
+ // Ignore.
762
+ }
763
+ const { openDesignApiKey, openDesignControlKey } = await this.resolveOpenDesignCredentials()
764
+ return {
765
+ goAccessToken,
766
+ commandCodeIntegrationKey,
767
+ commandCodeProviderKey,
768
+ openDesignApiKey,
769
+ openDesignControlKey,
770
+ }
771
+ }
772
+
773
+ /**
774
+ * Resolve the OpenDesign inference key and, only when the owner configured
775
+ * one explicitly, the account-control key.
776
+ *
777
+ * The inference key can call the model gateway but cannot read quota. The
778
+ * control key can read the account's usage windows and is a privileged
779
+ * credential, so it is never discovered automatically and never requested.
780
+ */
781
+ private async resolveOpenDesignCredentials(): Promise<{ openDesignApiKey?: string; openDesignControlKey?: string }> {
782
+ const providerSettings = this.settings.providers.opendesign
783
+ if (!providerSettings?.enabled) return {}
784
+
785
+ let providerSettingsKey: string | undefined
786
+ try {
787
+ const provider: any = await this.ctx.provider.get({ providerID: "opendesign" })
788
+ const info: any = provider?.data ?? provider
789
+ providerSettingsKey = info?.settings?.apiKey
790
+ } catch {
791
+ // The provider may not be configured in OpenCode; file discovery remains.
792
+ }
793
+
794
+ const discovered = discoverOpenDesignCredentials({
795
+ explicitPath: providerSettings.credentialPath,
796
+ providerSettingsKey,
797
+ credentialSource: providerSettings.credentialSource,
798
+ })
799
+ for (const warning of discovered.warnings) {
800
+ if (!this.settingsWarnings.includes(warning)) this.settingsWarnings.push(warning)
801
+ }
802
+
803
+ const controls = discoverOpenDesignControlKey({
804
+ explicitPath: providerSettings.controlKeyPath,
805
+ })
806
+ for (const warning of controls.warnings) {
807
+ if (!this.settingsWarnings.includes(warning)) this.settingsWarnings.push(warning)
808
+ }
809
+
810
+ return {
811
+ openDesignApiKey: discovered.candidates[0]?.key,
812
+ openDesignControlKey: controls.candidates[0]?.key,
813
+ }
814
+ }
815
+
816
+ private sessionState(sessionID: string): SessionState {
817
+ return this.ledger.ensureSession(sessionID)
818
+ }
819
+
820
+ private canonicalDir(value?: string): string {
821
+ if (!value) return ""
822
+ const cached = this.dirCache.get(value)
823
+ if (cached) return cached
824
+ let out = resolve(value)
825
+ if (out.startsWith("/private/")) out = out.slice("/private".length)
826
+ if (this.dirCache.size >= 500) {
827
+ const oldest = this.dirCache.keys().next().value
828
+ if (oldest) this.dirCache.delete(oldest)
829
+ }
830
+ this.dirCache.set(value, out)
831
+ return out
832
+ }
833
+
834
+ /**
835
+ * A location owns a session when the project ids match, or when the
836
+ * session's directory is the location directory or inside it. The second
837
+ * check matters when a location is opened at a subdirectory of a project:
838
+ * the session then carries the parent project id but still belongs here.
839
+ */
840
+ projectMatches(projectID?: string, directory?: string) {
841
+ const localID = this.ctx.location?.project?.id
842
+ if (projectID && localID && projectID === localID) return true
843
+ const remote = this.canonicalDir(directory)
844
+ if (this.localDir && remote) {
845
+ return remote === this.localDir || remote.startsWith(`${this.localDir}/`)
846
+ }
847
+ return !projectID || !localID
848
+ }
849
+
850
+ private ownsSession(session: SessionState) {
851
+ if (session.directory) return this.projectMatches(undefined, session.directory)
852
+ const localID = this.ctx.location?.project?.id
853
+ return !localID || !session.projectID || session.projectID === localID
854
+ }
855
+
856
+ private remember(set: Set<string>, id: string, max = 1_000) {
857
+ if (!set.has(id) && set.size >= max) {
858
+ const oldest = set.values().next().value
859
+ if (oldest) set.delete(oldest)
860
+ }
861
+ set.add(id)
862
+ }
863
+
864
+ private async loadSessionInfo(sessionID: string) {
865
+ try {
866
+ const info: any = await this.ctx.session.get({ sessionID })
867
+ if (!this.projectMatches(info?.projectID, info?.location?.directory)) return
868
+ sharedCore.claimSession(sessionID, this.bridgeID)
869
+ const model = modelRefFrom(info?.model)
870
+ const parentID = typeof info?.parentID === "string" ? info.parentID : undefined
871
+ const title = typeof info?.title === "string" ? info.title : undefined
872
+ const session = this.ledger.ensureSession(sessionID, parentID, title)
873
+ if (info?.projectID) this.ledger.patchSession(sessionID, { projectID: String(info.projectID) })
874
+ if (info?.location?.directory) {
875
+ this.ledger.patchSession(sessionID, { directory: String(info.location.directory) })
876
+ }
877
+ const baseTitle = stripTitlePrefix(typeof info?.title === "string" ? info.title : title)
878
+ if (baseTitle && !session.baseTitle) {
879
+ this.ledger.patchSession(sessionID, { baseTitle })
880
+ }
881
+ if (model) {
882
+ this.ledger.patchSession(sessionID, {
883
+ lastModel: model,
884
+ ...(session.manualOverride ? { manualModel: model } : {}),
885
+ })
886
+ }
887
+ // The context size decides which providers may serve this session, so
888
+ // it has to be the size of the transcript the next request carries.
889
+ //
890
+ // `info.tokens` is the session's CUMULATIVE usage across its whole
891
+ // life, not that size. It grows without bound, so it can only ever be
892
+ // an upper bound, and using it makes a session look far larger than it
893
+ // is - which pushes it off providers whose window it would actually
894
+ // fit in. The transcript read below gives the real number.
895
+ if (session.contextTokens === 0) {
896
+ const real = await this.readCurrentContext(sessionID)
897
+ if (real !== undefined) {
898
+ this.ledger.patchSession(sessionID, {
899
+ contextTokens: real.contextTokens,
900
+ cacheReadTokens: real.cacheReadTokens,
901
+ })
902
+ } else {
903
+ // The read failed. Assume a modest transcript rather than
904
+ // inventing a number that would wrongly exclude a model.
905
+ const created = Number(info?.time?.created) || 0
906
+ const updated = Number(info?.time?.updated) || 0
907
+ let estimate = 0
908
+ if (info?.fork) {
909
+ // A fork inherits a transcript but may carry no cumulative usage.
910
+ estimate = 350_000
911
+ } else if (created > 0 && updated - created > 60_000) {
912
+ estimate = 150_000
913
+ }
914
+ if (estimate > 0) {
915
+ this.ledger.patchSession(sessionID, { contextTokens: estimate, cacheReadTokens: 0 })
916
+ }
917
+ }
918
+ }
919
+ } catch {
920
+ // The prompt can still be routed from ledger state if the read fails.
921
+ }
922
+ }
923
+
924
+ private indicatorFor(sessionID: string, session: SessionState | undefined): Indicator {
925
+ const settings = this.settings
926
+ const state = session?.indicatorTitle?.startsWith("BLOCKED ·")
927
+ ? "BLOCKED"
928
+ : indicatorState({
929
+ enabled: settings.enabled,
930
+ runtimePaused: this.runtime.paused === true,
931
+ manual: session?.manualOverride === true,
932
+ dryRun: settings.dryRun,
933
+ })
934
+ const ref = session?.pinnedModel ?? session?.manualModel ?? session?.lastModel
935
+ const model = ref ? this.registry.get(ref.providerID, ref.id) : undefined
936
+ return {
937
+ state,
938
+ ...(ref ? { provider: providerShort(ref.providerID), model: ref.id } : {}),
939
+ ...(model ? { modelLabel: model.name } : ref ? { modelLabel: ref.id } : {}),
940
+ ...(session?.indicatorTitle ? { title: session.indicatorTitle } : {}),
941
+ }
942
+ }
943
+
944
+ private queueTitle(sessionID: string) {
945
+ const previous = this.titleQueue.get(sessionID) ?? Promise.resolve()
946
+ const next = previous
947
+ .catch(() => undefined)
948
+ .then(() => this.applyTitleNow(sessionID))
949
+ .catch(() => undefined)
950
+ .finally(() => {
951
+ if (this.titleQueue.get(sessionID) === next) this.titleQueue.delete(sessionID)
952
+ })
953
+ this.titleQueue.set(sessionID, next)
954
+ return next
955
+ }
956
+
957
+ private async applyTitleNow(sessionID: string) {
958
+ const session = this.ledger.getSession(sessionID)
959
+ if (!session) return
960
+ let info: any
961
+ try {
962
+ info = await this.ctx.session.get({ sessionID })
963
+ } catch {
964
+ info = undefined
965
+ }
966
+ if (!this.projectMatches(info?.projectID, info?.location?.directory)) return
967
+ const currentTitle = typeof info?.title === "string" ? info.title : undefined
968
+
969
+ if (!this.settings.ui.titleIndicator) {
970
+ /**
971
+ * The indicator is off. If a previous version of this plugin left its
972
+ * prefix on the title, put the name back once, the first time the session
973
+ * is touched. Without this, upgrading would leave every old chat reading
974
+ * "AUTO · OD · Model — name" forever, because the prefix lives in
975
+ * OpenCode's database and the router no longer rewrites it.
976
+ */
977
+ const stripped = stripTitlePrefix(currentTitle)
978
+ if (currentTitle && stripped && stripped !== currentTitle.trim()) {
979
+ try {
980
+ await this.ctx.session.update({ sessionID, title: stripped })
981
+ this.ledger.patchSession(sessionID, { title: stripped, indicatorTitle: undefined })
982
+ } catch {
983
+ // Leaving the prefix is better than failing the prompt over a title.
984
+ }
985
+ }
986
+ return
987
+ }
988
+
989
+ const modelRef = session.pinnedModel ?? session.manualModel ?? session.lastModel
990
+ if (!modelRef) return
991
+
992
+ // Adopt a name the router did not write. Never fall back to `session.title`:
993
+ // that value is the router's own previous output, so reading it back is how
994
+ // the prefix used to become the name permanently.
995
+ const baseTitle = resolveBaseTitle({
996
+ currentTitle,
997
+ lastWrittenTitle: session.indicatorTitle,
998
+ storedBase: session.baseTitle,
999
+ })
1000
+ // Nothing worth annotating. Leave the user's title exactly as it is.
1001
+ if (!baseTitle) return
1002
+
1003
+ const model = this.registry.get(modelRef.providerID, modelRef.id)
1004
+ const indicator = this.indicatorFor(sessionID, { ...session, baseTitle })
1005
+ const nextTitle = buildIndicatorTitle({
1006
+ state: indicator.state,
1007
+ provider: modelRef.providerID,
1008
+ modelLabel: model?.name ?? modelRef.id,
1009
+ baseTitle,
1010
+ compact: this.settings.ui.compactMode || this.settings.ui.compact || this.settings.compactMode || this.settings.compact,
1011
+ })
1012
+ if (currentTitle === nextTitle && session.indicatorTitle === nextTitle) return
1013
+ session.baseTitle = baseTitle
1014
+ session.indicatorTitle = nextTitle
1015
+ session.title = nextTitle
1016
+ this.ledger.patchSession(sessionID, {
1017
+ baseTitle,
1018
+ indicatorTitle: nextTitle,
1019
+ title: nextTitle,
1020
+ })
1021
+ try {
1022
+ await this.ctx.session.update({ sessionID, title: nextTitle })
1023
+ } catch (error) {
1024
+ this.settingsWarnings.push(`Could not update the session title indicator: ${safeError(error)}`)
1025
+ }
1026
+ }
1027
+
1028
+ private async refreshKnownTitles() {
1029
+ const ids = Object.values(this.ledger.data.sessions)
1030
+ .filter((session) => this.ownsSession(session))
1031
+ .map((session) => session.sessionID)
1032
+ await Promise.allSettled(ids.map((sessionID) => this.queueTitle(sessionID)))
1033
+ }
1034
+
1035
+ private async restoreTitles() {
1036
+ for (const session of Object.values(this.ledger.data.sessions)) {
1037
+ if (!this.ownsSession(session)) continue
1038
+ const base = session.baseTitle || stripTitlePrefix(session.title)
1039
+ if (!base) continue
1040
+ try {
1041
+ await this.ctx.session.update({ sessionID: session.sessionID, title: base })
1042
+ this.ledger.patchSession(session.sessionID, {
1043
+ title: base,
1044
+ indicatorTitle: undefined,
1045
+ })
1046
+ } catch (error) {
1047
+ this.settingsWarnings.push(`Could not restore session title: ${safeError(error)}`)
1048
+ }
1049
+ }
1050
+ }
1051
+
1052
+ private async notifyChange(key: string, message: string) {
1053
+ if (!this.settings.ui.notifications) return
1054
+ if (!this.notifier.shouldNotify(key)) return
1055
+ await this.notifier.notify(message)
1056
+ }
1057
+
1058
+ private modelNotice(sessionID: string, state: string, ref: ModelRef | undefined, reason?: string) {
1059
+ if (!ref) return
1060
+ const model = this.registry.get(ref.providerID, ref.id)
1061
+ const label = `${providerShort(ref.providerID)} · ${model?.name ?? ref.id}`
1062
+ const detail = reason ? ` — ${reason}` : ""
1063
+ void this.notifyChange(
1064
+ `${sessionID}:${state}:${ref.providerID}:${ref.id}:${reason ?? ""}`,
1065
+ `${state} · ${label}${detail}`,
1066
+ )
1067
+ }
1068
+
1069
+ /**
1070
+ * Measured generation speed per model, for the routing engine.
1071
+ *
1072
+ * Only models with enough samples are passed; the engine applies its own
1073
+ * minimum, so a single fast request cannot promote a model on noise.
1074
+ */
1075
+ private speedSnapshot(): Record<string, { tokensPerSecond: number; samples: number }> {
1076
+ const out: Record<string, { tokensPerSecond: number; samples: number }> = {}
1077
+ for (const [key, metrics] of Object.entries(this.ledger.snapshotMetrics().byModel)) {
1078
+ if (metrics.outputTokensPerSecond <= 0 || metrics.latencySamples <= 0) continue
1079
+ out[key] = {
1080
+ tokensPerSecond: metrics.outputTokensPerSecond,
1081
+ samples: metrics.latencySamples,
1082
+ }
1083
+ }
1084
+ return out
1085
+ }
1086
+
1087
+ private premiumState(session?: SessionState): PremiumState { const spent = this.ledger.premiumSpent()
1088
+ const budget = this.settings.premium.monthlyBudgetUsd
1089
+ return {
1090
+ mode: this.settings.premium.mode,
1091
+ monthlyBudgetUsd: budget,
1092
+ spentUsd: spent,
1093
+ remainingUsd: Math.max(0, budget - spent),
1094
+ oneShotGranted: session?.premiumOneShot === true,
1095
+ ...(session?.premiumOneShotAt ? { oneShotGrantedAt: session.premiumOneShotAt } : {}),
1096
+ }
1097
+ }
1098
+
1099
+ private quotaSnapshots() {
1100
+ return this.quota
1101
+ ? this.quota.read().map((snapshot) => {
1102
+ const providerSettings = this.settings.providers[snapshot.providerID]
1103
+ // A provider-scoped allowance is one shared pool. Per-model quota
1104
+ // snapshots would count the same pool once per model.
1105
+ if (!providerSettings || providerSettings.providerScopedQuota) return snapshot
1106
+ return this.ledger.attachModelQuotas(snapshot, this.registry.all(), providerSettings)
1107
+ })
1108
+ : []
1109
+ }
1110
+
1111
+ private async ensureContextTokens(sessionID: string) {
1112
+ if (this.contextLoaded.has(sessionID)) return
1113
+ const existing = this.contextInflight.get(sessionID)
1114
+ if (existing) return existing
1115
+ const task = (async () => {
1116
+ try {
1117
+ const real = await this.readCurrentContext(sessionID)
1118
+ if (real) {
1119
+ this.sessionState(sessionID)
1120
+ this.ledger.patchSession(sessionID, {
1121
+ contextTokens: real.contextTokens,
1122
+ cacheReadTokens: real.cacheReadTokens,
1123
+ })
1124
+ }
1125
+ } catch {
1126
+ // A context read failure must not block prompt admission.
1127
+ } finally {
1128
+ this.remember(this.contextLoaded, sessionID, 2_000)
1129
+ this.contextInflight.delete(sessionID)
1130
+ }
1131
+ })()
1132
+ this.contextInflight.set(sessionID, task)
1133
+ return task
1134
+ }
1135
+
1136
+ /**
1137
+ * Read a session's real context size from the host's transcript.
1138
+ *
1139
+ * The authoritative value is the last assistant step: its cache-read
1140
+ * count is the size of the transcript the next request will carry. A
1141
+ * compaction message's own counts describe the transcript it replaced, so
1142
+ * those are not used.
1143
+ *
1144
+ * Returns undefined when the transcript cannot be read.
1145
+ */
1146
+ private async readCurrentContext(
1147
+ sessionID: string,
1148
+ ): Promise<{ contextTokens: number; cacheReadTokens: number } | undefined> {
1149
+ try {
1150
+ const messages = unwrap<any>(
1151
+ await Promise.race([
1152
+ this.ctx.session.context({ sessionID }),
1153
+ new Promise((resolve) => setTimeout(() => resolve({ data: [] }), 5_000)),
1154
+ ]),
1155
+ )
1156
+ const assistant = [...messages]
1157
+ .reverse()
1158
+ .find((message: any) => message?.type === "assistant" && message?.tokens)
1159
+ if (!assistant?.tokens) return undefined
1160
+ const tokens = tokensFrom(assistant.tokens)
1161
+ const contextTokens = tokens.input + tokens.cache.read + tokens.cache.write
1162
+ if (contextTokens <= 0) return undefined
1163
+ return { contextTokens, cacheReadTokens: tokens.cache.read }
1164
+ } catch {
1165
+ return undefined
1166
+ }
1167
+ }
1168
+
1169
+ /**
1170
+ * Re-read a session's real context size right after a compaction.
1171
+ *
1172
+ * A compaction rebuilds the transcript, so the size the router was
1173
+ * carrying is stale. Learning the new size now - rather than at the next
1174
+ * prompt - means the cheap provider is eligible again on the very next
1175
+ * turn instead of being excluded for a size the session no longer has.
1176
+ */
1177
+ private async refreshContextAfterCompaction(sessionID: string) {
1178
+ const real = await this.readCurrentContext(sessionID)
1179
+ if (!real) return
1180
+ this.sessionState(sessionID)
1181
+ this.ledger.patchSession(sessionID, {
1182
+ contextTokens: real.contextTokens,
1183
+ cacheReadTokens: real.cacheReadTokens,
1184
+ })
1185
+ this.persistLedgerSoon()
1186
+ }
1187
+
1188
+ private classifierFiles(files: unknown): ClassifierFile[] {
1189
+ if (!Array.isArray(files)) return []
1190
+ return files
1191
+ .filter(isRecord)
1192
+ .map((file) => ({
1193
+ uri: typeof file.uri === "string" ? file.uri : "",
1194
+ ...(typeof file.name === "string" ? { name: file.name } : {}),
1195
+ ...(typeof file.description === "string" ? { description: file.description } : {}),
1196
+ }))
1197
+ .filter((file) => file.uri.length > 0)
1198
+ }
1199
+
1200
+ private statusFor(sessionID?: string): RouterStatus {
1201
+ if (this.settingsWarnings.length > 50) this.settingsWarnings = this.settingsWarnings.slice(-50)
1202
+ const session = sessionID ? this.ledger.getSession(sessionID) : undefined
1203
+ const current = session?.lastModel
1204
+ const runtime = this.runtimeFor(session)
1205
+ const currentModel = this.registry.get(current?.providerID ?? "", current?.id ?? "")
1206
+ const decisions = sessionID
1207
+ ? this.ledger.decisions(20).filter((decision) => decision.sessionID === sessionID)
1208
+ : this.ledger.decisions(20)
1209
+ const quota = this.quotaSnapshots()
1210
+ const warnings = [
1211
+ ...this.settingsWarnings,
1212
+ ...quota.filter((snapshot) => snapshot.error).map((snapshot) => `${snapshot.label}: ${snapshot.error}`),
1213
+ ]
1214
+ return {
1215
+ enabled: this.settings.enabled,
1216
+ mode: this.settings.mode,
1217
+ runtime,
1218
+ dryRun: this.settings.dryRun,
1219
+ ...(sessionID ? { sessionID } : {}),
1220
+ ...(current ? { current } : {}),
1221
+ ...(currentModel ? { currentTier: currentModel.tier } : {}),
1222
+ manualOverride: session?.manualOverride ?? false,
1223
+ ...(session?.pinnedModel ? { pinnedModel: session.pinnedModel } : {}),
1224
+ premium: this.premiumState(session),
1225
+ quota,
1226
+ context: computeContextHealth(session, this.settings.contextHealth),
1227
+ indicator: this.indicatorFor(sessionID ?? "", session),
1228
+ ...(decisions[0] ? { lastDecision: decisions[0] } : {}),
1229
+ lastDecisions: decisions,
1230
+ metrics: this.ledger.snapshotMetrics(),
1231
+ approvedModels: this.registry.approved().map((model) => ({
1232
+ key: model.key,
1233
+ tier: model.tier,
1234
+ name: model.name,
1235
+ intelligence: model.intelligence,
1236
+ design: model.design,
1237
+ vision: model.inputModalities.includes("image"),
1238
+ premium: model.premium,
1239
+ })),
1240
+ models: this.registry.all().map((model) => ({
1241
+ key: model.key,
1242
+ providerID: model.providerID,
1243
+ name: model.name,
1244
+ tier: model.tier,
1245
+ intelligence: model.intelligence,
1246
+ design: model.design,
1247
+ vision: model.inputModalities.includes("image"),
1248
+ premium: model.premium,
1249
+ context: model.context,
1250
+ costInput: model.cost[0]?.input ?? 0,
1251
+ costOutput: model.cost[0]?.output ?? 0,
1252
+ approved: model.approved,
1253
+ excluded: this.settings.models.excluded.includes(model.key),
1254
+ ...(model.notes ? { approvalNote: model.notes } : {}),
1255
+ })),
1256
+ warnings,
1257
+ }
1258
+ }
1259
+
1260
+ private async markBlocked(sessionID: string, reason: string) {
1261
+ const session = this.ledger.ensureSession(sessionID)
1262
+ const ref = session.lastModel
1263
+ const model = ref ? this.registry.get(ref.providerID, ref.id) : undefined
1264
+ const label = model?.name ?? ref?.id ?? "current model"
1265
+ const blockedTitle = buildIndicatorTitle({
1266
+ state: "BLOCKED",
1267
+ provider: ref?.providerID,
1268
+ modelLabel: label,
1269
+ baseTitle: session.baseTitle || session.title,
1270
+ })
1271
+ session.indicatorTitle = blockedTitle
1272
+ session.title = blockedTitle
1273
+ this.ledger.patchSession(sessionID, { indicatorTitle: blockedTitle, title: blockedTitle })
1274
+ try {
1275
+ await this.ctx.session.update({ sessionID, title: blockedTitle })
1276
+ } catch {
1277
+ // The decision still carries the reason.
1278
+ }
1279
+ void this.notifyChange(`${sessionID}:blocked:${reason}`, `BLOCKED · ${label} — ${reason}`)
1280
+ }
1281
+
1282
+ private recordFailedDecision(sessionID: string, text: string, files: unknown, reason: string) {
1283
+ const session = this.ledger.ensureSession(sessionID)
1284
+ const task = classifyTask({
1285
+ text,
1286
+ files: this.classifierFiles(files),
1287
+ contextTokens: session.contextTokens,
1288
+ recentFailures: session.failures.length,
1289
+ })
1290
+ const runtime = this.runtimeFor(session)
1291
+ const decision: RoutingDecision = {
1292
+ id: nowId("dec"),
1293
+ at: Date.now(),
1294
+ sessionID,
1295
+ mode: this.settings.mode,
1296
+ dryRun: this.settings.dryRun,
1297
+ runtime,
1298
+ task,
1299
+ ...(session.lastModel ? { current: session.lastModel } : {}),
1300
+ switched: false,
1301
+ switchReason: `Routing failed; the current model was kept: ${reason}`,
1302
+ reasons: [`Routing failed: ${reason}`],
1303
+ warnings: [`Routing failed: ${reason}`],
1304
+ candidates: [],
1305
+ premium: this.premiumState(session),
1306
+ quota: this.quotaSnapshots(),
1307
+ contextTokens: session.contextTokens,
1308
+ cachedTokens: session.cacheReadTokens,
1309
+ estimatedUsd: 0,
1310
+ needsPremiumApproval: false,
1311
+ manualOverride: session.manualOverride === true,
1312
+ error: reason,
1313
+ }
1314
+ this.ledger.recordDecision(decision)
1315
+ this.persistLedgerSoon()
1316
+ void this.emit("decision", { decision })
1317
+ return decision
1318
+ }
1319
+
1320
+ /**
1321
+ * A routing exception must never fall through as a silent automatic route.
1322
+ * Record the failure, and when failClosed is on, block admission.
1323
+ */
1324
+ private async handleRoutingFailure(sessionID: string, text: string, files: unknown, error: unknown) {
1325
+ const reason = safeError(error)
1326
+ this.settingsWarnings.push(`Prompt routing failed: ${reason}`)
1327
+ try {
1328
+ this.recordFailedDecision(sessionID, text, files, reason)
1329
+ } catch (recordError) {
1330
+ this.settingsWarnings.push(`Could not record the failed routing decision: ${safeError(recordError)}`)
1331
+ }
1332
+ if (!this.settings.routing.failClosed) return
1333
+ await this.markBlocked(sessionID, reason)
1334
+ throw new RouterBlockedError(`Smart Router blocked this prompt: ${reason}`)
1335
+ }
1336
+
1337
+ private async routePrompt(sessionID: string, text: string, files: unknown): Promise<RoutingDecision | undefined> {
1338
+ const settings = this.settings
1339
+ await this.loadSessionInfo(sessionID)
1340
+ const session = this.sessionState(sessionID)
1341
+ // A new prompt supersedes a previous blocked admission. If this prompt
1342
+ // blocks again, markBlocked sets the title again.
1343
+ if (session.indicatorTitle?.startsWith("BLOCKED ·")) {
1344
+ this.ledger.patchSession(sessionID, { indicatorTitle: undefined })
1345
+ }
1346
+ const runtime = this.runtimeFor(session)
1347
+ void this.ensureContextTokens(sessionID)
1348
+
1349
+ if (runtime !== "automatic") {
1350
+ const task = classifyTask({
1351
+ text,
1352
+ files: this.classifierFiles(files),
1353
+ contextTokens: session.contextTokens,
1354
+ recentFailures: session.failures.length,
1355
+ })
1356
+ const result = routeTask({
1357
+ task,
1358
+ settings,
1359
+ mode: settings.mode,
1360
+ runtime,
1361
+ models: this.routingModels(),
1362
+ intelligenceFloor: this.routingFloor(this.routingModels()).floor,
1363
+ current: session.lastModel,
1364
+ session,
1365
+ quota: this.quotaSnapshots(),
1366
+ premium: this.premiumState(session),
1367
+ contextTokens: session.contextTokens,
1368
+ cachedTokens: session.cacheReadTokens,
1369
+ dryRun: settings.dryRun,
1370
+ })
1371
+ this.ledger.recordDecision(result.decision)
1372
+ this.persistLedgerSoon()
1373
+ void this.emit("decision", { decision: result.decision })
1374
+ return result.decision
1375
+ }
1376
+
1377
+ const task = classifyTask({
1378
+ text,
1379
+ files: this.classifierFiles(files),
1380
+ contextTokens: session.contextTokens,
1381
+ recentFailures: session.failures.length,
1382
+ })
1383
+ const quota = this.quotaSnapshots()
1384
+ const effectiveMode = session.parentID && settings.mode === "premium" ? "balanced" : settings.mode
1385
+ const premium = session.parentID
1386
+ ? { ...this.premiumState(session), oneShotGranted: false }
1387
+ : this.premiumState(session)
1388
+ const result = routeTask({
1389
+ task,
1390
+ settings,
1391
+ mode: effectiveMode,
1392
+ runtime,
1393
+ models: this.routingModels(),
1394
+ intelligenceFloor: this.routingFloor(this.routingModels()).floor,
1395
+ current: session.lastModel,
1396
+ session,
1397
+ quota,
1398
+ premium,
1399
+ contextTokens: session.contextTokens,
1400
+ cachedTokens: session.cacheReadTokens,
1401
+ dryRun: settings.dryRun,
1402
+ modelHistory: this.ledger.snapshotMetrics().byModel,
1403
+ cooldowns: this.ledger.cooldowns(),
1404
+ providerCooldowns: this.ledger.providerCooldowns(),
1405
+ speed: this.speedSnapshot(),
1406
+ })
1407
+ if (session.lastModel) this.rememberModel(sessionID, session.lastModel.providerID, session.lastModel.id)
1408
+
1409
+ let switchError: string | undefined
1410
+ if (result.chosenModel && result.decision.switched) {
1411
+ const target = result.chosenModel
1412
+ const key = modelKey(target.providerID, target.modelID)
1413
+ if (this.expectedSwitch.size > 500) {
1414
+ const cutoff = Date.now() - 60_000
1415
+ for (const [sid, entry] of this.expectedSwitch) if (entry.at < cutoff) this.expectedSwitch.delete(sid)
1416
+ if (this.expectedSwitch.size > 500) {
1417
+ const oldest = this.expectedSwitch.keys().next().value
1418
+ if (oldest) this.expectedSwitch.delete(oldest)
1419
+ }
1420
+ }
1421
+ // NEVER SWITCH IN THE MIDDLE OF A RUNNING TURN.
1422
+ //
1423
+ // `switchModel` changes the model for the NEXT step. If a turn is already
1424
+ // in flight, the step that is queued behind the switch resolves against a
1425
+ // provider whose registration may not be settled yet, and dies with
1426
+ // "Model unavailable: <provider>/<model>" -- which reads like a dead
1427
+ // provider and is not one. Observed live on 2026-11-03: four of six such
1428
+ // failures landed within ten seconds of the plugin being reloaded, three
1429
+ // of them within one second.
1430
+ //
1431
+ // The prompt hook normally fires before a turn starts, so this is a guard
1432
+ // for the cases where it does not: a retry, a second queued prompt, or an
1433
+ // event arriving while the session is still busy. Deferring is always safe
1434
+ // because the next prompt routes again anyway.
1435
+ const busy = this.sessionBusy(sessionID)
1436
+ if (busy) {
1437
+ this.expectedSwitch.delete(sessionID)
1438
+ this.settingsWarnings.push(
1439
+ `Deferred a switch to ${target.providerID}/${target.modelID}: the session was mid-turn. Routing again on the next prompt.`,
1440
+ )
1441
+ return
1442
+ }
1443
+
1444
+ // NEVER SWITCH TO A MODEL OPENCODE DOES NOT HAVE.
1445
+ //
1446
+ // The router's candidate list is built from the GATEWAY and the seed catalog.
1447
+ // OpenCode's own model list is a third thing, and they can disagree: a model
1448
+ // the gateway serves, and the router has approved, may not be present in
1449
+ // OpenCode's provider config -- or, as on 2026-11-03, the provider's model
1450
+ // list can be empty mid-session because the plugin directory was replaced
1451
+ // underneath a running server.
1452
+ //
1453
+ // Switching to a model OpenCode cannot resolve fails the NEXT step with
1454
+ // "Model unavailable: <provider>/<model>", which looks exactly like a dead
1455
+ // gateway and is not one. Checking first costs one call and turns a broken
1456
+ // session into a skipped switch.
1457
+ const resolvable = await this.modelResolvable(target.providerID, target.modelID)
1458
+ if (!resolvable) {
1459
+ this.expectedSwitch.delete(sessionID)
1460
+ this.settingsWarnings.push(
1461
+ `Skipped a switch to ${target.providerID}/${target.modelID}: OpenCode does not currently expose that model.`,
1462
+ )
1463
+ return
1464
+ }
1465
+ this.expectedSwitch.set(sessionID, { key, at: Date.now() })
1466
+ try {
1467
+ await this.ctx.session.switchModel({
1468
+ sessionID,
1469
+ model: { providerID: target.providerID, id: target.modelID },
1470
+ })
1471
+ this.ledger.patchSession(sessionID, {
1472
+ lastModel: { providerID: target.providerID, id: target.modelID },
1473
+ lastRoutedModel: { providerID: target.providerID, id: target.modelID },
1474
+ })
1475
+ this.rememberModel(sessionID, target.providerID, target.modelID)
1476
+ if (result.decision.premium.oneShotGranted && target.premium) {
1477
+ this.ledger.consumePremiumOneShot(sessionID)
1478
+ }
1479
+ } catch (error) {
1480
+ switchError = safeError(error)
1481
+ this.expectedSwitch.delete(sessionID)
1482
+ this.settingsWarnings.push(`Model switch failed: ${switchError}`)
1483
+ }
1484
+ }
1485
+
1486
+ const finalDecision: RoutingDecision = switchError
1487
+ ? {
1488
+ ...result.decision,
1489
+ error: switchError,
1490
+ switched: false,
1491
+ switchReason: `Switch failed; kept the current model. ${switchError}`,
1492
+ warnings: [...result.decision.warnings, `Switch failed: ${switchError}`],
1493
+ }
1494
+ : result.decision
1495
+
1496
+ // Fail closed when the request would otherwise run on an unqualified model.
1497
+ if (runtime === "automatic" && settings.routing.failClosed && (!finalDecision.chosen || switchError)) {
1498
+ const currentRecord = session.lastModel
1499
+ ? this.registry.get(session.lastModel.providerID, session.lastModel.id)
1500
+ : undefined
1501
+ const premiumDecision = decidePremium({
1502
+ settings,
1503
+ mode: effectiveMode,
1504
+ task,
1505
+ session,
1506
+ premium,
1507
+ })
1508
+ const currentEligibility = currentRecord
1509
+ ? evaluateEligibility({
1510
+ model: currentRecord,
1511
+ task,
1512
+ settings,
1513
+ mode: effectiveMode,
1514
+ contextTokens: session.contextTokens,
1515
+ providerQuota: quota.find((entry) => entry.providerID === currentRecord.providerID),
1516
+ modelQuota: quota
1517
+ .find((entry) => entry.providerID === currentRecord.providerID)
1518
+ ?.models[`${currentRecord.providerID}/${currentRecord.modelID}`],
1519
+ premium: premiumDecision,
1520
+ currentModelKey: currentRecord.key,
1521
+ })
1522
+ : { eligible: false, reason: "No current model is recorded for this session", warnings: [] }
1523
+ if (!currentEligibility.eligible) {
1524
+ const reason = currentEligibility.reason
1525
+ await this.markBlocked(sessionID, reason)
1526
+ const blockedDecision: RoutingDecision = {
1527
+ ...finalDecision,
1528
+ error: reason,
1529
+ switched: false,
1530
+ switchReason: `Prompt blocked: ${reason}`,
1531
+ warnings: [...finalDecision.warnings, `Prompt blocked: ${reason}`],
1532
+ }
1533
+ this.ledger.recordDecision(blockedDecision)
1534
+ this.persistLedgerSoon()
1535
+ void this.emit("decision", { decision: blockedDecision })
1536
+ throw new RouterBlockedError(`Smart Router blocked this prompt: ${reason}`)
1537
+ }
1538
+ }
1539
+
1540
+ this.ledger.recordDecision(finalDecision)
1541
+ this.persistLedgerSoon()
1542
+ void this.emit("decision", { decision: finalDecision })
1543
+ if (finalDecision.needsPremiumApproval) {
1544
+ void this.emit("status", { summary: { needsPremiumApproval: true, sessionID } })
1545
+ void this.notifyChange(
1546
+ `${sessionID}:premium-needed`,
1547
+ "Premium approval required. Run /router-approve for this session.",
1548
+ )
1549
+ }
1550
+ void this.queueTitle(sessionID)
1551
+ const indicator = this.indicatorFor(sessionID, this.ledger.getSession(sessionID))
1552
+ if (finalDecision.switched && finalDecision.chosen) {
1553
+ const tier = finalDecision.task.tier === 1 ? "economy" : finalDecision.task.tier === 3 ? "premium" : "standard"
1554
+ this.modelNotice(sessionID, indicator.state, finalDecision.chosen, `${tier} task · quota-aware route`)
1555
+ }
1556
+ return finalDecision
1557
+ }
1558
+
1559
+ private async registerHooks() {
1560
+ this.registrations.push(
1561
+ await this.ctx.session.hook("prompt", async (event: any) => {
1562
+ const sessionID = event.sessionID
1563
+ if (!sessionID) return
1564
+ try {
1565
+ const probe: any = await this.ctx.session.get({ sessionID })
1566
+ if (!this.projectMatches(probe?.projectID, probe?.location?.directory)) return
1567
+ } catch {
1568
+ // If the location probe fails, the location-scoped hook can still run.
1569
+ }
1570
+ sharedCore.claimSession(sessionID, this.bridgeID)
1571
+ this.remember(this.promptSeen, sessionID)
1572
+ this.pendingHeals.delete(sessionID)
1573
+ if (this.inflight.has(sessionID)) return
1574
+ this.inflight.add(sessionID)
1575
+ try {
1576
+ const decision = await this.routePrompt(sessionID, event.prompt?.text ?? "", event.prompt?.files)
1577
+ if (decision) {
1578
+ // Available to the retry hook, which has no prompt linkage of its
1579
+ // own: a deterministic rejection re-routes this same task.
1580
+ this.promptRoutes.set(sessionID, {
1581
+ messageID: typeof event.messageID === "string" ? event.messageID : undefined,
1582
+ task: decision.task,
1583
+ decision,
1584
+ at: Date.now(),
1585
+ })
1586
+ }
1587
+ } catch (error) {
1588
+ if (error instanceof RouterBlockedError || (error as any)?.name === "RouterBlockedError") throw error
1589
+ await this.handleRoutingFailure(sessionID, event.prompt?.text ?? "", event.prompt?.files, error)
1590
+ } finally {
1591
+ this.inflight.delete(sessionID)
1592
+ }
1593
+ }),
1594
+ )
1595
+
1596
+ this.registrations.push(
1597
+ await this.ctx.session.hook("model.request", (event: any) => {
1598
+ if (sharedCore.ownerOf(event.sessionID) !== this.bridgeID) return
1599
+ // Latch the request kind so the context hook (whose event carries no
1600
+ // kind) can tell the agent loop apart from title and compaction runs.
1601
+ // One-shot consumption keeps a stale latch from leaking to a later
1602
+ // request of another kind.
1603
+ if (typeof event.kind === "string" && event.kind) {
1604
+ if (this.requestKinds.size > 500) {
1605
+ const cutoff = Date.now() - 60_000
1606
+ for (const [sid, entry] of this.requestKinds) if (entry.at < cutoff) this.requestKinds.delete(sid)
1607
+ if (this.requestKinds.size > 500) {
1608
+ const oldest = this.requestKinds.keys().next().value
1609
+ if (oldest) this.requestKinds.delete(oldest)
1610
+ }
1611
+ }
1612
+ this.requestKinds.set(event.sessionID, { kind: event.kind, at: Date.now() })
1613
+ }
1614
+ if (event.model?.providerID === "opencode-go") {
1615
+ event.headers["x-opencode-session"] = event.headers["x-opencode-session"] ?? event.sessionID
1616
+ }
1617
+ if (this.settings.privacy.requireZdr && event.model?.providerID === "commandcode") {
1618
+ event.headers["x-cmd-zdr"] = "1"
1619
+ }
1620
+ }),
1621
+ )
1622
+
1623
+ // The host emits `model.request` for every model call with the request's
1624
+ // kind and `context` with the built messages, in that order (the bundled
1625
+ // Claude integration relies on the same pairing), so a missing latch reads
1626
+ // as the agent loop: the common case, and the one that needs the repair.
1627
+ this.registrations.push(
1628
+ await this.ctx.session.hook("context", async (event: any) => {
1629
+ try {
1630
+ if (!(await this.ensureOwnership(event.sessionID))) return
1631
+ const latched = this.requestKinds.get(event.sessionID)
1632
+ this.requestKinds.delete(event.sessionID)
1633
+ if (latched && latched.kind !== "primary") return
1634
+ const session = this.ledger.getSession(event.sessionID)
1635
+ const model = event.model ?? session?.lastModel
1636
+ const record = model ? this.registry.get(model.providerID, model.id) : undefined
1637
+ const protocol = providerProtocol({
1638
+ ...(record ?? {}),
1639
+ providerID: model?.providerID ?? "",
1640
+ modelID: model?.id ?? "",
1641
+ })
1642
+ if (protocol !== "anthropic") return
1643
+ const result = repairMessages(event.messages, { protocol })
1644
+ if (result.folded > 0) event.messages = result.messages
1645
+ } catch (error) {
1646
+ // A repair failure must never escape into the model request.
1647
+ this.settingsWarnings.push(`Prompt repair failed: ${safeError(error)}`)
1648
+ }
1649
+ }),
1650
+ )
1651
+
1652
+ this.registrations.push(
1653
+ await this.ctx.session.hook("retry", async (event: any) => {
1654
+ if (sharedCore.ownerOf(event.sessionID) !== this.bridgeID) return
1655
+ const session = this.ledger.getSession(event.sessionID)
1656
+ const assessment = classifyFailure(
1657
+ { type: event.error?.type, message: event.error?.message ?? "", status: event.error?.status },
1658
+ session,
1659
+ )
1660
+ // The retry hook and the step/execution/idle events can all report
1661
+ // the same fault. Record it once, and carry the cooldown through so
1662
+ // the model is actually held back when it keeps failing.
1663
+ // The host identifies a scheduled retry by its assistant message id,
1664
+ // which is the same identity the step/execution events carry.
1665
+ const attemptKey =
1666
+ typeof event.assistantMessageID === "string"
1667
+ ? event.assistantMessageID
1668
+ : typeof event.attemptID === "string"
1669
+ ? event.attemptID
1670
+ : undefined
1671
+ if (!this.alreadyRecorded(event.sessionID, assessment.class, event.error?.message ?? "", attemptKey)) {
1672
+ this.ledger.recordFailure(event.sessionID, event.model, assessment.class, event.error?.message ?? "", {
1673
+ countAgainstModel: assessment.countAgainstModel,
1674
+ cooldownMs: assessment.cooldownMs,
1675
+ providerScoped: assessment.providerScoped,
1676
+ saturation: assessment.saturation === true,
1677
+ })
1678
+ }
1679
+ this.ledger.recordRetry(event.sessionID)
1680
+ this.persistLedgerSoon()
1681
+ if (assessment.class === "auth" || assessment.class === "context_overflow" || assessment.class === "image_rejected") {
1682
+ event.decision = { retry: false }
1683
+ return
1684
+ }
1685
+
1686
+ /**
1687
+ * The provider has no allowance left.
1688
+ *
1689
+ * This is the compensating control for everything the router cannot
1690
+ * read in advance. For OpenDesign it cannot read usage at all, so its
1691
+ * figure is a lower bound and a spent allowance is only discovered when
1692
+ * the gateway refuses. One refusal is enough: stop retrying this turn,
1693
+ * and hold the provider back until its window resets so the next prompt
1694
+ * routes somewhere that can actually serve it.
1695
+ *
1696
+ * Holding back for the reset, not for a few seconds, is the point. A
1697
+ * short backoff would re-probe an empty account on every prompt and burn
1698
+ * a turn each time to learn something already known.
1699
+ */
1700
+ if (assessment.class === "quota_exhausted") {
1701
+ const providerID = event.model?.providerID
1702
+ if (providerID) {
1703
+ this.ledger.exhaustProvider(providerID, this.providerResetAt(providerID))
1704
+ }
1705
+ this.settingsWarnings.push(
1706
+ `${providerID ?? "The provider"} reported no allowance left. Routing will avoid it until its window resets.`,
1707
+ )
1708
+ event.decision = { retry: false }
1709
+ this.persistLedgerSoon()
1710
+ return
1711
+ }
1712
+ if (assessment.class === "capability") {
1713
+ event.decision = { retry: false }
1714
+ return
1715
+ }
1716
+ if (assessment.class === "prompt_rejected") {
1717
+ // A deterministic rejection: the provider refused the request shape
1718
+ // before generating anything. Retrying reproduces the same 400, so
1719
+ // the turn stops immediately and the router repairs (next request)
1720
+ // or fails over to the next best choice.
1721
+ event.decision = { retry: false }
1722
+ try {
1723
+ await this.attemptSelfHeal(event.sessionID, event)
1724
+ } catch (error) {
1725
+ this.settingsWarnings.push(`Prompt-rejection failover failed: ${safeError(error)}`)
1726
+ }
1727
+ this.persistLedgerSoon()
1728
+ return
1729
+ }
1730
+ if (assessment.class === "rate_limit") {
1731
+ event.decision = { retry: true, delay: Math.max(5_000, event.error?.retryAfterMs ?? 15_000) }
1732
+ return
1733
+ }
1734
+ if (assessment.providerScoped) {
1735
+ // The provider's own pre-request path failed before a model ran, or
1736
+ // its gateway is refusing work outright, so the attempt cost nothing.
1737
+ // These failures come in runs: the gateway is slow or saturated for a
1738
+ // stretch and every immediate retry lands inside the same stretch and
1739
+ // dies with it, which is what turns a recoverable blip into a turn
1740
+ // that stops and waits for the owner to say continue. Back off long
1741
+ // enough for the stretch to pass.
1742
+ //
1743
+ // Backing off is the only thing available here: the retry hook can
1744
+ // retry with a delay or give up, but it cannot change model, so there
1745
+ // is no cheaper escape from a saturated gateway within the turn.
1746
+ if (event.attempt >= PROVIDER_SCOPED_RETRY_ATTEMPTS) {
1747
+ event.decision = { retry: false }
1748
+ } else {
1749
+ event.decision = { retry: true, delay: Math.min(40_000, 5_000 * 2 ** (event.attempt - 1)) }
1750
+ }
1751
+ } else if (assessment.class === "provider_outage" || assessment.class === "network") {
1752
+ if (event.attempt >= 3) event.decision = { retry: false }
1753
+ }
1754
+ }),
1755
+ )
1756
+ }
1757
+
1758
+
1759
+
1760
+ private async ensureOwnership(sessionID: string): Promise<boolean> {
1761
+ const owner = sharedCore.ownerOf(sessionID)
1762
+ if (owner) return owner === this.bridgeID
1763
+ try {
1764
+ const info: any = await this.ctx.session.get({ sessionID })
1765
+ if (!this.projectMatches(info?.projectID, info?.location?.directory)) return false
1766
+ sharedCore.claimSession(sessionID, this.bridgeID)
1767
+ return true
1768
+ } catch {
1769
+ return false
1770
+ }
1771
+ }
1772
+
1773
+ /**
1774
+ * Record a provider fault against the model that was actually serving the
1775
+ * session.
1776
+ *
1777
+ * The router's own session state is the primary source, but it can lag
1778
+ * behind a manual model change. The host's session record is the
1779
+ * authority, so it is consulted when the router has no model on record.
1780
+ */
1781
+ private async recordProviderFault(sessionID: string, outcome: string, error: any, attemptKey?: string) {
1782
+ const session = this.ledger.getSession(sessionID)
1783
+ let model = session?.lastModel
1784
+ if (!model) {
1785
+ try {
1786
+ const info: any = await this.ctx.session.get({ sessionID })
1787
+ const ref = modelRefFrom(info?.model)
1788
+ if (ref) {
1789
+ model = ref
1790
+ this.ledger.patchSession(sessionID, { lastModel: ref })
1791
+ this.rememberModel(sessionID, ref.providerID, ref.id)
1792
+ }
1793
+ } catch {
1794
+ // The session may already be gone; the fault is still recorded.
1795
+ }
1796
+ }
1797
+ const message =
1798
+ typeof error?.message === "string" && error.message.trim().length > 0
1799
+ ? error.message
1800
+ : outcome === "interrupted"
1801
+ ? "Step interrupted"
1802
+ : "The provider ended the response without completing it"
1803
+ const assessment = classifyFailure(
1804
+ {
1805
+ type: typeof error?.type === "string" ? error.type : outcome,
1806
+ message,
1807
+ ...(typeof error?.status === "number" ? { status: error.status } : {}),
1808
+ },
1809
+ session,
1810
+ )
1811
+ // The same fault can arrive through the retry hook and through the
1812
+ // step, execution and idle events. Record it once.
1813
+ if (this.alreadyRecorded(sessionID, assessment.class, message, attemptKey)) return
1814
+ this.ledger.recordFailure(sessionID, model, assessment.class, message, {
1815
+ countAgainstModel: assessment.countAgainstModel,
1816
+ cooldownMs: assessment.cooldownMs,
1817
+ providerScoped: assessment.providerScoped,
1818
+ saturation: assessment.saturation === true,
1819
+ })
1820
+ }
1821
+
1822
+ /** Hold-back window applied to a model that deterministically rejected a prompt. */
1823
+ private static readonly PROMPT_REJECTED_HOLD_MS = 5 * 60_000
1824
+
1825
+ /**
1826
+ * Recover a turn whose provider rejected the request shape.
1827
+ *
1828
+ * The model is healthy and the request cost nothing, so the router re-routes
1829
+ * the prompt's stored task with the rejecting model held back, moves the
1830
+ * session onto the next best choice, and continues the turn with one
1831
+ * router-marked synthetic notice. Everything that can be missing is missing
1832
+ * in a defined way: no banked routing, no other eligible model, or a host
1833
+ * without the needed API all degrade to leaving the failed turn as it is,
1834
+ * with the rejecting model held back so the next prompt routes elsewhere.
1835
+ * One failover per prompt, ever.
1836
+ *
1837
+ * A session that is still running its failed step must not be switched
1838
+ * mid-flight (a queued step would resolve against an unsettled model
1839
+ * registration), so the heal parks and is flushed from the turn's terminal
1840
+ * event, when the host considers the session idle.
1841
+ */
1842
+ private async attemptSelfHeal(sessionID: string, event: any): Promise<void> {
1843
+ const route = this.promptRoutes.get(sessionID)
1844
+ const rejected = modelRefFrom(event.model) ?? this.ledger.getSession(sessionID)?.lastModel
1845
+ if (!rejected) return
1846
+ const rejectedKey = modelKey(rejected.providerID, rejected.id)
1847
+ const hold = (model: ModelRef) => this.ledger.holdModel(model, RouterController.PROMPT_REJECTED_HOLD_MS)
1848
+
1849
+ // One failover per prompt: the cap is set before any host call, so a
1850
+ // failure partway through cannot let a later rejection heal again.
1851
+ if (!route || route.healedAt) {
1852
+ hold(rejected)
1853
+ this.noteSelfHeal(sessionID, route, rejected, "noted")
1854
+ return
1855
+ }
1856
+ // Automatic routing only: a manual model is the owner's pick, and a paused
1857
+ // session answers for itself on the next prompt.
1858
+ if (this.runtimeFor(this.sessionState(sessionID)) !== "automatic") {
1859
+ hold(rejected)
1860
+ this.noteSelfHeal(sessionID, route, rejected, "noted")
1861
+ return
1862
+ }
1863
+
1864
+ const session = this.sessionState(sessionID)
1865
+ const result = routeTask({
1866
+ task: route.task,
1867
+ settings: this.settings,
1868
+ mode: session.parentID && this.settings.mode === "premium" ? "balanced" : this.settings.mode,
1869
+ runtime: "automatic",
1870
+ models: this.routingModels(),
1871
+ intelligenceFloor: this.routingFloor(this.routingModels()).floor,
1872
+ current: rejected,
1873
+ session,
1874
+ quota: this.quotaSnapshots(),
1875
+ // A child session has no one-shot of its own: the owner approves spend,
1876
+ // not the subagent.
1877
+ premium: session.parentID ? { ...this.premiumState(session), oneShotGranted: false } : this.premiumState(session),
1878
+ contextTokens: session.contextTokens,
1879
+ cachedTokens: session.cacheReadTokens,
1880
+ dryRun: this.settings.dryRun,
1881
+ modelHistory: this.ledger.snapshotMetrics().byModel,
1882
+ cooldowns: { ...this.ledger.cooldowns(), [rejectedKey]: Date.now() + RouterController.PROMPT_REJECTED_HOLD_MS },
1883
+ providerCooldowns: this.ledger.providerCooldowns(),
1884
+ speed: this.speedSnapshot(),
1885
+ })
1886
+ const chosen = result.chosenModel
1887
+ // `decision.switched` is false under dry run: the plan explains what would
1888
+ // happen instead of doing it, and so does the heal.
1889
+ if (!chosen || !result.decision.switched || modelKey(chosen.providerID, chosen.modelID) === rejectedKey) {
1890
+ route.healedAt = Date.now()
1891
+ hold(rejected)
1892
+ this.noteSelfHeal(sessionID, route, rejected, "noted")
1893
+ return
1894
+ }
1895
+ if (
1896
+ typeof this.ctx.session.switchModel !== "function" ||
1897
+ typeof this.ctx.session.synthetic !== "function" ||
1898
+ !(await this.modelResolvable(chosen.providerID, chosen.modelID))
1899
+ ) {
1900
+ route.healedAt = Date.now()
1901
+ hold(rejected)
1902
+ this.noteSelfHeal(sessionID, route, rejected, "noted")
1903
+ return
1904
+ }
1905
+
1906
+ route.healedAt = Date.now()
1907
+ hold(rejected)
1908
+ this.pendingHeals.set(sessionID, {
1909
+ chosen,
1910
+ base: result.decision,
1911
+ rejectedLabel: `${rejected.providerID}/${rejected.id}`,
1912
+ })
1913
+ if (!this.sessionBusy(sessionID)) await this.flushPendingHeal(sessionID)
1914
+ }
1915
+
1916
+ /**
1917
+ * Apply a parked failover: the failed turn is over, so switching is safe.
1918
+ * Switches the session, mirrors the routing state (expected switch, last
1919
+ * model, one-shot consumption) exactly as the prompt path does, and
1920
+ * continues the turn with one router-marked notice.
1921
+ */
1922
+ private async flushPendingHeal(sessionID: string): Promise<void> {
1923
+ const heal = this.pendingHeals.get(sessionID)
1924
+ if (!heal) return
1925
+ this.pendingHeals.delete(sessionID)
1926
+ if (sharedCore.ownerOf(sessionID) !== this.bridgeID) return
1927
+ const chosen = heal.chosen
1928
+ this.expectedSwitch.set(sessionID, { key: modelKey(chosen.providerID, chosen.modelID), at: Date.now() })
1929
+ try {
1930
+ await this.ctx.session.switchModel({ sessionID, model: { providerID: chosen.providerID, id: chosen.modelID } })
1931
+ this.ledger.patchSession(sessionID, {
1932
+ lastModel: { providerID: chosen.providerID, id: chosen.modelID },
1933
+ lastRoutedModel: { providerID: chosen.providerID, id: chosen.modelID },
1934
+ })
1935
+ this.rememberModel(sessionID, chosen.providerID, chosen.modelID)
1936
+ if (heal.base.premium.oneShotGranted && chosen.premium) {
1937
+ this.ledger.consumePremiumOneShot(sessionID)
1938
+ }
1939
+ await this.ctx.session.synthetic({
1940
+ sessionID,
1941
+ text: `[router] The request was rejected on ${heal.rejectedLabel}, so the router moved it to ${chosen.providerID}/${chosen.modelID}.`,
1942
+ description: "Smart Router",
1943
+ // A fresh run, not a display notice: the failed turn was admitted under
1944
+ // the rejecting model and this is its continuation.
1945
+ resume: true,
1946
+ })
1947
+ const annotated: RoutingDecision = {
1948
+ ...heal.base,
1949
+ id: `${heal.base.id}+heal`,
1950
+ switched: true,
1951
+ switchReason: "selfheal",
1952
+ selfheal: {
1953
+ rejected: heal.rejectedLabel,
1954
+ action: `continued on ${chosen.providerID}/${chosen.modelID}`,
1955
+ },
1956
+ }
1957
+ this.ledger.recordDecision(annotated)
1958
+ void this.emit("decision", { decision: annotated })
1959
+ this.persistLedgerSoon()
1960
+ } catch (error) {
1961
+ this.expectedSwitch.delete(sessionID)
1962
+ this.settingsWarnings.push(`Self-heal switch failed: ${safeError(error)}`)
1963
+ this.persistLedgerSoon()
1964
+ }
1965
+ }
1966
+
1967
+ /**
1968
+ * Record the failover's outcome without re-counting the routed prompt's own
1969
+ * metrics: fresh id, applied-switch fields neutralized. The panel line comes
1970
+ * from the annotation.
1971
+ */
1972
+ private noteSelfHeal(
1973
+ sessionID: string,
1974
+ route: { decision: RoutingDecision } | undefined,
1975
+ rejected: ModelRef,
1976
+ action: string,
1977
+ ): void {
1978
+ if (!route) return
1979
+ this.ledger.recordDecision({
1980
+ ...route.decision,
1981
+ id: `${route.decision.id}+heal-note`,
1982
+ switched: false,
1983
+ dryRun: false,
1984
+ needsPremiumApproval: false,
1985
+ chosen: route.decision.current,
1986
+ selfheal: { rejected: `${rejected.providerID}/${rejected.id}`, action },
1987
+ })
1988
+ this.persistLedgerSoon()
1989
+ }
1990
+
1991
+ /** Record that a session has been on a model, so a later change back to
1992
+ * it is read as the client catching up rather than a fresh pick. */
1993
+ private rememberModel(sessionID: string, providerID: string, id: string) {
1994
+ let seen = this.modelsSeen.get(sessionID)
1995
+ if (!seen) {
1996
+ seen = new Set()
1997
+ this.modelsSeen.set(sessionID, seen)
1998
+ }
1999
+ seen.add(modelKey(providerID, id))
2000
+ }
2001
+
2002
+ /**
2003
+ * The context window of the cheapest variant of a model, across providers.
2004
+ *
2005
+ * The same model id is often served by more than one provider at different
2006
+ * prices. The variant the router wants to keep a session on is the cheapest
2007
+ * one, so that variant's window is the size the session needs to fit.
2008
+ * Ties prefer the larger window.
2009
+ */
2010
+ /**
2011
+ * Whether OpenCode exposes a model RIGHT NOW.
2012
+ *
2013
+ * The router knows a model because the gateway serves it and the owner approved it.
2014
+ * OpenCode knows it only if it is in the provider's configured model list. When the
2015
+ * two disagree, switching produces a session that fails on its NEXT step with
2016
+ * "Model unavailable: provider/model", which reads like a dead provider and is not
2017
+ * one. Observed 2026-11-03: the plugin directory was replaced underneath a running
2018
+ * server, the provider's model list came back empty, and four live sessions died
2019
+ * that way.
2020
+ *
2021
+ * So ask the host instead of assuming. If the host cannot answer at all, allow the
2022
+ * switch: the previous behaviour was to try it and surface the real error, and a
2023
+ * false negative here would silently stop all routing.
2024
+ */
2025
+ /**
2026
+ * Whether a turn is currently running for this session.
2027
+ *
2028
+ * Two independent signals, because neither is complete on its own:
2029
+ *
2030
+ * - `stepStarted` is set in-process and cleared on the step's terminal event.
2031
+ * It is the precise one, but a plugin reload empties it, so a session that
2032
+ * was already mid-turn looks idle.
2033
+ * - `status` is persisted in the ledger and survives a reload, so it covers
2034
+ * exactly that gap. It is also written by auto-compaction.
2035
+ *
2036
+ * Either one saying "busy" is enough. A false positive only defers a switch
2037
+ * to the next prompt, which costs nothing; a false negative breaks the turn.
2038
+ */
2039
+ private sessionBusy(sessionID: string): boolean {
2040
+ if (this.stepStarted.has(sessionID)) return true
2041
+ return this.ledger.getSession(sessionID)?.status === "busy"
2042
+ }
2043
+
2044
+ private async modelResolvable(providerID: string, modelID: string): Promise<boolean> {
2045
+ // The host is the authority, but it is not always reachable. A MISSING API is not
2046
+ // a "no": it means this host cannot answer the question, so fall back to the
2047
+ // previous behaviour of attempting the switch and surfacing whatever happens.
2048
+ // Only an API that answers and returns nothing is treated as a real refusal.
2049
+ const get = this.ctx?.model?.get
2050
+ if (typeof get !== "function") return true
2051
+ try {
2052
+ const model = await get.call(this.ctx.model, providerID, modelID)
2053
+ return Boolean(model)
2054
+ } catch {
2055
+ return true
2056
+ }
2057
+ }
2058
+
2059
+ /**
2060
+ * The candidate list a routing decision may choose from.
2061
+ *
2062
+ * Offline mode replaces the approved-online list with the local models. The
2063
+ * engine applies its own allow-list on top, so this is a convenience rather
2064
+ * than the security boundary; the boundary is in `routeTask` and stays there
2065
+ * even if a future caller passes the wrong list.
2066
+ */
2067
+ private routingModels() {
2068
+ if (this.settings.offline?.enabled) return this.registry.modelsForOffline()
2069
+ return this.registry.approved()
2070
+ }
2071
+
2072
+ /**
2073
+ * The intelligence floor to route with.
2074
+ *
2075
+ * With calibration enabled this replaces the hand-picked number with one
2076
+ * derived from what the owner actually spent over the recorded window. It is
2077
+ * memoized briefly because it is otherwise recomputed for every decision,
2078
+ * while the underlying ledger changes per request, not per candidate.
2079
+ *
2080
+ * Offline mode bypasses it: that path has its own allow-list and its own
2081
+ * relationship with the floor, and online cost calibration must not reach
2082
+ * into it.
2083
+ */
2084
+ /**
2085
+ * Everything needed to judge whether the floor in force is trustworthy.
2086
+ *
2087
+ * Reads the ledger directly rather than the memoized routing value, so the
2088
+ * report answers for the current moment even when nothing has routed yet.
2089
+ */
2090
+ private floorReport() {
2091
+ const models = this.registry.approved()
2092
+ const configured = this.settings.routing.intelligenceFloor
2093
+ const enabled = this.settings.routing.calibrateFloor === true
2094
+ const offline = this.settings.offline?.enabled === true
2095
+ const now = Date.now()
2096
+ const usage = this.ledger.usageByModel({ since: now - CALIBRATION_WINDOW_MS })
2097
+ const calibration = resolveIntelligenceFloor(this.settings, { models, usage, now })
2098
+ const inForce = enabled && !offline ? calibration.floor : configured
2099
+
2100
+ const perModel = usage
2101
+ .filter((row) => row.requests > 0)
2102
+ .map((row) => {
2103
+ const record = this.registry.get(row.providerID, row.modelID)
2104
+ return {
2105
+ model: `${row.providerID}/${row.modelID}`,
2106
+ requests: row.requests,
2107
+ usd: Number(row.usd.toFixed(6)),
2108
+ usdPerRequest: Number((row.usd / row.requests).toFixed(6)),
2109
+ intelligence: record?.intelligence ?? null,
2110
+ }
2111
+ })
2112
+ .sort((a, b) => a.usdPerRequest - b.usdPerRequest)
2113
+
2114
+ return {
2115
+ calibrateFloor: enabled,
2116
+ offlineBypass: offline,
2117
+ configuredFloor: configured,
2118
+ floorInForce: inForce,
2119
+ calibrated: calibration.calibrated,
2120
+ source: calibration.calibrated ? "measured" : enabled ? "configured (not calibrated)" : "configured",
2121
+ reason: calibration.reason,
2122
+ baselineUsdPerRequest: calibration.baselineUsdPerRequest,
2123
+ admittedUsdPerRequest: calibration.admittedUsdPerRequest,
2124
+ windowDays: Math.round(CALIBRATION_WINDOW_MS / 86_400_000),
2125
+ minRequests: MIN_REQUESTS_FOR_COST_EVIDENCE,
2126
+ // What the floor actually admits, so the number has a meaning attached.
2127
+ admitted: models
2128
+ .filter((model) => !model.escalationOnly && !model.premium)
2129
+ .filter((model) => model.intelligence >= inForce)
2130
+ .map((model) => ({
2131
+ model: `${model.providerID}/${model.modelID}`,
2132
+ intelligence: model.intelligence,
2133
+ listInputUsdPerMtok: model.cost?.[0]?.input ?? null,
2134
+ }))
2135
+ .sort((a, b) => (a.listInputUsdPerMtok ?? 0) - (b.listInputUsdPerMtok ?? 0)),
2136
+ measuredTraffic: perModel,
2137
+ totalRequests: perModel.reduce((sum, row) => sum + row.requests, 0),
2138
+ }
2139
+ }
2140
+
2141
+ private routingFloor(models: ReadonlyArray<ModelRecord>): { floor: number; calibration?: CalibrationResult } {
2142
+ if (this.settings.routing.calibrateFloor !== true || this.settings.offline?.enabled === true) {
2143
+ return { floor: this.settings.routing.intelligenceFloor }
2144
+ }
2145
+ const now = Date.now()
2146
+ if (this.floorCache && now - this.floorCache.at < FLOOR_CACHE_MS) return this.floorCache.value
2147
+ const calibration = resolveIntelligenceFloor(this.settings, {
2148
+ models,
2149
+ usage: this.ledger.usageByModel({ since: now - CALIBRATION_WINDOW_MS }),
2150
+ now,
2151
+ })
2152
+ const value = { floor: calibration.floor, calibration }
2153
+ this.floorCache = { at: now, value }
2154
+ return value
2155
+ }
2156
+
2157
+ private cheapestVariantContext(modelID: string): number {
2158
+ let best: { multiplier: number; context: number } | undefined
2159
+ for (const model of this.registry.approved()) {
2160
+ if (model.modelID !== modelID) continue
2161
+ const multiplier = model.creditMultiplier && model.creditMultiplier > 0 ? model.creditMultiplier : 1
2162
+ if (
2163
+ !best ||
2164
+ multiplier < best.multiplier ||
2165
+ (multiplier === best.multiplier && model.context > best.context)
2166
+ ) {
2167
+ best = { multiplier, context: model.context }
2168
+ }
2169
+ }
2170
+ return best?.context ?? 0
2171
+ }
2172
+
2173
+ /**
2174
+ * Router-driven conversation compaction.
2175
+ *
2176
+ * Compaction is destructive: it replaces the transcript with a summary and
2177
+ * the detail is gone. It was introduced when OpenDesign Go's $10 allowance
2178
+ * made a long session on the cheapest provider unaffordable, and it is now
2179
+ * governed by `contextManagement.mode`:
2180
+ *
2181
+ * - `native` (default) never compacts except to avoid a verified overflow.
2182
+ * - `conservative` compacts only to prevent a real overflow.
2183
+ * - `budget-optimized` preserves the old early-compaction behavior for
2184
+ * small plans and small-window models.
2185
+ *
2186
+ * In every mode the effective window is the one the provider actually
2187
+ * accepts, never a theoretical maximum.
2188
+ */
2189
+ private maybeCompactForCost(sessionID: string) {
2190
+ const settings = this.settings
2191
+ if (!settings.enabled || this.runtime.paused === true) return
2192
+ if (this.ledger.getSession(sessionID)?.paused === true) return
2193
+ if (!settings.contextManagement.enabled) return
2194
+ const session = this.ledger.getSession(sessionID)
2195
+ const model = session?.lastModel
2196
+ if (!model) return
2197
+ if (session?.status !== "idle") return
2198
+ const record = this.registry.get(model.providerID, model.id)
2199
+ if (!record || record.context <= 0) return
2200
+ const contextTokens = session?.contextTokens ?? 0
2201
+ if (contextTokens <= 0) return
2202
+ const now = Date.now()
2203
+ const cooldownMs = settings.contextManagement.cooldownMinutes * 60_000
2204
+ const last = this.routerCompactAt.get(sessionID)
2205
+ if (typeof last === "number" && now - last < cooldownMs) return
2206
+
2207
+ // The window that matters is the one the *current* model accepts. The
2208
+ // cheapest variant's window is only relevant to the budget-optimized
2209
+ // strategy, which trades context for staying on the cheapest route.
2210
+ const effectiveWindow = record.context
2211
+ const cheapestWindow =
2212
+ settings.contextManagement.mode === "budget-optimized"
2213
+ ? this.cheapestVariantContext(record.modelID) || record.context
2214
+ : effectiveWindow
2215
+
2216
+ const decision = decideCompaction({
2217
+ mode: settings.contextManagement.mode,
2218
+ settings: settings.contextManagement,
2219
+ contextTokens,
2220
+ effectiveWindow,
2221
+ cheapestWindow,
2222
+ })
2223
+ this.lastCompactionDecision.set(sessionID, decision)
2224
+ if (!decision.compact) return
2225
+
2226
+ if (typeof this.ctx.session.compact !== "function") return
2227
+ this.routerCompactAt.set(sessionID, now)
2228
+ this.ledger.patchSession(sessionID, { status: "busy" })
2229
+ this.persistLedgerSoon()
2230
+ void Promise.resolve(
2231
+ this.ctx.session.compact({ sessionID }),
2232
+ )
2233
+ .then(() => {
2234
+ this.ledger.patchSession(sessionID, { status: "idle" })
2235
+ void this.emit("status", {
2236
+ sessionID,
2237
+ summary: {
2238
+ note: `${decision.hardLimit ? "Protected context limit" : "Compacted"}: ${decision.reason} (${(contextTokens / 1000).toFixed(0)}k to fit ${((decision.targetTokens ?? effectiveWindow) / 1000).toFixed(0)}k)`,
2239
+ contextManagement: settings.contextManagement.mode,
2240
+ },
2241
+ })
2242
+ })
2243
+ .catch((error: unknown) => {
2244
+ this.ledger.patchSession(sessionID, { status: "idle" })
2245
+ this.settingsWarnings.push(`Auto-compact failed: ${safeError(error)}`)
2246
+ })
2247
+ }
2248
+
2249
+ /**
2250
+ * True when this exact failure was already recorded for the session.
2251
+ *
2252
+ * `attemptKey` identifies the failed attempt. When the host provides one
2253
+ * (the assistant message id), it is authoritative: the same attempt is
2254
+ * reported once, however many channels describe it. When it does not,
2255
+ * the error text plus a short window stands in, which still collapses the
2256
+ * channels of one attempt without merging a later, separate fault.
2257
+ */
2258
+ private alreadyRecorded(sessionID: string, failureClass: string, message: string, attemptKey?: string): boolean {
2259
+ const now = Date.now()
2260
+ const key = attemptKey
2261
+ ? `${sessionID} ${failureClass} attempt:${attemptKey}`
2262
+ : `${sessionID} ${failureClass} text:${message.slice(0, 200)}`
2263
+ const previous = this.recordedFaults.get(key)
2264
+ if (attemptKey) {
2265
+ // An attempt identity is exact, so it never expires into a new fault.
2266
+ if (previous !== undefined) return true
2267
+ } else if (typeof previous === "number" && now - previous < FAULT_DEDUPE_WINDOW_MS) {
2268
+ return true
2269
+ }
2270
+ this.recordedFaults.set(key, now)
2271
+ // Keep the map bounded; entries older than the window are dead weight.
2272
+ if (this.recordedFaults.size > 500) {
2273
+ for (const [candidate, at] of this.recordedFaults) {
2274
+ if (now - at >= FAULT_DEDUPE_WINDOW_MS) this.recordedFaults.delete(candidate)
2275
+ }
2276
+ }
2277
+ return false
2278
+ }
2279
+
2280
+ async handleEvent(event: any) {
2281
+ const type = event?.type
2282
+ // Env-gated event trace. Used to learn which bus events carry a provider
2283
+ // fault; inert unless SMART_ROUTER_EVENT_LOG is set.
2284
+ const eventLog = process.env.SMART_ROUTER_EVENT_LOG
2285
+ if (eventLog) {
2286
+ try {
2287
+ appendFileSync(eventLog, `${JSON.stringify({ at: Date.now(), type, sessionID: event?.data?.sessionID, keys: Object.keys(event?.data ?? {}) })}\n`)
2288
+ } catch {
2289
+ // Diagnostics only.
2290
+ }
2291
+ }
2292
+ const data = event?.data ?? {}
2293
+ const eventSessionID = typeof data.sessionID === "string" ? data.sessionID : undefined
2294
+ if (eventSessionID && type !== "session.created") {
2295
+ if (!(await this.ensureOwnership(eventSessionID))) {
2296
+ sharedCore.noteEvent(false)
2297
+ return
2298
+ }
2299
+ }
2300
+ sharedCore.noteEvent(true)
2301
+ if (type === "session.created") {
2302
+ const sessionID = data.sessionID
2303
+ if (typeof sessionID !== "string") return
2304
+ if (
2305
+ !this.projectMatches(
2306
+ typeof data.projectID === "string" ? data.projectID : undefined,
2307
+ typeof data.location?.directory === "string" ? data.location.directory : undefined,
2308
+ )
2309
+ ) {
2310
+ sharedCore.noteEvent(false)
2311
+ return
2312
+ }
2313
+ if (!this.created.has(sessionID) && this.created.size >= 500) {
2314
+ const oldest = this.created.keys().next().value
2315
+ if (oldest) this.created.delete(oldest)
2316
+ }
2317
+ this.created.set(sessionID, Date.now())
2318
+ const session = this.ledger.ensureSession(sessionID, data.parentID, data.title)
2319
+ if (typeof data.projectID === "string") this.ledger.patchSession(sessionID, { projectID: data.projectID })
2320
+ if (typeof data.location?.directory === "string") {
2321
+ this.ledger.patchSession(sessionID, { directory: data.location.directory })
2322
+ }
2323
+ const base = stripTitlePrefix(typeof data.title === "string" ? data.title : undefined)
2324
+ if (base && !session.baseTitle) this.ledger.patchSession(sessionID, { baseTitle: base })
2325
+ this.persistLedgerSoon()
2326
+ return
2327
+ }
2328
+ if (type === "session.renamed") {
2329
+ const sessionID = data.sessionID
2330
+ if (typeof sessionID !== "string" || typeof data.title !== "string") return
2331
+ const session = this.ledger.getSession(sessionID)
2332
+ if (!session) return
2333
+ if (data.title === session.indicatorTitle) return
2334
+ const base = stripTitlePrefix(data.title)
2335
+ if (base) this.ledger.patchSession(sessionID, { baseTitle: base })
2336
+ void this.queueTitle(sessionID)
2337
+ return
2338
+ }
2339
+ if (type === "session.deleted") {
2340
+ if (eventSessionID) {
2341
+ sharedCore.releaseSession(eventSessionID)
2342
+ this.stepStarted.delete(eventSessionID)
2343
+ this.contextLoaded.delete(eventSessionID)
2344
+ this.expectedSwitch.delete(eventSessionID)
2345
+ this.promptSeen.delete(eventSessionID)
2346
+ this.created.delete(eventSessionID)
2347
+ }
2348
+ return
2349
+ }
2350
+ if (type === "session.model.selected") {
2351
+ const sessionID = data.sessionID
2352
+ if (typeof sessionID !== "string") return
2353
+ const model = modelRefFrom(data.model)
2354
+ if (!model) return
2355
+ const expected = this.expectedSwitch.get(sessionID)
2356
+ const key = modelKey(model.providerID, model.id)
2357
+ const isOurs = expected?.key === key && Date.now() - expected.at < 15_000
2358
+ if (isOurs) {
2359
+ this.expectedSwitch.delete(sessionID)
2360
+ return
2361
+ }
2362
+ const created = this.created.get(sessionID) ?? 0
2363
+ const initialSelection = !this.promptSeen.has(sessionID) && Date.now() - created < 5_000 && !data.previous
2364
+ if (initialSelection) return
2365
+ // A change back to a model this session has already been on is the
2366
+ // client catching up with its own saved selection, not the owner
2367
+ // picking a model. Only a model new to the session suspends routing.
2368
+ const changedModelKey = modelKey(model.providerID, model.id)
2369
+ const seen = this.modelsSeen.get(sessionID)
2370
+ const isCatchUp = seen?.has(changedModelKey) === true
2371
+ this.rememberModel(sessionID, model.providerID, model.id)
2372
+ if (isCatchUp) {
2373
+ this.ledger.patchSession(sessionID, { lastModel: model })
2374
+ this.persistLedgerSoon()
2375
+ return
2376
+ }
2377
+ this.ledger.setManual(sessionID, model)
2378
+ this.persistLedgerSoon()
2379
+ void this.emit("status", { sessionID, summary: { manualOverride: true, model } })
2380
+ void this.queueTitle(sessionID)
2381
+ this.modelNotice(sessionID, "MANUAL", model, "automatic routing suspended · /router-auto to resume")
2382
+ return
2383
+ }
2384
+ if (type === "session.execution.started") {
2385
+ const sessionID = data.sessionID
2386
+ if (typeof sessionID === "string") {
2387
+ if (!this.stepStarted.has(sessionID) && this.stepStarted.size >= 500) {
2388
+ const oldest = this.stepStarted.keys().next().value
2389
+ if (oldest) this.stepStarted.delete(oldest)
2390
+ }
2391
+ this.stepStarted.set(sessionID, Date.now())
2392
+ this.ledger.patchSession(sessionID, { status: "busy" })
2393
+ this.persistLedgerSoon()
2394
+ }
2395
+ return
2396
+ }
2397
+ if (type === "session.execution.succeeded" || type === "session.execution.failed" || type === "session.execution.interrupted" || type === "session.idle") {
2398
+ const sessionID = data.sessionID
2399
+ if (typeof sessionID === "string") {
2400
+ this.ledger.patchSession(sessionID, { status: "idle" })
2401
+ this.stepStarted.delete(sessionID)
2402
+ // A failover parked because the turn was busy switches here: the turn
2403
+ // is over, so the queued-step hazard is gone.
2404
+ if (type !== "session.execution.succeeded") {
2405
+ try {
2406
+ if (!this.sessionBusy(sessionID)) await this.flushPendingHeal(sessionID)
2407
+ } catch (healError) {
2408
+ this.settingsWarnings.push(`Prompt-rejection failover failed: ${safeError(healError)}`)
2409
+ }
2410
+ }
2411
+ // `session.execution.failed` and `.interrupted` carry no outcome
2412
+ // field; the event type itself is the signal. `session.idle` does
2413
+ // carry an outcome. Recording these is what lets the router see a
2414
+ // model that keeps dropping streams.
2415
+ const outcome = typeof data.outcome === "string" ? data.outcome : undefined
2416
+ const attemptKey = typeof data.assistantMessageID === "string" ? data.assistantMessageID : undefined
2417
+ if (type === "session.execution.failed" || outcome === "failed") {
2418
+ await this.recordProviderFault(sessionID, "failed", data.error, attemptKey)
2419
+ } else if (type === "session.execution.interrupted" || outcome === "interrupted") {
2420
+ await this.recordProviderFault(sessionID, "interrupted", data.error, attemptKey)
2421
+ }
2422
+ // Idle is the only safe moment to compact: a running turn cannot be
2423
+ // interrupted. Compacting here keeps the session inside the cheapest
2424
+ // provider's window, so the next prompt does not get pushed onto a
2425
+ // costlier provider purely because the conversation grew.
2426
+ //
2427
+ // Note the host's `session.idle` bus event carries no outcome field
2428
+ // (only the persisted message does), so success cannot be read here.
2429
+ // `session.execution.succeeded` is the signal that a turn finished
2430
+ // cleanly; the idle event covers the rest. The compaction cooldown is
2431
+ // what stops the two from both firing.
2432
+ if (type === "session.execution.succeeded" || type === "session.idle") {
2433
+ this.maybeCompactForCost(sessionID)
2434
+ }
2435
+ this.persistLedgerSoon()
2436
+ }
2437
+ return
2438
+ }
2439
+ if (type === "session.compaction.ended" || type === "session.compaction.failed") {
2440
+ const sessionID = data.sessionID
2441
+ if (typeof sessionID !== "string") return
2442
+ // A compaction rebuilds the transcript, so the context size the router
2443
+ // has been carrying is stale: it still reflects the pre-compaction
2444
+ // session. That stale number is what the context gate uses to decide
2445
+ // whether a model can serve the session, so a session that was just
2446
+ // compacted can be wrongly excluded from the cheapest provider - it
2447
+ // looks too large for OpenDesign's window when it is not.
2448
+ //
2449
+ // Drop the cached value so the next prompt re-reads the real size.
2450
+ this.contextLoaded.delete(sessionID)
2451
+ const session = this.ledger.getSession(sessionID)
2452
+ if (session) {
2453
+ this.ledger.patchSession(sessionID, { contextTokens: 0, cacheReadTokens: 0 })
2454
+ }
2455
+ // Re-read the real size now rather than at the next prompt. The
2456
+ // compaction message's own token counts describe the transcript it
2457
+ // replaced, not the rebuilt one, so the authoritative value is the
2458
+ // last assistant step after the compaction.
2459
+ void this.refreshContextAfterCompaction(sessionID)
2460
+ this.persistLedgerSoon()
2461
+ return
2462
+ }
2463
+ if (type === "session.step.ended") {
2464
+ const sessionID = data.sessionID
2465
+ if (typeof sessionID !== "string") return
2466
+ const session = this.ledger.getSession(sessionID)
2467
+ const model = session?.lastModel
2468
+ const tokens = tokensFrom(data.tokens)
2469
+ const latency = this.stepStarted.get(sessionID) ? Date.now() - (this.stepStarted.get(sessionID) ?? Date.now()) : undefined
2470
+ if (model) {
2471
+ this.ledger.recordUsage({
2472
+ sessionID,
2473
+ model,
2474
+ cost: typeof data.cost === "number" ? data.cost : 0,
2475
+ tokens,
2476
+ at: typeof event.created === "number" ? event.created : Date.now(),
2477
+ latencyMs: latency,
2478
+ premium: this.registry.get(model.providerID, model.id)?.premium ?? false,
2479
+ })
2480
+ // A completed step is proof the provider's pre-request path worked,
2481
+ // so any run of provider-scoped failures starts over.
2482
+ this.ledger.recordProviderSuccess(model.providerID)
2483
+ }
2484
+ this.persistLedgerSoon()
2485
+ return
2486
+ }
2487
+ if (type === "session.step.failed") {
2488
+ const sessionID = data.sessionID
2489
+ if (typeof sessionID !== "string") return
2490
+ // The assistant message id identifies the attempt, so the same
2491
+ // attempt reported through several channels is counted once.
2492
+ const attemptKey = typeof data.assistantMessageID === "string" ? data.assistantMessageID : undefined
2493
+ await this.recordProviderFault(sessionID, "failed", data.error, attemptKey)
2494
+ this.persistLedgerSoon()
2495
+ return
2496
+ }
2497
+ if (type === "session.retry.scheduled") {
2498
+ const sessionID = data.sessionID
2499
+ if (typeof sessionID === "string") {
2500
+ // This event only schedules a retry; the failure itself is recorded
2501
+ // by the retry hook or the step/execution events, so it must not
2502
+ // add a strike of its own.
2503
+ this.ledger.recordRetry(sessionID)
2504
+ this.persistLedgerSoon()
2505
+ }
2506
+ }
2507
+ }
2508
+
2509
+ private async registerRpc() {
2510
+ const implementation: any = {
2511
+ status: async (input: any) => this.statusFor(input?.sessionID),
2512
+ onboarding: async () => this.onboardingState(),
2513
+ completeOnboarding: async () => {
2514
+ this.onboardingComplete = true
2515
+ return { ok: true }
2516
+ },
2517
+ recordPlanPage: async (input: any) => this.recordPlanPage(input),
2518
+ preview: async (input: any) => {
2519
+ const sessionID = input?.sessionID ?? "preview"
2520
+ const session = this.ledger.getSession(sessionID)
2521
+ const task = classifyTask({
2522
+ text: input?.text ?? "",
2523
+ files: this.classifierFiles(input?.files),
2524
+ contextTokens: session?.contextTokens ?? 0,
2525
+ recentFailures: session?.failures.length ?? 0,
2526
+ })
2527
+ const child = Boolean(session?.parentID)
2528
+ const previewMode = child && this.settings.mode === "premium" ? "balanced" : this.settings.mode
2529
+ const previewPremium = child ? { ...this.premiumState(session), oneShotGranted: false } : this.premiumState(session)
2530
+ const result = routeTask({
2531
+ task,
2532
+ settings: this.settings,
2533
+ mode: previewMode,
2534
+ runtime: session?.manualOverride ? "manual" : this.settings.enabled ? "automatic" : "paused",
2535
+ models: this.registry.approved(),
2536
+ current: session?.lastModel,
2537
+ session,
2538
+ quota: this.quotaSnapshots(),
2539
+ premium: previewPremium,
2540
+ contextTokens: session?.contextTokens ?? 0,
2541
+ cachedTokens: session?.cacheReadTokens ?? 0,
2542
+ dryRun: true,
2543
+ modelHistory: this.ledger.snapshotMetrics().byModel,
2544
+ cooldowns: this.ledger.cooldowns(),
2545
+ })
2546
+ return result.decision
2547
+ },
2548
+ setMode: async (input: any) => {
2549
+ this.runtime.mode = input.mode
2550
+ this.persistRuntime()
2551
+ const summary = this.statusFor()
2552
+ void this.emit("status", { summary })
2553
+ return summary
2554
+ },
2555
+ setPaused: async (input: any) => {
2556
+ this.runtime.paused = Boolean(input.paused)
2557
+ this.persistRuntime()
2558
+ void this.refreshKnownTitles()
2559
+ void this.notifyChange(
2560
+ `global:paused:${Boolean(input.paused)}`,
2561
+ input.paused ? "PAUSED · automatic routing is paused" : "AUTO · automatic routing resumed",
2562
+ )
2563
+ const summary = this.statusFor()
2564
+ void this.emit("status", { summary })
2565
+ return summary
2566
+ },
2567
+ setSessionAuto: async (input: any) => {
2568
+ const sessionID = input.sessionID
2569
+ await this.loadSessionInfo(sessionID)
2570
+ const auto = Boolean(input.auto)
2571
+ this.ledger.patchSession(sessionID, { paused: !auto })
2572
+ if (auto) {
2573
+ // Handing control back means letting the router pick again; leaving a
2574
+ // manual override in place would make the switch look broken.
2575
+ this.ledger.clearManual(sessionID)
2576
+ this.ledger.patchSession(sessionID, { pinnedModel: undefined })
2577
+ }
2578
+ this.persistLedgerSoon()
2579
+ await this.queueTitle(sessionID)
2580
+ this.modelNotice(
2581
+ sessionID,
2582
+ auto ? "AUTO" : "MANUAL",
2583
+ this.ledger.getSession(sessionID)?.lastModel,
2584
+ auto ? "automatic routing resumed" : "automatic routing off for this session",
2585
+ )
2586
+ const summary = this.statusFor(sessionID)
2587
+ void this.emit("status", { sessionID, summary })
2588
+ return summary
2589
+ },
2590
+ setDryRun: async (input: any) => {
2591
+ this.runtime.dryRun = Boolean(input.dryRun)
2592
+ this.persistRuntime()
2593
+ void this.refreshKnownTitles()
2594
+ void this.notifyChange(
2595
+ `global:dry-run:${Boolean(input.dryRun)}`,
2596
+ input.dryRun ? "DRY RUN · decisions will be explained, not applied" : "AUTO · routing will apply decisions",
2597
+ )
2598
+ const summary = this.statusFor()
2599
+ void this.emit("status", { summary })
2600
+ return summary
2601
+ },
2602
+ resumeAuto: async (input: any) => {
2603
+ const sessionID = input.sessionID
2604
+ await this.loadSessionInfo(sessionID)
2605
+ this.ledger.clearManual(sessionID)
2606
+ this.ledger.patchSession(sessionID, { pinnedModel: undefined, paused: false, premiumOneShot: false })
2607
+ this.persistLedgerSoon()
2608
+ await this.queueTitle(sessionID)
2609
+ this.modelNotice(sessionID, "AUTO", this.ledger.getSession(sessionID)?.lastModel, "automatic routing resumed")
2610
+ const summary = this.statusFor(sessionID)
2611
+ void this.emit("status", { sessionID, summary })
2612
+ return summary
2613
+ },
2614
+ pin: async (input: any) => {
2615
+ const model: ModelRef = {
2616
+ providerID: input.providerID,
2617
+ id: input.modelID,
2618
+ ...(typeof input.variant === "string" && input.variant.length > 0 ? { variant: input.variant } : {}),
2619
+ }
2620
+ this.ledger.setManual(input.sessionID, model)
2621
+ this.ledger.patchSession(input.sessionID, { pinnedModel: model, lastModel: model })
2622
+ this.rememberModel(input.sessionID, model.providerID, model.id)
2623
+ void this.queueTitle(input.sessionID)
2624
+ this.modelNotice(input.sessionID, "MANUAL", model, "pinned · /router-auto to resume")
2625
+ this.expectedSwitch.set(input.sessionID, { key: modelKey(model.providerID, model.id), at: Date.now() })
2626
+ try {
2627
+ await this.ctx.session.switchModel({ sessionID: input.sessionID, model })
2628
+ } catch (error) {
2629
+ this.expectedSwitch.delete(input.sessionID)
2630
+ this.settingsWarnings.push(`Pin switch failed: ${safeError(error)}`)
2631
+ }
2632
+ this.persistLedgerSoon()
2633
+ const summary = this.statusFor(input.sessionID)
2634
+ void this.emit("status", { sessionID: input.sessionID, summary })
2635
+ return summary
2636
+ },
2637
+ approvePremium: async (input: any) => {
2638
+ this.ledger.grantPremiumOneShot(input.sessionID)
2639
+ this.persistLedgerSoon()
2640
+ void this.notifyChange(`${input.sessionID}:premium-granted`, "Premium approval granted for this session.")
2641
+ const summary = this.statusFor(input.sessionID)
2642
+ void this.emit("status", { sessionID: input.sessionID, summary })
2643
+ return summary
2644
+ },
2645
+ denyPremium: async (input: any) => {
2646
+ this.ledger.consumePremiumOneShot(input.sessionID)
2647
+ this.persistLedgerSoon()
2648
+ void this.notifyChange(`${input.sessionID}:premium-denied`, "Premium approval cleared for this session.")
2649
+ const summary = this.statusFor(input.sessionID)
2650
+ void this.emit("status", { sessionID: input.sessionID, summary })
2651
+ return summary
2652
+ },
2653
+ excludeModel: async (input: any) => {
2654
+ const key = modelKey(input.providerID, input.modelID)
2655
+ const excluded = new Set(this.runtime.excluded ?? [])
2656
+ if (input.excluded) excluded.add(key)
2657
+ else excluded.delete(key)
2658
+ this.runtime.excluded = [...excluded]
2659
+ this.persistRuntime()
2660
+ this.rebuildRegistry()
2661
+ const summary = this.statusFor()
2662
+ void this.emit("status", { summary })
2663
+ return summary
2664
+ },
2665
+ setPremiumBudget: async (input: any) => {
2666
+ this.runtime.premiumBudgetUsd = input.usd
2667
+ this.persistRuntime()
2668
+ const summary = this.statusFor()
2669
+ void this.emit("status", { summary })
2670
+ return summary
2671
+ },
2672
+ setReserve: async (input: any) => {
2673
+ const reserves = { ...(this.runtime.reserves ?? {}) }
2674
+ reserves[input.providerID] = {
2675
+ ...(reserves[input.providerID] ?? {}),
2676
+ ...(typeof input.monthlyPct === "number" ? { monthlyPct: input.monthlyPct } : {}),
2677
+ ...(typeof input.weeklyPct === "number" ? { weeklyPct: input.weeklyPct } : {}),
2678
+ ...(typeof input.fiveHourPct === "number" ? { fiveHourPct: input.fiveHourPct } : {}),
2679
+ }
2680
+ this.runtime.reserves = reserves
2681
+ this.persistRuntime()
2682
+ const summary = this.statusFor()
2683
+ void this.emit("status", { summary })
2684
+ return summary
2685
+ },
2686
+ refreshQuota: async () => {
2687
+ await Promise.allSettled([this.quota.refresh("opencode-go", true), this.quota.refresh("commandcode", true)])
2688
+ const summary = this.statusFor()
2689
+ void this.emit("status", { summary })
2690
+ return summary
2691
+ },
2692
+ report: async () => this.report(),
2693
+ usage: async () => this.usageReport(),
2694
+ /**
2695
+ * Report the intelligence floor in force and where it came from.
2696
+ *
2697
+ * Deliberately reports the *disabled* case too. The useful answer to
2698
+ * "what is my floor" is not one number: it is whether that number was
2699
+ * measured, hand-picked, or not yet calibratable because there is no
2700
+ * traffic to calibrate against.
2701
+ */
2702
+ floor: async () => this.floorReport(),
2703
+ setCalibrateFloor: async (input: any) => {
2704
+ const enabled = Boolean(input?.enabled)
2705
+ // Routing settings are file-backed. Runtime overrides exist for mode,
2706
+ // pause and dry-run; changing a routing policy value at runtime would
2707
+ // diverge from the file and silently reset on reload, so this reports
2708
+ // exactly what to write instead of pretending it was applied.
2709
+ return {
2710
+ ...this.floorReport(),
2711
+ setting: "routing.calibrateFloor",
2712
+ requested: enabled,
2713
+ applied: false,
2714
+ instruction: enabled
2715
+ ? "Set routing.calibrateFloor to true in smart-router.json, then restart the router."
2716
+ : "Set routing.calibrateFloor to false in smart-router.json, then restart the router.",
2717
+ }
2718
+ },
2719
+ getConfig: async () => ({
2720
+ settings: (() => {
2721
+ const copy = this.safe(this.settings)
2722
+ for (const provider of Object.values(copy.providers ?? {}) as any[]) {
2723
+ if (provider && typeof provider === "object") delete provider.credentialPath
2724
+ }
2725
+ return copy
2726
+ })(),
2727
+ sources: this.settingsSources,
2728
+ warnings: this.settingsWarnings,
2729
+ runtime: this.runtime,
2730
+ ledgerUpdatedAt: this.ledger.data.updatedAt,
2731
+ models: this.registry.approved().map((model) => ({
2732
+ key: model.key,
2733
+ tier: model.tier,
2734
+ intelligence: model.intelligence,
2735
+ design: model.design,
2736
+ premium: model.premium,
2737
+ training: model.training,
2738
+ monthlyAllowanceUsd: model.monthlyAllowanceUsd,
2739
+ })),
2740
+ }),
2741
+ resetOverrides: async () => {
2742
+ for (const key of Object.keys(this.runtime) as Array<keyof RuntimeOverrides>) delete this.runtime[key]
2743
+ sharedCore.runtime = this.runtime
2744
+ this.persistRuntime()
2745
+ this.rebuildRegistry()
2746
+ const summary = this.statusFor()
2747
+ void this.emit("status", { summary })
2748
+ return summary
2749
+ },
2750
+ resetMetrics: async () => {
2751
+ await sharedCore.resetLedger()
2752
+ this.ledger = sharedCore.ledger
2753
+ this.contextLoaded.clear()
2754
+ this.contextInflight.clear()
2755
+ const summary = this.statusFor()
2756
+ void this.emit("status", { summary })
2757
+ return summary
2758
+ },
2759
+ refreshIndicator: async (input: any) => {
2760
+ await this.loadSessionInfo(input.sessionID)
2761
+ await this.queueTitle(input.sessionID)
2762
+ const summary = this.statusFor(input.sessionID)
2763
+ void this.emit("status", { sessionID: input.sessionID, summary })
2764
+ return summary
2765
+ },
2766
+ setTitleIndicator: async (input: any) => {
2767
+ this.runtime.titleIndicator = Boolean(input.enabled)
2768
+ this.persistRuntime()
2769
+ if (input.enabled) await this.refreshKnownTitles()
2770
+ else await this.restoreTitles()
2771
+ const summary = this.statusFor()
2772
+ void this.emit("status", { summary })
2773
+ return summary
2774
+ },
2775
+ diagnostics: async () => sharedCore.diagnostics(),
2776
+ }
2777
+ const safeImplementation = Object.fromEntries(
2778
+ Object.entries(implementation).map(([name, handler]) => [
2779
+ name,
2780
+ async (input: any) => this.safe(await (handler as any)(input)),
2781
+ ]),
2782
+ )
2783
+ this.rpcRegistration = await this.ctx.rpc.register(SmartRouterRpc, safeImplementation)
2784
+ }
2785
+
2786
+ private async registerCommands() {
2787
+ const notice = async (sessionID: string, text: string) => {
2788
+ try {
2789
+ await this.ctx.session.synthetic({ sessionID, text, description: "Smart Router", resume: false })
2790
+ } catch (error) {
2791
+ this.settingsWarnings.push(`Could not display router command output: ${safeError(error)}`)
2792
+ }
2793
+ }
2794
+
2795
+ const command = (name: string, description: string, execute: (input: any) => Promise<void>) =>
2796
+ this.ctx.command.transform((editor: any) => {
2797
+ editor.add({ name, description, execute })
2798
+ })
2799
+
2800
+ this.registrations.push(
2801
+ await command("router-status", "Show the current routing decision, tier and quota state", async ({ sessionID }: any) => {
2802
+ await this.loadSessionInfo(sessionID)
2803
+ await this.queueTitle(sessionID)
2804
+ const status = this.statusFor(sessionID)
2805
+ const decision = status.lastDecision
2806
+ const lines = [
2807
+ `Smart Router: ${status.indicator.state} · ${status.mode}${status.dryRun ? " · dry run" : ""}`,
2808
+ status.current ? `Current: ${status.current.providerID}/${status.current.id}${status.currentTier ? ` (tier ${status.currentTier})` : ""}` : "Current: unknown",
2809
+ decision ? `Task: tier ${decision.task.tier} ${decision.task.kind}; chosen: ${decision.chosen ? `${decision.chosen.providerID}/${decision.chosen.id}` : "none"}` : "No decision yet",
2810
+ decision ? `Why: ${decision.switchReason}` : "",
2811
+ ...status.quota.flatMap((snapshot) => formatQuotaLines(snapshot)),
2812
+ status.premium.mode !== "off"
2813
+ ? `Premium: ${status.premium.mode}, ${usd(status.premium.remainingUsd)} remaining${status.premium.oneShotGranted ? ", one-shot approved" : ""}`
2814
+ : "Premium: off",
2815
+ ...status.warnings.slice(0, 4).map((warning) => `Warning: ${warning}`),
2816
+ ].filter(Boolean)
2817
+ await notice(sessionID, lines.join("\n"))
2818
+ }),
2819
+ )
2820
+
2821
+ this.registrations.push(
2822
+ await command("router-auto", "Resume automatic routing for this session", async ({ sessionID }: any) => {
2823
+ this.ledger.clearManual(sessionID)
2824
+ this.ledger.patchSession(sessionID, { pinnedModel: undefined, paused: false })
2825
+ this.persistLedgerSoon()
2826
+ void this.queueTitle(sessionID)
2827
+ this.modelNotice(sessionID, "AUTO", this.ledger.getSession(sessionID)?.lastModel, "automatic routing resumed")
2828
+ await notice(sessionID, "AUTO · automatic routing resumed for this session.")
2829
+ }),
2830
+ )
2831
+
2832
+ this.registrations.push(
2833
+ await command("router-pin", "Pin a model for this session: /router-pin provider/model", async ({ sessionID, prompt }: any) => {
2834
+ const requested = String(prompt?.text ?? "").trim().split(/\s+/).pop() ?? ""
2835
+ const [modelKeyPart, variant] = requested.split("#", 2)
2836
+ const parsed = parseModelKey(modelKeyPart ?? "")
2837
+ if (!parsed.modelID) {
2838
+ await notice(sessionID, "Usage: /router-pin provider/model[#variant]")
2839
+ return
2840
+ }
2841
+ const model = {
2842
+ providerID: parsed.providerID,
2843
+ id: parsed.modelID,
2844
+ ...(variant ? { variant } : {}),
2845
+ }
2846
+ this.ledger.setManual(sessionID, model)
2847
+ this.ledger.patchSession(sessionID, { pinnedModel: model, lastModel: model })
2848
+ this.rememberModel(sessionID, model.providerID, model.id)
2849
+ this.expectedSwitch.set(sessionID, { key: requested, at: Date.now() })
2850
+ try {
2851
+ await this.ctx.session.switchModel({ sessionID, model })
2852
+ void this.queueTitle(sessionID)
2853
+ this.modelNotice(sessionID, "MANUAL", model, "pinned · /router-auto to resume")
2854
+ await notice(sessionID, `MANUAL · pinned ${requested}. Automatic routing is suspended for this session. Run /router-auto to resume.`)
2855
+ } catch (error) {
2856
+ this.expectedSwitch.delete(sessionID)
2857
+ await notice(sessionID, `Could not pin ${requested}: ${safeError(error)}`)
2858
+ }
2859
+ this.persistLedgerSoon()
2860
+ }),
2861
+ )
2862
+
2863
+ this.registrations.push(
2864
+ await command("router-mode", "Set routing mode: efficiency, balanced, or premium", async ({ sessionID, prompt }: any) => {
2865
+ const mode = String(prompt?.text ?? "").trim().split(/\s+/).pop()
2866
+ if (mode !== "efficiency" && mode !== "balanced" && mode !== "premium") {
2867
+ await notice(sessionID, "Usage: /router-mode efficiency|balanced|premium")
2868
+ return
2869
+ }
2870
+ this.runtime.mode = mode
2871
+ this.persistRuntime()
2872
+ await notice(sessionID, `Routing mode set to ${mode}.`)
2873
+ }),
2874
+ )
2875
+
2876
+ this.registrations.push(
2877
+ await command("router-approve", "Approve one premium request for this session", async ({ sessionID }: any) => {
2878
+ this.ledger.grantPremiumOneShot(sessionID)
2879
+ this.persistLedgerSoon()
2880
+ await notice(sessionID, "One-shot premium approval granted for this session.")
2881
+ }),
2882
+ )
2883
+
2884
+ this.registrations.push(
2885
+ await command("router-pause", "Pause automatic routing globally", async ({ sessionID }: any) => {
2886
+ this.runtime.paused = true
2887
+ this.persistRuntime()
2888
+ void this.refreshKnownTitles()
2889
+ void this.notifyChange("global:paused:true", "PAUSED · automatic routing is paused")
2890
+ await notice(sessionID, "PAUSED · automatic routing is paused.")
2891
+ }),
2892
+ )
2893
+
2894
+ this.registrations.push(
2895
+ await command("router-resume", "Resume automatic routing globally", async ({ sessionID }: any) => {
2896
+ this.runtime.paused = false
2897
+ this.persistRuntime()
2898
+ void this.refreshKnownTitles()
2899
+ void this.notifyChange("global:paused:false", "AUTO · automatic routing resumed")
2900
+ await notice(sessionID, "AUTO · automatic routing resumed.")
2901
+ }),
2902
+ )
2903
+
2904
+ this.registrations.push(
2905
+ await command("router-report", "Show local usage and routing metrics", async ({ sessionID }: any) => {
2906
+ await notice(sessionID, this.formatReport())
2907
+ }),
2908
+ )
2909
+
2910
+ this.registrations.push(
2911
+ await command("router-floor", "Show the intelligence floor in force and where it came from", async ({ sessionID }: any) => {
2912
+ await notice(sessionID, this.formatFloor())
2913
+ }),
2914
+ )
2915
+
2916
+ this.registrations.push(
2917
+ await command("router-dry-run", "Enable or disable dry-run routing: /router-dry-run on|off", async ({ sessionID, prompt }: any) => {
2918
+ const value = String(prompt?.text ?? "").trim().split(/\s+/).pop()
2919
+ if (value !== "on" && value !== "off") {
2920
+ await notice(sessionID, "Usage: /router-dry-run on|off")
2921
+ return
2922
+ }
2923
+ this.runtime.dryRun = value === "on"
2924
+ this.persistRuntime()
2925
+ void this.refreshKnownTitles()
2926
+ void this.notifyChange(
2927
+ `global:dry-run:${value === "on"}`,
2928
+ value === "on" ? "DRY RUN · decisions will be explained, not applied" : "AUTO · routing will apply decisions",
2929
+ )
2930
+ await notice(sessionID, value === "on" ? "DRY RUN · decisions will be explained, not applied." : "AUTO · routing will apply decisions.")
2931
+ }),
2932
+ )
2933
+
2934
+ this.registrations.push(
2935
+ await command("router-title", "Turn the persistent session-title indicator on or off: /router-title on|off", async ({ sessionID, prompt }: any) => {
2936
+ const value = String(prompt?.text ?? "").trim().split(/\s+/).pop()
2937
+ if (value !== "on" && value !== "off") {
2938
+ await notice(sessionID, "Usage: /router-title on|off")
2939
+ return
2940
+ }
2941
+ this.runtime.titleIndicator = value === "on"
2942
+ this.persistRuntime()
2943
+ if (value === "on") await this.refreshKnownTitles()
2944
+ else await this.restoreTitles()
2945
+ await notice(sessionID, value === "on" ? "Session-title indicator enabled." : "Session-title indicator disabled; base titles restored.")
2946
+ }),
2947
+ )
2948
+
2949
+ this.registrations.push(
2950
+ await command("router-exclude", "Exclude a model from automatic routing: /router-exclude provider/model", async ({ sessionID, prompt }: any) => {
2951
+ const requested = String(prompt?.text ?? "").trim().split(/\s+/).pop() ?? ""
2952
+ const parsed = parseModelKey(requested)
2953
+ if (!parsed.modelID) {
2954
+ await notice(sessionID, "Usage: /router-exclude provider/model")
2955
+ return
2956
+ }
2957
+ const excluded = new Set(this.runtime.excluded ?? [])
2958
+ excluded.add(requested)
2959
+ this.runtime.excluded = [...excluded]
2960
+ this.persistRuntime()
2961
+ this.rebuildRegistry()
2962
+ await notice(sessionID, `Excluded ${requested} from automatic routing.`)
2963
+ }),
2964
+ )
2965
+
2966
+ this.registrations.push(
2967
+ await command("router-premium-budget", "Set the monthly premium budget in USD: /router-premium-budget 10", async ({ sessionID, prompt }: any) => {
2968
+ const value = Number(String(prompt?.text ?? "").trim().split(/\s+/).pop())
2969
+ if (!Number.isFinite(value) || value < 0) {
2970
+ await notice(sessionID, "Usage: /router-premium-budget <usd>")
2971
+ return
2972
+ }
2973
+ this.runtime.premiumBudgetUsd = Math.max(0, Math.min(100_000, value))
2974
+ this.persistRuntime()
2975
+ await notice(sessionID, `Premium monthly budget set to ${usd(value)}.`)
2976
+ }),
2977
+ )
2978
+
2979
+ this.registrations.push(
2980
+ await command("router-reserve", "Set provider reserves: /router-reserve provider monthlyPct weeklyPct fiveHourPct", async ({ sessionID, prompt }: any) => {
2981
+ const tokens = String(prompt?.text ?? "").trim().split(/\s+/).filter(Boolean)
2982
+ const tail = tokens.slice(-4)
2983
+ const providerID = tail[0]
2984
+ const monthlyPct = Number(tail[1])
2985
+ const weeklyPct = Number(tail[2])
2986
+ const fiveHourPct = Number(tail[3])
2987
+ if (!providerID || ![monthlyPct, weeklyPct, fiveHourPct].every((value) => Number.isFinite(value))) {
2988
+ await notice(sessionID, "Usage: /router-reserve provider monthlyPct weeklyPct fiveHourPct")
2989
+ return
2990
+ }
2991
+ const clampReserve = (value: number) => Math.max(0, Math.min(90, value))
2992
+ this.runtime.reserves = {
2993
+ ...(this.runtime.reserves ?? {}),
2994
+ [providerID]: {
2995
+ monthlyPct: clampReserve(monthlyPct),
2996
+ weeklyPct: clampReserve(weeklyPct),
2997
+ fiveHourPct: clampReserve(fiveHourPct),
2998
+ },
2999
+ }
3000
+ this.persistRuntime()
3001
+ await notice(sessionID, `Reserves for ${providerID}: monthly ${monthlyPct}%, weekly ${weeklyPct}%, 5h ${fiveHourPct}%.`)
3002
+ }),
3003
+ )
3004
+
3005
+ this.registrations.push(
3006
+ await command("router-reset-metrics", "Reset local router metrics and session routing state: /router-reset-metrics --confirm", async ({ sessionID, prompt }: any) => {
3007
+ if (!String(prompt?.text ?? "").includes("--confirm")) {
3008
+ await notice(sessionID, "This resets local router metrics, not conversations. Run /router-reset-metrics --confirm to proceed.")
3009
+ return
3010
+ }
3011
+ await sharedCore.resetLedger()
3012
+ this.ledger = sharedCore.ledger
3013
+ this.contextLoaded.clear()
3014
+ this.contextInflight.clear()
3015
+ await notice(sessionID, "Local router metrics and routing state were reset; conversations were not changed.")
3016
+ }),
3017
+ )
3018
+
3019
+ this.registrations.push(
3020
+ await command("router-diagnostics", "Show shared-core lifecycle and registration diagnostics", async ({ sessionID }: any) => {
3021
+ await notice(sessionID, JSON.stringify(sharedCore.diagnostics(), null, 2))
3022
+ }),
3023
+ )
3024
+
3025
+ this.registrations.push(
3026
+ await command("router-explain", "Explain the last routing decision", async ({ sessionID }: any) => {
3027
+ const status = this.statusFor(sessionID)
3028
+ const decision = status.lastDecision
3029
+ if (!decision) {
3030
+ await notice(sessionID, "No routing decision has been recorded yet.")
3031
+ return
3032
+ }
3033
+ const lines = [
3034
+ `Decision ${decision.id} at ${new Date(decision.at).toISOString()}`,
3035
+ `Task: tier ${decision.task.tier} ${decision.task.kind} (${decision.task.confidence} confidence)`,
3036
+ `Signals: ${decision.task.signals.join(", ") || "none"}`,
3037
+ `Reasons: ${decision.reasons.join(" ") || "none"}`,
3038
+ `Chosen: ${decision.chosen ? `${decision.chosen.providerID}/${decision.chosen.id}` : "none"}`,
3039
+ `Switch: ${decision.switchReason}`,
3040
+ `Warnings: ${decision.warnings.join(" ") || "none"}`,
3041
+ ...decision.candidates.slice(0, 5).map(
3042
+ (candidate) =>
3043
+ ` ${candidate.model.providerID}/${candidate.model.modelID}: ${candidate.eligible ? "eligible" : candidate.reason} · ${usd(candidate.effectiveCost)} expected${candidate.creditMultiplier && candidate.creditMultiplier !== 1 ? ` · ${candidate.creditMultiplier}x credits` : ""}`,
3044
+ ),
3045
+ ...decision.quota.map(
3046
+ (snapshot) => ` quota ${snapshot.label}: ${quotaStateLabel(snapshot)}`,
3047
+ ),
3048
+ ]
3049
+ await notice(sessionID, lines.join("\n"))
3050
+ }),
3051
+ )
3052
+
3053
+ this.registrations.push(
3054
+ await command("router-opendesign", "Show OpenDesign connection, entitlement and credit diagnostics", async ({ sessionID }: any) => {
3055
+ await notice(sessionID, await this.formatOpenDesignReport())
3056
+ }),
3057
+ )
3058
+ }
3059
+
3060
+ /**
3061
+ * Report the OpenDesign connection state.
3062
+ *
3063
+ * This command makes only read-only metadata requests. It never performs
3064
+ * inference and never spends plan credits.
3065
+ */
3066
+ private async formatOpenDesignReport(): Promise<string> {
3067
+ const providerSettings = this.settings.providers.opendesign
3068
+ if (!providerSettings) return "OpenDesign is not configured."
3069
+ const lines: string[] = []
3070
+ lines.push(`OpenDesign provider: ${providerSettings.enabled ? "enabled" : "disabled"} · plan ${providerSettings.plan}`)
3071
+ lines.push(`Base URL: ${normalizeOpenDesignBaseURL(providerSettings.baseURL)}`)
3072
+ lines.push(
3073
+ `Allowance: monthly ${usd(providerSettings.monthlyCreditsUsd ?? 0)} · 5h ${usd(providerSettings.fiveHourCapUsd ?? 0)} · weekly ${usd(providerSettings.weeklyCapUsd ?? 0)} (5h and weekly are nested inside the monthly maximum)`,
3074
+ )
3075
+ lines.push(`Subscription only: ${providerSettings.subscriptionOnly !== false ? "yes" : "no"} · purchased credits allowed: ${providerSettings.allowExtraUsage ? "yes" : "no"}`)
3076
+ if (providerSettings.creditsPerUsd) lines.push(`Credits per USD: ${providerSettings.creditsPerUsd}`)
3077
+
3078
+ let apiKey: string | undefined
3079
+ let controlKey: string | undefined
3080
+ try {
3081
+ const resolved = await this.resolveOpenDesignCredentials()
3082
+ apiKey = resolved.openDesignApiKey
3083
+ controlKey = resolved.openDesignControlKey
3084
+ } catch {
3085
+ // Fall through to the unknown state.
3086
+ }
3087
+ lines.push(`Inference key: ${apiKey ? "found" : "not found"}`)
3088
+ lines.push(`Control key: ${controlKey ? "found (verified quota available)" : "not configured (quota is an estimate)"}`)
3089
+
3090
+ if (apiKey) {
3091
+ const result = await discoverOpenDesignModels({
3092
+ apiKey,
3093
+ ...(providerSettings.baseURL ? { baseURL: providerSettings.baseURL } : {}),
3094
+ })
3095
+ if (!result.ok) {
3096
+ lines.push(`Gateway discovery failed: ${result.error ?? "unknown error"}`)
3097
+ } else {
3098
+ lines.push(`Entitled models on the gateway: ${result.models.length}`)
3099
+ for (const model of result.models.slice(0, 20)) {
3100
+ const spec = OPEN_DESIGN_MODEL_BY_ID.get(model.id)
3101
+ const multiplier = creditMultiplierFor(model.id)
3102
+ const context = model.contextBudget ?? spec?.context ?? 0
3103
+ const vision = model.inputModalities.includes("image") ? "image" : "text only"
3104
+ lines.push(
3105
+ ` ${model.id}: ${context.toLocaleString()} usable input · ${(model.outputBudget ?? spec?.maxOutput ?? 0).toLocaleString()} output · ${vision} · ${multiplier.multiplier}x credits${multiplier.evidence === "unverified" ? " (multiplier not published)" : ""}`,
3106
+ )
3107
+ }
3108
+ }
3109
+ }
3110
+
3111
+ const snapshot = this.quotaSnapshots().find((entry) => entry.providerID === "opendesign")
3112
+ if (snapshot) {
3113
+ lines.push(`Quota state: ${quotaStateLabel(snapshot)}`)
3114
+ for (const note of snapshot.notes.slice(0, 4)) lines.push(` ${note}`)
3115
+ if (snapshot.error) lines.push(` ${snapshot.error}`)
3116
+ }
3117
+ lines.push("No inference request was made by this command.")
3118
+ return lines.join("\n")
3119
+ }
3120
+
3121
+ /**
3122
+ * One screen describing the floor, its provenance, and what it admits.
3123
+ *
3124
+ * Printed rather than summarised because the point of the command is to make
3125
+ * the number checkable. The reason line names the model and both cost figures
3126
+ * the calibration compared, so the reader can disagree with it.
3127
+ */
3128
+ private formatFloor(): string {
3129
+ const report = this.floorReport()
3130
+ const lines: string[] = []
3131
+ lines.push(`Floor in force: ${report.floorInForce.toFixed(1)} (${report.source})`)
3132
+ lines.push(` configured: ${report.configuredFloor.toFixed(1)}`)
3133
+
3134
+ if (report.offlineBypass) {
3135
+ lines.push(" calibration is inactive while offline mode is enabled")
3136
+ } else if (!report.calibrateFloor) {
3137
+ lines.push(" calibration is off; set routing.calibrateFloor to true to derive this from spend")
3138
+ } else if (!report.calibrated) {
3139
+ lines.push(` not calibrated: ${report.reason}`)
3140
+ } else {
3141
+ lines.push(` calibrated: ${report.reason}`)
3142
+ if (report.baselineUsdPerRequest !== null) {
3143
+ lines.push(` measured baseline: $${report.baselineUsdPerRequest.toFixed(6)} per request over ${report.windowDays} days`)
3144
+ }
3145
+ }
3146
+
3147
+ lines.push(` needs ${report.minRequests} recorded requests; you have ${report.totalRequests}`)
3148
+ lines.push(`Admitted at this floor (${report.admitted.length}):`)
3149
+ for (const entry of report.admitted.slice(0, 10)) {
3150
+ const price = entry.listInputUsdPerMtok === null ? "?" : `$${entry.listInputUsdPerMtok}`
3151
+ lines.push(` ${entry.model} intel ${entry.intelligence.toFixed(1)} ${price}/Mtok in`)
3152
+ }
3153
+ if (report.admitted.length > 10) lines.push(` ... ${report.admitted.length - 10} more`)
3154
+
3155
+ if (report.measuredTraffic.length > 0) {
3156
+ lines.push("Measured traffic:")
3157
+ for (const row of report.measuredTraffic.slice(0, 6)) {
3158
+ lines.push(` ${row.model} ${row.requests} req $${row.usdPerRequest.toFixed(6)}/req`)
3159
+ }
3160
+ }
3161
+ return lines.join("\n")
3162
+ }
3163
+
3164
+ private formatReport() {
3165
+ const metrics = this.ledger.snapshotMetrics()
3166
+ const modelLines = Object.entries(metrics.byModel)
3167
+ .sort((a, b) => b[1].usd - a[1].usd)
3168
+ .slice(0, 8)
3169
+ .map(([key, value]) => {
3170
+ const reliability =
3171
+ value.failures > 0
3172
+ ? ` · ${value.failures} provider fault${value.failures === 1 ? "" : "s"}`
3173
+ : ""
3174
+ const cost = key.startsWith("opendesign/")
3175
+ ? `${usd(value.usd)} est. credits`
3176
+ : usd(value.usd)
3177
+ return `${key}: ${value.requests} req · ${value.input.toLocaleString()} in · ${value.cacheRead.toLocaleString()} cache · ${value.output.toLocaleString()} out · ${cost}${reliability}`
3178
+ })
3179
+ const latency = metrics.latencySamples > 0 ? `${Math.round(metrics.latencyMsTotal / metrics.latencySamples)} ms avg` : "no samples"
3180
+ const cooldowns = Object.entries(this.ledger.cooldowns()).filter(([, until]) => until > Date.now())
3181
+ return [
3182
+ `Requests: ${metrics.requests.toLocaleString()}`,
3183
+ `Tokens: ${metrics.inputTokens.toLocaleString()} fresh input, ${metrics.cacheReadTokens.toLocaleString()} cache read, ${metrics.outputTokens.toLocaleString()} output`,
3184
+ `Estimated allowance value: ${usd(metrics.estimatedUsd)} (provider-reported where available; otherwise local estimate)`,
3185
+ `Switches: ${metrics.switches} · escalations: ${metrics.escalations} · prevented premium: ${metrics.preventedPremium} · retries: ${metrics.retries}`,
3186
+ `Premium spend: ${usd(metrics.premiumUsd)} across ${metrics.premiumRequests} requests`,
3187
+ `Latency: ${latency}`,
3188
+ ...cooldowns.map(
3189
+ ([key, until]) => `Cooling down: ${key} for another ${Math.max(0, Math.round((until - Date.now()) / 1000))}s after a provider fault`,
3190
+ ),
3191
+ ...modelLines,
3192
+ ].join("\n")
3193
+ }
3194
+
3195
+ private report() {
3196
+ return {
3197
+ text: this.formatReport(),
3198
+ metrics: this.ledger.snapshotMetrics(),
3199
+ quota: this.quotaSnapshots(),
3200
+ decisions: this.ledger.decisions(20),
3201
+ status: this.statusFor(),
3202
+ }
3203
+ }
3204
+
3205
+ /**
3206
+ * Durable usage report: per-provider, per-model, per-window, with forecasts.
3207
+ *
3208
+ * A provider that meters a five-hour window reports it; one that does not
3209
+ * reports an empty row, so a dashboard can distinguish "unused" from "not
3210
+ * applicable" instead of showing a limit that cannot be hit.
3211
+ */
3212
+ private usageReport() {
3213
+ const now = Date.now()
3214
+ const quota = this.quotaSnapshots()
3215
+ const providers = Object.entries(this.settings.providers)
3216
+ .filter(([, settings]) => settings.enabled)
3217
+ .map(([providerID, settings]) => {
3218
+ const snapshot = quota.find((entry) => entry.providerID === providerID)
3219
+ const resolvedPlan = resolvePlanWithReason(providerID, settings.plan)
3220
+ const plan = resolvedPlan.plan
3221
+ const designPlan = plan?.pools.find((pool) => pool.id === "design-plan")
3222
+ const hasFiveHourWindow = Boolean(
3223
+ !settings.ignoreFiveHourWindow && designPlan?.fiveHourUsd !== undefined,
3224
+ )
3225
+ const hasWeeklyWindow = Boolean(
3226
+ !settings.ignoreWeeklyWindow &&
3227
+ (designPlan?.weeklyUsd !== undefined ||
3228
+ plan?.perModelWindowRule !== undefined),
3229
+ )
3230
+ return buildProviderUsageReport({
3231
+ providerID,
3232
+ label: snapshot?.label ?? plan?.label ?? providerID,
3233
+ // A configured-but-unrecognized plan id is reported as unresolved
3234
+ // rather than echoed. Printing the raw string next to dollar figures
3235
+ // implies it is a tier this build understands.
3236
+ plan: describePlan(plan?.planID, settings.plan),
3237
+ source: snapshot?.source ?? "unknown",
3238
+ ...(snapshot?.quotaState ? { quotaState: snapshot.quotaState } : {}),
3239
+ now,
3240
+ source_: {
3241
+ aggregate: (window) => this.ledger.aggregate(window),
3242
+ usageByModel: (window) => this.ledger.usageByModel(window),
3243
+ usageEvents: this.ledger.usageEvents(),
3244
+ },
3245
+ hasFiveHourWindow,
3246
+ hasWeeklyWindow,
3247
+ ...(snapshot?.monthlyRemainingUsd !== undefined
3248
+ ? { remainingMonthlyUsd: snapshot.monthlyRemainingUsd }
3249
+ : {}),
3250
+ notes: snapshot?.notes ?? [],
3251
+ })
3252
+ })
3253
+ return {
3254
+ generatedAt: now,
3255
+ providers,
3256
+ speed: this.speedSnapshot(),
3257
+ contextManagement: {
3258
+ mode: this.settings.contextManagement.mode,
3259
+ description: describeMode(this.settings.contextManagement.mode),
3260
+ enabled: this.settings.contextManagement.enabled,
3261
+ },
3262
+ retention: {
3263
+ rawEventHorizonHours: 48,
3264
+ note: "Events older than the horizon are folded into hourly aggregates, so nothing is discarded.",
3265
+ },
3266
+ }
3267
+ }
3268
+
3269
+ async dispose() {
3270
+ if (this.cleanupDone) return
3271
+ this.cleanupDone = true
3272
+ this.promptRoutes.clearAll()
3273
+ this.pendingHeals.clear()
3274
+ this.requestKinds.clear()
3275
+ if (this.bridgeID) sharedCore.removeBridge(this.bridgeID)
3276
+ await Promise.allSettled(this.registrations.map((registration) => registration?.dispose?.()))
3277
+ // Start the write immediately, but do not block a reload on storage. The
3278
+ // grace timer performs the final flush when the last bridge leaves.
3279
+ void sharedCore.persistNow()
3280
+ }
3281
+ }