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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +286 -0
  2. package/CONTRIBUTING.md +103 -0
  3. package/LICENSE +21 -0
  4. package/README.md +425 -2
  5. package/SECURITY.md +174 -0
  6. package/config.example.json +107 -0
  7. package/dist/npm/index.js +40 -0
  8. package/docs/ARCHITECTURE.md +104 -0
  9. package/docs/DEBUGGING-GUI.md +70 -0
  10. package/docs/INSTALL.md +151 -0
  11. package/docs/LIMITATIONS.md +230 -0
  12. package/docs/MODELS.md +124 -0
  13. package/docs/OPENDESIGN-INTEGRATION.md +635 -0
  14. package/docs/QA.md +142 -0
  15. package/docs/SHARED-CORE.md +114 -0
  16. package/install +75 -0
  17. package/package.json +94 -4
  18. package/scripts/install.sh +324 -0
  19. package/scripts/uninstall.sh +91 -0
  20. package/src/allowance.ts +135 -0
  21. package/src/core/classify/classifier.ts +305 -0
  22. package/src/core/config-writer.ts +167 -0
  23. package/src/core/config.ts +1047 -0
  24. package/src/core/context/strategy.ts +188 -0
  25. package/src/core/context-health.ts +39 -0
  26. package/src/core/failures.ts +398 -0
  27. package/src/core/history/opencode.ts +173 -0
  28. package/src/core/models/protocol.ts +22 -0
  29. package/src/core/models/registry.ts +204 -0
  30. package/src/core/models/seed.ts +781 -0
  31. package/src/core/offline/mode.ts +321 -0
  32. package/src/core/plan-page.ts +390 -0
  33. package/src/core/plans/catalog.ts +487 -0
  34. package/src/core/policy/calibration.ts +259 -0
  35. package/src/core/policy/eligibility.ts +630 -0
  36. package/src/core/providers/enablement.ts +143 -0
  37. package/src/core/providers/opendesign-credits.ts +206 -0
  38. package/src/core/providers/opendesign-models.ts +654 -0
  39. package/src/core/providers/opendesign.ts +474 -0
  40. package/src/core/quota/calibration.ts +166 -0
  41. package/src/core/quota/commandcode.ts +471 -0
  42. package/src/core/quota/credentials.ts +490 -0
  43. package/src/core/quota/ledger.ts +1304 -0
  44. package/src/core/quota/opencode-go.ts +148 -0
  45. package/src/core/quota/opendesign.ts +1043 -0
  46. package/src/core/quota/reconcile.ts +155 -0
  47. package/src/core/quota/report.ts +261 -0
  48. package/src/core/quota/service.ts +273 -0
  49. package/src/core/reprompt.ts +48 -0
  50. package/src/core/routing/engine.ts +978 -0
  51. package/src/core/types.ts +866 -0
  52. package/src/core/ui.ts +236 -0
  53. package/src/core/util.ts +114 -0
  54. package/src/gui/index.tsx +1675 -0
  55. package/src/gui/viewmodel.ts +456 -0
  56. package/src/index.ts +33 -0
  57. package/src/rpc.ts +204 -0
  58. package/src/server/containment.ts +131 -0
  59. package/src/server/controller.ts +3405 -0
  60. package/src/server/route-tracker.ts +60 -0
  61. package/src/server/shared-core.ts +701 -0
  62. package/src/tui/index.tsx +168 -0
@@ -0,0 +1,456 @@
1
+ import type { CandidateEvaluation, ModelKey, ProviderQuotaSnapshot, RoutingDecision } from "../core/types.js"
2
+
3
+ /**
4
+ * The GUI's view-model.
5
+ *
6
+ * Pure functions over the data the server already produces. No Solid, no DOM,
7
+ * no RPC — so every rule below is testable in plain Node, and the TUI renderer
8
+ * can reuse it unchanged.
9
+ *
10
+ * The rule this file exists to enforce: the panel shows the router's own words.
11
+ * If the engine says a model was ineligible because the weekly window is spent,
12
+ * that sentence is displayed. The view-model formats and orders; it never
13
+ * invents or softens a reason.
14
+ */
15
+
16
+ export type Verdict = "chosen" | "eligible" | "blocked"
17
+
18
+ export interface CandidateRow {
19
+ readonly key: string
20
+ readonly name: string
21
+ readonly tier: number
22
+ readonly verdict: Verdict
23
+ readonly reason: string
24
+ readonly estimatedUsd: number
25
+ readonly effectiveCost: number
26
+ readonly successProbability: number
27
+ /** Why the ranking moved this model, when a multiplier applied. Empty otherwise. */
28
+ readonly adjustments: ReadonlyArray<string>
29
+ }
30
+
31
+ export interface DecisionView {
32
+ readonly id: string
33
+ readonly chosen: string | undefined
34
+ readonly switched: boolean
35
+ readonly switchReason: string
36
+ readonly taskKind: string
37
+ readonly taskTier: number
38
+ readonly contextTokens: number
39
+ readonly estimatedUsd: number
40
+ readonly reasons: ReadonlyArray<string>
41
+ readonly warnings: ReadonlyArray<string>
42
+ /** One line about a self-healed deterministic rejection, when the decision is one. */
43
+ readonly selfheal?: string
44
+ readonly eligibleCount: number
45
+ readonly blockedCount: number
46
+ readonly rows: ReadonlyArray<CandidateRow>
47
+ }
48
+
49
+ export type WindowID = "fiveHour" | "weekly" | "monthly"
50
+
51
+ export interface QuotaWindowView {
52
+ readonly id: WindowID
53
+ /** Short label used in the panel: 5h, 7d, Monthly. */
54
+ readonly label: string
55
+ readonly usedPct: number
56
+ readonly remainingPct: number
57
+ readonly usedUsd?: number
58
+ readonly capUsd?: number
59
+ readonly resetsAt?: number
60
+ readonly resetLabel?: string
61
+ readonly source: "provider" | "estimate" | "unknown"
62
+ readonly sourceLabel: string
63
+ readonly exceeded: boolean
64
+ /**
65
+ * Whether the provider reports this window at all. A window that does not
66
+ * exist for a plan is different from a window at 0%, and showing "0%" for a
67
+ * five-hour cap that does not apply would be a lie.
68
+ */
69
+ readonly reported: boolean
70
+ /**
71
+ * True when the figure is a lower bound the router derived itself, not a
72
+ * measurement from the provider.
73
+ *
74
+ * This matters more than it looks. OpenDesign publishes no quota endpoint for
75
+ * an inference key, so the router estimates from the traffic it routed itself.
76
+ * A user whose account was already 42% used before installing the router saw
77
+ * "5% monthly" — a true statement about the router's own traffic, and a
78
+ * dangerously wrong impression of their account. A lower bound must never be
79
+ * rendered as a measurement.
80
+ */
81
+ readonly lowerBound: boolean
82
+ }
83
+
84
+ export interface PoolRow {
85
+ readonly id: string
86
+ readonly label: string
87
+ readonly source: "provider" | "estimate" | "unknown"
88
+ readonly sourceLabel: string
89
+ readonly monthlyUsd: number
90
+ readonly usedUsd: number
91
+ readonly remainingUsd: number
92
+ readonly usedPct: number
93
+ readonly weeklyPct?: number
94
+ readonly fiveHourPct?: number
95
+ readonly resetLabel?: string
96
+ readonly note: string
97
+ }
98
+
99
+ export interface ProviderRow {
100
+ readonly providerID: string
101
+ readonly label: string
102
+ readonly plan: string
103
+ /** `provider` and `estimate` are facts of different strength and are never merged. */
104
+ readonly source: "provider" | "estimate" | "unknown"
105
+ readonly sourceLabel: string
106
+ readonly quotaState?: string
107
+ readonly stale: boolean
108
+ readonly fetchedAt: number
109
+ /** The tightest window that is exhausted, if any. */
110
+ readonly exhausted: boolean
111
+ readonly exhaustedWindow?: string
112
+ /** 5h, 7d, monthly — always all three, with `reported` telling the truth. */
113
+ readonly windows: ReadonlyArray<QuotaWindowView>
114
+ readonly pools: ReadonlyArray<PoolRow>
115
+ readonly monthlyRemainingUsd?: number
116
+ readonly extraCreditsUsd?: number
117
+ readonly error?: string
118
+ readonly notes: ReadonlyArray<string>
119
+ /** The sentence that explains why a figure is a lower bound, if it is one. */
120
+ readonly caveat?: string
121
+ }
122
+
123
+ const sourceLabelOf = (source: "provider" | "estimate" | "unknown"): string => {
124
+ switch (source) {
125
+ case "provider":
126
+ return "verified by provider"
127
+ case "estimate":
128
+ return "estimated — lower bound"
129
+ default:
130
+ return "not reported"
131
+ }
132
+ }
133
+
134
+ const windowLabels: Record<WindowID, string> = {
135
+ fiveHour: "5h",
136
+ weekly: "7d",
137
+ monthly: "Monthly",
138
+ }
139
+
140
+ /** Human reset time. Absolute once it is far away; relative while it matters. */
141
+ export const formatReset = (at: number | undefined, now = Date.now()): string | undefined => {
142
+ if (at === undefined) return undefined
143
+ const delta = at - now
144
+ if (delta <= 0) return "resetting"
145
+ const minutes = Math.round(delta / 60_000)
146
+ if (minutes < 60) return `in ${minutes}m`
147
+ const hours = Math.round(minutes / 60)
148
+ if (hours < 36) return `in ${hours}h`
149
+ const days = Math.round(hours / 24)
150
+ return `in ${days}d`
151
+ }
152
+
153
+ const toWindow = (
154
+ id: WindowID,
155
+ window: { usedPct?: number; remainingPct?: number; usedUsd?: number; capUsd?: number; resetsAt?: number; exceeded?: boolean; source?: "provider" | "estimate" | "unknown" } | undefined,
156
+ now: number,
157
+ ): QuotaWindowView => {
158
+ const source = window?.source ?? "unknown"
159
+ const usedPct = window?.usedPct ?? 0
160
+ // A window is reported when the provider said anything about it. A cap with no
161
+ // source is an absent window, not an empty one.
162
+ const reported = source !== "unknown" && (window?.capUsd !== undefined || usedPct > 0)
163
+ return {
164
+ id,
165
+ label: windowLabels[id],
166
+ usedPct,
167
+ remainingPct: window?.remainingPct ?? Math.max(0, 100 - usedPct),
168
+ ...(window?.usedUsd !== undefined ? { usedUsd: window.usedUsd } : {}),
169
+ ...(window?.capUsd !== undefined ? { capUsd: window.capUsd } : {}),
170
+ ...(window?.resetsAt !== undefined ? { resetsAt: window.resetsAt } : {}),
171
+ ...(formatReset(window?.resetsAt, now) ? { resetLabel: formatReset(window?.resetsAt, now)! } : {}),
172
+ source,
173
+ sourceLabel: sourceLabelOf(source),
174
+ exceeded: Boolean(window?.exceeded),
175
+ reported,
176
+ lowerBound: source === "estimate",
177
+ }
178
+ }
179
+
180
+ export const toProviderRow = (snapshot: ProviderQuotaSnapshot, now = Date.now()): ProviderRow => {
181
+ const source: ProviderRow["source"] =
182
+ snapshot.monthly?.source === "provider"
183
+ ? "provider"
184
+ : snapshot.monthly?.source === "estimate"
185
+ ? "estimate"
186
+ : "unknown"
187
+ const windows = [
188
+ toWindow("fiveHour", snapshot.rolling, now),
189
+ toWindow("weekly", snapshot.weekly, now),
190
+ toWindow("monthly", snapshot.monthly, now),
191
+ ]
192
+ const exhaustedWindow = windows.find((window) => window.exceeded)
193
+ const pools: PoolRow[] = (snapshot.pools ?? []).map((pool) => ({
194
+ id: pool.id,
195
+ label: pool.label,
196
+ source: pool.source,
197
+ sourceLabel: sourceLabelOf(pool.source),
198
+ monthlyUsd: pool.monthlyUsd,
199
+ usedUsd: pool.usedUsd,
200
+ remainingUsd: pool.remainingUsd,
201
+ usedPct: pool.usedPct,
202
+ ...(pool.weeklyUsedPct !== undefined ? { weeklyPct: pool.weeklyUsedPct } : {}),
203
+ ...(pool.fiveHourUsedPct !== undefined ? { fiveHourPct: pool.fiveHourUsedPct } : {}),
204
+ ...(formatReset(pool.resetsAt, now) ? { resetLabel: formatReset(pool.resetsAt, now)! } : {}),
205
+ note: pool.note,
206
+ }))
207
+
208
+ return {
209
+ providerID: snapshot.providerID,
210
+ label: snapshot.label ?? snapshot.providerID,
211
+ plan: snapshot.plan ?? "",
212
+ source,
213
+ sourceLabel: sourceLabelOf(source),
214
+ ...(snapshot.quotaState ? { quotaState: snapshot.quotaState } : {}),
215
+ stale: Boolean(snapshot.stale),
216
+ fetchedAt: snapshot.fetchedAt ?? now,
217
+ exhausted: Boolean(exhaustedWindow),
218
+ ...(exhaustedWindow ? { exhaustedWindow: exhaustedWindow.label } : {}),
219
+ windows,
220
+ pools,
221
+ ...(snapshot.monthlyRemainingUsd !== undefined ? { monthlyRemainingUsd: snapshot.monthlyRemainingUsd } : {}),
222
+ ...(snapshot.extraCredits ? { extraCreditsUsd: snapshot.extraCredits.totalUsd } : {}),
223
+ ...(snapshot.error ? { error: snapshot.error } : {}),
224
+ notes: snapshot.notes ?? [],
225
+ ...(source === "estimate" ? { caveat: (snapshot.notes ?? [])[0] } : {}),
226
+ }
227
+ }
228
+
229
+ /** Money at the precision the router actually estimates in. */
230
+ export const formatUsd = (value: number): string => {
231
+ if (!Number.isFinite(value)) return "—"
232
+ if (value === 0) return "$0"
233
+ if (value < 0.01) return `$${value.toFixed(4)}`
234
+ if (value < 1) return `$${value.toFixed(3)}`
235
+ return `$${value.toFixed(2)}`
236
+ }
237
+
238
+ export const formatPercent = (value: number): string => (Number.isFinite(value) ? `${Math.round(value)}%` : "—")
239
+
240
+ /**
241
+ * A percentage as the panel should show it.
242
+ *
243
+ * `≥` is not decoration: it tells the reader the real figure is at least this,
244
+ * and may be far higher. Without it, an estimate reads as a measurement.
245
+ */
246
+ export const formatWindowUsed = (window: { usedPct: number; lowerBound: boolean; reported: boolean }): string => {
247
+ if (!window.reported) return "—"
248
+ const value = formatPercent(window.usedPct)
249
+ return window.lowerBound ? `≥${value}` : value
250
+ }
251
+
252
+ /**
253
+ * Explain the multipliers that changed a candidate's rank.
254
+ *
255
+ * These exist because a user who sees two prices and one winner will assume the
256
+ * cheaper one won. When it did not, the panel has to say which multiplier moved
257
+ * it, or the ranking looks arbitrary.
258
+ */
259
+ export const rankingAdjustments = (candidate: CandidateEvaluation): ReadonlyArray<string> => {
260
+ const notes: string[] = []
261
+ const credit = candidate.creditMultiplier
262
+ if (credit !== undefined && credit !== 1) notes.push(`${credit}x plan credits`)
263
+ const speed = candidate.speedFactor
264
+ if (speed !== undefined && speed !== 1) notes.push(`${speed}x measured speed`)
265
+ const marginal = candidate.marginalPriceFactor
266
+ if (marginal !== undefined && marginal !== 1) notes.push(`${marginal}x expiring allowance`)
267
+ if (candidate.switchPenaltyUsd > 0) notes.push(`${formatUsd(candidate.switchPenaltyUsd)} switch cost`)
268
+ return notes
269
+ }
270
+
271
+ /** `provider/model`, the same shape the engine uses for model identity. */
272
+ export const keyOf = (model: ModelKey): string => `${model.providerID}/${model.modelID}`
273
+
274
+ export const toCandidateRow = (candidate: CandidateEvaluation, chosen: string | undefined): CandidateRow => {
275
+ const key = keyOf(candidate.model)
276
+ return {
277
+ key,
278
+ name: candidate.name,
279
+ tier: candidate.tier,
280
+ verdict: key === chosen && candidate.eligible ? "chosen" : candidate.eligible ? "eligible" : "blocked",
281
+ reason: candidate.reason,
282
+ estimatedUsd: candidate.estimatedUsd,
283
+ effectiveCost: candidate.effectiveCost,
284
+ successProbability: candidate.successProbability,
285
+ adjustments: rankingAdjustments(candidate),
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Order for display: what won, then what could have, cheapest first, then what
291
+ * could not and why. A blocked model is never hidden — "why can't I use this"
292
+ * is the question a user asks most often.
293
+ */
294
+ const verdictRank: Record<Verdict, number> = { chosen: 0, eligible: 1, blocked: 2 }
295
+
296
+ export const orderCandidateRows = (rows: ReadonlyArray<CandidateRow>): ReadonlyArray<CandidateRow> =>
297
+ [...rows].sort((left, right) => {
298
+ const byVerdict = verdictRank[left.verdict] - verdictRank[right.verdict]
299
+ if (byVerdict !== 0) return byVerdict
300
+ if (left.verdict === "blocked") return left.key.localeCompare(right.key)
301
+ return left.effectiveCost - right.effectiveCost || left.key.localeCompare(right.key)
302
+ })
303
+
304
+ export const summariseDecision = (decision: RoutingDecision | undefined | null): DecisionView | undefined => {
305
+ if (!decision) return undefined
306
+ const chosenKey = decision.chosen ? `${decision.chosen.providerID}/${decision.chosen.id}` : undefined
307
+ const rows = orderCandidateRows(decision.candidates.map((candidate) => toCandidateRow(candidate, chosenKey)))
308
+ return {
309
+ id: decision.id,
310
+ chosen: chosenKey,
311
+ switched: decision.switched,
312
+ switchReason: decision.switchReason,
313
+ taskKind: decision.task?.kind ?? "unknown",
314
+ taskTier: decision.task?.tier ?? 0,
315
+ contextTokens: decision.contextTokens,
316
+ estimatedUsd: decision.estimatedUsd,
317
+ reasons: decision.reasons ?? [],
318
+ warnings: decision.warnings ?? [],
319
+ selfheal: decision.selfheal?.rejected
320
+ ? `rejected ${decision.selfheal.rejected} · ${
321
+ decision.selfheal.action === "noted" ? "noted for the next prompt" : decision.selfheal.action
322
+ }`
323
+ : undefined,
324
+ eligibleCount: rows.filter((row) => row.verdict !== "blocked").length,
325
+ blockedCount: rows.filter((row) => row.verdict === "blocked").length,
326
+ rows,
327
+ }
328
+ }
329
+
330
+ /** The one-line state shown above the decision, and in the footer slot. */
331
+ export const statusLine = (input: {
332
+ runtime?: string
333
+ mode?: string
334
+ current?: { providerID: string; id: string } | undefined
335
+ }): string => {
336
+ const parts = [input.runtime ?? "unknown", input.mode ?? "unknown"]
337
+ parts.push(input.current ? `${input.current.providerID}/${input.current.id}` : "no model yet")
338
+ return parts.join(" · ")
339
+ }
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // Providers and models (P3)
343
+ // ---------------------------------------------------------------------------
344
+
345
+ export interface ModelRowView {
346
+ readonly key: string
347
+ readonly providerID: string
348
+ readonly name: string
349
+ readonly tier: number
350
+ readonly intelligence: number
351
+ readonly design: number
352
+ readonly contextLabel: string
353
+ readonly costLabel: string
354
+ readonly vision: boolean
355
+ readonly state: "approved" | "excluded" | "not approved"
356
+ }
357
+
358
+ export interface ProviderGroupView {
359
+ readonly providerID: string
360
+ readonly label: string
361
+ readonly state: "active" | "not configured"
362
+ readonly models: ReadonlyArray<ModelRowView>
363
+ readonly approvedCount: number
364
+ readonly totalCount: number
365
+ /** Why this provider is not being used, when it is not. */
366
+ readonly note?: string
367
+ }
368
+
369
+ const contextLabel = (tokens: number): string => {
370
+ if (!Number.isFinite(tokens) || tokens <= 0) return "unknown"
371
+ // "1M" reads better than "1.0M"; a fraction still needs its decimal.
372
+ return tokens >= 1_000_000
373
+ ? `${Number((tokens / 1_000_000).toFixed(1))}M`
374
+ : `${Math.round(tokens / 1000)}k`
375
+ }
376
+
377
+ /**
378
+ * Providers and their models, as a user needs them to answer "why is that model
379
+ * not being used?".
380
+ *
381
+ * The distinction that matters is between a provider that is off and a model
382
+ * that is unapproved: one is a decision about an account, the other about a
383
+ * model. Collapsing them into "unavailable" is what makes people think a model
384
+ * is missing when it is simply not enabled.
385
+ */
386
+ export const providerGroups = (input: {
387
+ models: ReadonlyArray<{
388
+ key: string
389
+ providerID: string
390
+ name: string
391
+ tier: number
392
+ intelligence: number
393
+ design: number
394
+ vision: boolean
395
+ context: number
396
+ costInput: number
397
+ costOutput: number
398
+ approved: boolean
399
+ excluded: boolean
400
+ }>
401
+ quota: ReadonlyArray<{ providerID: string; label?: string }>
402
+ /** Provider ids the router will route to right now. */
403
+ activeProviders: ReadonlyArray<string>
404
+ }): ReadonlyArray<ProviderGroupView> => {
405
+ const byProvider = new Map<string, ModelRowView[]>()
406
+ for (const model of input.models) {
407
+ const state: ModelRowView["state"] = model.excluded
408
+ ? "excluded"
409
+ : model.approved
410
+ ? "approved"
411
+ : "not approved"
412
+ const row: ModelRowView = {
413
+ key: model.key,
414
+ providerID: model.providerID,
415
+ name: model.name,
416
+ tier: model.tier,
417
+ intelligence: model.intelligence,
418
+ design: model.design,
419
+ contextLabel: contextLabel(model.context),
420
+ costLabel: `${formatUsd(model.costInput)} / ${formatUsd(model.costOutput)} per 1M`,
421
+ vision: model.vision,
422
+ state,
423
+ }
424
+ const list = byProvider.get(model.providerID) ?? []
425
+ list.push(row)
426
+ byProvider.set(model.providerID, list)
427
+ }
428
+
429
+ const quotaLabel = new Map(input.quota.map((entry) => [entry.providerID, entry.label ?? entry.providerID]))
430
+
431
+ return [...byProvider.entries()]
432
+ .map(([providerID, models]) => {
433
+ const active = input.activeProviders.includes(providerID)
434
+ const approvedCount = models.filter((model) => model.state === "approved").length
435
+ return {
436
+ providerID,
437
+ label: quotaLabel.get(providerID) ?? providerID,
438
+ state: active ? ("active" as const) : ("not configured" as const),
439
+ // Approved first, then by tier and cost: the order a routing decision
440
+ // would consider them.
441
+ models: [...models].sort((left, right) => {
442
+ const rank = (row: ModelRowView) => (row.state === "approved" ? 0 : row.state === "excluded" ? 2 : 1)
443
+ return rank(left) - rank(right) || left.tier - right.tier || left.key.localeCompare(right.key)
444
+ }),
445
+ approvedCount,
446
+ totalCount: models.length,
447
+ ...(active
448
+ ? {}
449
+ : { note: "Not routing here: no credential found for this provider on this machine." }),
450
+ }
451
+ })
452
+ .sort((left, right) => {
453
+ if (left.state !== right.state) return left.state === "active" ? -1 : 1
454
+ return left.label.localeCompare(right.label)
455
+ })
456
+ }
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { Plugin } from "@opencode/plugin"
2
+ import { RouterController } from "./server/controller.js"
3
+ import { sharedCore } from "./server/shared-core.js"
4
+
5
+ const describe = (error: unknown) => (error instanceof Error ? error.message : String(error))
6
+
7
+ export default Plugin.define({
8
+ id: "opencode-smart-router",
9
+ async setup(ctx) {
10
+ const controller = new RouterController({ ctx, pluginOptions: ctx.options })
11
+ try {
12
+ return await controller.initialize()
13
+ } catch (error) {
14
+ // A broken location must not take down the host or the other bridges, and
15
+ // it must not take the owner's ability to send a prompt with it.
16
+ //
17
+ // This branch used to install a "minimal fail-closed hook" that rejected
18
+ // every prompt until the next reload. That turned a failed setup into a
19
+ // disabled editor, which is the worst possible response to a plugin that
20
+ // is already not working. Verified in scripts/isolated-e2e.sh: a plugin
21
+ // hook that throws fails the request outright, so a hook here is not a
22
+ // safety net.
23
+ //
24
+ // The correct outcome is that this location has no router. OpenCode then
25
+ // behaves exactly as it would with the plugin uninstalled, which is the
26
+ // guarantee the plugin owes its host. The failure stays visible in
27
+ // diagnostics and in the status RPC of any bridge that did start.
28
+ sharedCore.recordError(`setup ${ctx.location?.directory ?? ""}: ${describe(error)}`)
29
+ await controller.dispose().catch(() => undefined)
30
+ return () => undefined
31
+ }
32
+ },
33
+ })
package/src/rpc.ts ADDED
@@ -0,0 +1,204 @@
1
+ import { Rpc } from "@opencode/plugin/rpc"
2
+
3
+ const objectOutput = { type: "object", additionalProperties: true } as const
4
+ const emptyInput = { type: "object", properties: {}, additionalProperties: false } as const
5
+ const sessionInput = {
6
+ type: "object",
7
+ properties: { sessionID: { type: "string" } },
8
+ required: ["sessionID"],
9
+ additionalProperties: false,
10
+ } as const
11
+
12
+ const method = (input: any) => ({ input, output: objectOutput })
13
+
14
+ export const SmartRouterRpc = Rpc.define({
15
+ id: "smart-router",
16
+ methods: {
17
+ status: method({
18
+ type: "object",
19
+ properties: { sessionID: { type: "string" } },
20
+ additionalProperties: false,
21
+ }),
22
+ preview: method({
23
+ type: "object",
24
+ properties: {
25
+ sessionID: { type: "string" },
26
+ text: { type: "string" },
27
+ files: { type: "array", items: { type: "object", additionalProperties: true } },
28
+ },
29
+ required: ["sessionID", "text"],
30
+ additionalProperties: false,
31
+ }),
32
+ setMode: method({
33
+ type: "object",
34
+ properties: { mode: { type: "string", enum: ["efficiency", "balanced", "premium"] } },
35
+ required: ["mode"],
36
+ additionalProperties: false,
37
+ }),
38
+ setPaused: method({
39
+ type: "object",
40
+ properties: { paused: { type: "boolean" } },
41
+ required: ["paused"],
42
+ additionalProperties: false,
43
+ }),
44
+ /**
45
+ * Turn automatic routing on or off for one session.
46
+ *
47
+ * Not the same as disabling the plugin or pausing every session: the router
48
+ * stays loaded, keeps reporting quota and decisions, and simply stops
49
+ * changing the model. The user keeps whatever model they have.
50
+ */
51
+ setSessionAuto: method({
52
+ type: "object",
53
+ properties: { sessionID: { type: "string" }, auto: { type: "boolean" } },
54
+ required: ["sessionID", "auto"],
55
+ additionalProperties: false,
56
+ }),
57
+ setDryRun: method({
58
+ type: "object",
59
+ properties: { dryRun: { type: "boolean" } },
60
+ required: ["dryRun"],
61
+ additionalProperties: false,
62
+ }),
63
+ resumeAuto: method(sessionInput),
64
+ pin: method({
65
+ type: "object",
66
+ properties: {
67
+ sessionID: { type: "string" },
68
+ providerID: { type: "string" },
69
+ modelID: { type: "string" },
70
+ variant: { type: "string" },
71
+ },
72
+ required: ["sessionID", "providerID", "modelID"],
73
+ additionalProperties: false,
74
+ }),
75
+ approvePremium: method(sessionInput),
76
+ denyPremium: method(sessionInput),
77
+ excludeModel: method({
78
+ type: "object",
79
+ properties: {
80
+ providerID: { type: "string" },
81
+ modelID: { type: "string" },
82
+ excluded: { type: "boolean" },
83
+ },
84
+ required: ["providerID", "modelID", "excluded"],
85
+ additionalProperties: false,
86
+ }),
87
+ setPremiumBudget: method({
88
+ type: "object",
89
+ properties: { usd: { type: "number", minimum: 0, maximum: 100000 } },
90
+ required: ["usd"],
91
+ additionalProperties: false,
92
+ }),
93
+ setReserve: method({
94
+ type: "object",
95
+ properties: {
96
+ providerID: { type: "string" },
97
+ monthlyPct: { type: "number", minimum: 0, maximum: 90 },
98
+ weeklyPct: { type: "number", minimum: 0, maximum: 90 },
99
+ fiveHourPct: { type: "number", minimum: 0, maximum: 90 },
100
+ },
101
+ required: ["providerID"],
102
+ additionalProperties: false,
103
+ }),
104
+ /**
105
+ * The intelligence floor in force, and why.
106
+ *
107
+ * Calibration can only be trusted if its result is visible: a number with
108
+ * no explanation is indistinguishable from the hand-picked one it replaced.
109
+ * This previews without enabling anything.
110
+ */
111
+ floor: method(emptyInput),
112
+ setCalibrateFloor: method({
113
+ type: "object",
114
+ properties: { enabled: { type: "boolean" } },
115
+ required: ["enabled"],
116
+ additionalProperties: false,
117
+ }),
118
+ refreshQuota: method(emptyInput),
119
+ /**
120
+ * What this machine has, for the first-run screen.
121
+ *
122
+ * Onboarding must show evidence, not a questionnaire. The router already
123
+ * detects credentials and imports history at startup; this reports what it
124
+ * found so the first thing a new user sees is the truth about their own
125
+ * machine rather than a form asking them to type it in.
126
+ */
127
+ onboarding: method(emptyInput),
128
+ /** Mark the first run as seen, so it does not reappear. */
129
+ completeOnboarding: method(emptyInput),
130
+ /**
131
+ * Record what the user's account page showed, so the estimate reflects the
132
+ * whole account rather than only the traffic this router routed.
133
+ *
134
+ * Takes the percentage the page displays, not a dollar figure: asking a user
135
+ * to divide by a cap they may not know is where the arithmetic mistakes come
136
+ * from. The server converts it against the window the page was describing.
137
+ */
138
+ /**
139
+ * Record the account's balance page, pasted whole.
140
+ *
141
+ * One field, because that is the actual task: select the page, copy, paste.
142
+ * The server reads the plan, the percentage, which window it belongs to, the
143
+ * reset time, the billing period and the wallet out of the text. A form with
144
+ * four boxes asks the user to do that parsing by hand and get it right.
145
+ */
146
+ recordPlanPage: method({
147
+ type: "object",
148
+ properties: {
149
+ /** The pasted page text. */
150
+ page: { type: "string" },
151
+ /** Apply it without previewing. Defaults to false: the user confirms first. */
152
+ confirm: { type: "boolean" },
153
+ },
154
+ required: ["page"],
155
+ additionalProperties: false,
156
+ }),
157
+ report: method(emptyInput),
158
+ usage: method(emptyInput),
159
+ getConfig: method(emptyInput),
160
+ resetOverrides: method(emptyInput),
161
+ resetMetrics: method(emptyInput),
162
+ refreshIndicator: method(sessionInput),
163
+ diagnostics: method(emptyInput),
164
+ setTitleIndicator: method({
165
+ type: "object",
166
+ properties: { enabled: { type: "boolean" } },
167
+ required: ["enabled"],
168
+ additionalProperties: false,
169
+ }),
170
+ },
171
+ events: {
172
+ decision: {
173
+ schema: {
174
+ type: "object",
175
+ properties: {
176
+ decision: { type: "object", additionalProperties: true },
177
+ },
178
+ required: ["decision"],
179
+ additionalProperties: false,
180
+ },
181
+ },
182
+ status: {
183
+ schema: {
184
+ type: "object",
185
+ properties: {
186
+ sessionID: { type: "string" },
187
+ summary: { type: "object", additionalProperties: true },
188
+ },
189
+ required: ["summary"],
190
+ additionalProperties: false,
191
+ },
192
+ },
193
+ quota: {
194
+ schema: {
195
+ type: "object",
196
+ properties: {
197
+ providers: { type: "array", items: { type: "object", additionalProperties: true } },
198
+ },
199
+ required: ["providers"],
200
+ additionalProperties: false,
201
+ },
202
+ },
203
+ },
204
+ })