@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
package/dist/tui.js ADDED
@@ -0,0 +1,587 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropNames = Object.getOwnPropertyNames;
3
+ var __esm = (fn, res, err) => function __init() {
4
+ if (err) throw err[0];
5
+ try {
6
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
7
+ } catch (e) {
8
+ throw err = [e], e;
9
+ }
10
+ };
11
+ var __export = (target, all) => {
12
+ for (var name in all)
13
+ __defProp(target, name, { get: all[name], enumerable: true });
14
+ };
15
+
16
+ // src/gui/viewmodel.ts
17
+ var viewmodel_exports = {};
18
+ __export(viewmodel_exports, {
19
+ formatPercent: () => formatPercent,
20
+ formatReset: () => formatReset,
21
+ formatUsd: () => formatUsd,
22
+ formatWindowUsed: () => formatWindowUsed,
23
+ keyOf: () => keyOf,
24
+ orderCandidateRows: () => orderCandidateRows,
25
+ providerGroups: () => providerGroups,
26
+ rankingAdjustments: () => rankingAdjustments,
27
+ statusLine: () => statusLine,
28
+ summariseDecision: () => summariseDecision,
29
+ toCandidateRow: () => toCandidateRow,
30
+ toProviderRow: () => toProviderRow
31
+ });
32
+ var sourceLabelOf, windowLabels, formatReset, toWindow, toProviderRow, formatUsd, formatPercent, formatWindowUsed, rankingAdjustments, keyOf, toCandidateRow, verdictRank, orderCandidateRows, summariseDecision, statusLine, contextLabel, providerGroups;
33
+ var init_viewmodel = __esm({
34
+ "src/gui/viewmodel.ts"() {
35
+ "use strict";
36
+ sourceLabelOf = (source) => {
37
+ switch (source) {
38
+ case "provider":
39
+ return "verified by provider";
40
+ case "estimate":
41
+ return "estimated \u2014 lower bound";
42
+ default:
43
+ return "not reported";
44
+ }
45
+ };
46
+ windowLabels = {
47
+ fiveHour: "5h",
48
+ weekly: "7d",
49
+ monthly: "Monthly"
50
+ };
51
+ formatReset = (at, now = Date.now()) => {
52
+ if (at === void 0) return void 0;
53
+ const delta = at - now;
54
+ if (delta <= 0) return "resetting";
55
+ const minutes = Math.round(delta / 6e4);
56
+ if (minutes < 60) return `in ${minutes}m`;
57
+ const hours = Math.round(minutes / 60);
58
+ if (hours < 36) return `in ${hours}h`;
59
+ const days = Math.round(hours / 24);
60
+ return `in ${days}d`;
61
+ };
62
+ toWindow = (id, window, now) => {
63
+ const source = window?.source ?? "unknown";
64
+ const usedPct = window?.usedPct ?? 0;
65
+ const reported = source !== "unknown" && (window?.capUsd !== void 0 || usedPct > 0);
66
+ return {
67
+ id,
68
+ label: windowLabels[id],
69
+ usedPct,
70
+ remainingPct: window?.remainingPct ?? Math.max(0, 100 - usedPct),
71
+ ...window?.usedUsd !== void 0 ? { usedUsd: window.usedUsd } : {},
72
+ ...window?.capUsd !== void 0 ? { capUsd: window.capUsd } : {},
73
+ ...window?.resetsAt !== void 0 ? { resetsAt: window.resetsAt } : {},
74
+ ...formatReset(window?.resetsAt, now) ? { resetLabel: formatReset(window?.resetsAt, now) } : {},
75
+ source,
76
+ sourceLabel: sourceLabelOf(source),
77
+ exceeded: Boolean(window?.exceeded),
78
+ reported,
79
+ lowerBound: source === "estimate"
80
+ };
81
+ };
82
+ toProviderRow = (snapshot, now = Date.now()) => {
83
+ const source = snapshot.monthly?.source === "provider" ? "provider" : snapshot.monthly?.source === "estimate" ? "estimate" : "unknown";
84
+ const windows = [
85
+ toWindow("fiveHour", snapshot.rolling, now),
86
+ toWindow("weekly", snapshot.weekly, now),
87
+ toWindow("monthly", snapshot.monthly, now)
88
+ ];
89
+ const exhaustedWindow = windows.find((window) => window.exceeded);
90
+ const pools = (snapshot.pools ?? []).map((pool) => ({
91
+ id: pool.id,
92
+ label: pool.label,
93
+ source: pool.source,
94
+ sourceLabel: sourceLabelOf(pool.source),
95
+ monthlyUsd: pool.monthlyUsd,
96
+ usedUsd: pool.usedUsd,
97
+ remainingUsd: pool.remainingUsd,
98
+ usedPct: pool.usedPct,
99
+ ...pool.weeklyUsedPct !== void 0 ? { weeklyPct: pool.weeklyUsedPct } : {},
100
+ ...pool.fiveHourUsedPct !== void 0 ? { fiveHourPct: pool.fiveHourUsedPct } : {},
101
+ ...formatReset(pool.resetsAt, now) ? { resetLabel: formatReset(pool.resetsAt, now) } : {},
102
+ note: pool.note
103
+ }));
104
+ return {
105
+ providerID: snapshot.providerID,
106
+ label: snapshot.label ?? snapshot.providerID,
107
+ plan: snapshot.plan ?? "",
108
+ source,
109
+ sourceLabel: sourceLabelOf(source),
110
+ ...snapshot.quotaState ? { quotaState: snapshot.quotaState } : {},
111
+ stale: Boolean(snapshot.stale),
112
+ fetchedAt: snapshot.fetchedAt ?? now,
113
+ exhausted: Boolean(exhaustedWindow),
114
+ ...exhaustedWindow ? { exhaustedWindow: exhaustedWindow.label } : {},
115
+ windows,
116
+ pools,
117
+ ...snapshot.monthlyRemainingUsd !== void 0 ? { monthlyRemainingUsd: snapshot.monthlyRemainingUsd } : {},
118
+ ...snapshot.extraCredits ? { extraCreditsUsd: snapshot.extraCredits.totalUsd } : {},
119
+ ...snapshot.error ? { error: snapshot.error } : {},
120
+ notes: snapshot.notes ?? [],
121
+ ...source === "estimate" ? { caveat: (snapshot.notes ?? [])[0] } : {}
122
+ };
123
+ };
124
+ formatUsd = (value) => {
125
+ if (!Number.isFinite(value)) return "\u2014";
126
+ if (value === 0) return "$0";
127
+ if (value < 0.01) return `$${value.toFixed(4)}`;
128
+ if (value < 1) return `$${value.toFixed(3)}`;
129
+ return `$${value.toFixed(2)}`;
130
+ };
131
+ formatPercent = (value) => Number.isFinite(value) ? `${Math.round(value)}%` : "\u2014";
132
+ formatWindowUsed = (window) => {
133
+ if (!window.reported) return "\u2014";
134
+ const value = formatPercent(window.usedPct);
135
+ return window.lowerBound ? `\u2265${value}` : value;
136
+ };
137
+ rankingAdjustments = (candidate) => {
138
+ const notes = [];
139
+ const credit = candidate.creditMultiplier;
140
+ if (credit !== void 0 && credit !== 1) notes.push(`${credit}x plan credits`);
141
+ const speed = candidate.speedFactor;
142
+ if (speed !== void 0 && speed !== 1) notes.push(`${speed}x measured speed`);
143
+ const marginal = candidate.marginalPriceFactor;
144
+ if (marginal !== void 0 && marginal !== 1) notes.push(`${marginal}x expiring allowance`);
145
+ if (candidate.switchPenaltyUsd > 0) notes.push(`${formatUsd(candidate.switchPenaltyUsd)} switch cost`);
146
+ return notes;
147
+ };
148
+ keyOf = (model) => `${model.providerID}/${model.modelID}`;
149
+ toCandidateRow = (candidate, chosen) => {
150
+ const key = keyOf(candidate.model);
151
+ return {
152
+ key,
153
+ name: candidate.name,
154
+ tier: candidate.tier,
155
+ verdict: key === chosen && candidate.eligible ? "chosen" : candidate.eligible ? "eligible" : "blocked",
156
+ reason: candidate.reason,
157
+ estimatedUsd: candidate.estimatedUsd,
158
+ effectiveCost: candidate.effectiveCost,
159
+ successProbability: candidate.successProbability,
160
+ adjustments: rankingAdjustments(candidate)
161
+ };
162
+ };
163
+ verdictRank = { chosen: 0, eligible: 1, blocked: 2 };
164
+ orderCandidateRows = (rows) => [...rows].sort((left, right) => {
165
+ const byVerdict = verdictRank[left.verdict] - verdictRank[right.verdict];
166
+ if (byVerdict !== 0) return byVerdict;
167
+ if (left.verdict === "blocked") return left.key.localeCompare(right.key);
168
+ return left.effectiveCost - right.effectiveCost || left.key.localeCompare(right.key);
169
+ });
170
+ summariseDecision = (decision) => {
171
+ if (!decision) return void 0;
172
+ const chosenKey = decision.chosen ? `${decision.chosen.providerID}/${decision.chosen.id}` : void 0;
173
+ const rows = orderCandidateRows(decision.candidates.map((candidate) => toCandidateRow(candidate, chosenKey)));
174
+ return {
175
+ id: decision.id,
176
+ chosen: chosenKey,
177
+ switched: decision.switched,
178
+ switchReason: decision.switchReason,
179
+ taskKind: decision.task?.kind ?? "unknown",
180
+ taskTier: decision.task?.tier ?? 0,
181
+ contextTokens: decision.contextTokens,
182
+ estimatedUsd: decision.estimatedUsd,
183
+ reasons: decision.reasons ?? [],
184
+ warnings: decision.warnings ?? [],
185
+ selfheal: decision.selfheal?.rejected ? `rejected ${decision.selfheal.rejected} \xB7 ${decision.selfheal.action === "noted" ? "noted for the next prompt" : decision.selfheal.action}` : void 0,
186
+ eligibleCount: rows.filter((row) => row.verdict !== "blocked").length,
187
+ blockedCount: rows.filter((row) => row.verdict === "blocked").length,
188
+ rows
189
+ };
190
+ };
191
+ statusLine = (input) => {
192
+ const parts = [input.runtime ?? "unknown", input.mode ?? "unknown"];
193
+ parts.push(input.current ? `${input.current.providerID}/${input.current.id}` : "no model yet");
194
+ return parts.join(" \xB7 ");
195
+ };
196
+ contextLabel = (tokens) => {
197
+ if (!Number.isFinite(tokens) || tokens <= 0) return "unknown";
198
+ return tokens >= 1e6 ? `${Number((tokens / 1e6).toFixed(1))}M` : `${Math.round(tokens / 1e3)}k`;
199
+ };
200
+ providerGroups = (input) => {
201
+ const byProvider = /* @__PURE__ */ new Map();
202
+ for (const model of input.models) {
203
+ const state = model.excluded ? "excluded" : model.approved ? "approved" : "not approved";
204
+ const row = {
205
+ key: model.key,
206
+ providerID: model.providerID,
207
+ name: model.name,
208
+ tier: model.tier,
209
+ intelligence: model.intelligence,
210
+ design: model.design,
211
+ contextLabel: contextLabel(model.context),
212
+ costLabel: `${formatUsd(model.costInput)} / ${formatUsd(model.costOutput)} per 1M`,
213
+ vision: model.vision,
214
+ state
215
+ };
216
+ const list = byProvider.get(model.providerID) ?? [];
217
+ list.push(row);
218
+ byProvider.set(model.providerID, list);
219
+ }
220
+ const quotaLabel = new Map(input.quota.map((entry) => [entry.providerID, entry.label ?? entry.providerID]));
221
+ return [...byProvider.entries()].map(([providerID, models]) => {
222
+ const active = input.activeProviders.includes(providerID);
223
+ const approvedCount = models.filter((model) => model.state === "approved").length;
224
+ return {
225
+ providerID,
226
+ label: quotaLabel.get(providerID) ?? providerID,
227
+ state: active ? "active" : "not configured",
228
+ // Approved first, then by tier and cost: the order a routing decision
229
+ // would consider them.
230
+ models: [...models].sort((left, right) => {
231
+ const rank = (row) => row.state === "approved" ? 0 : row.state === "excluded" ? 2 : 1;
232
+ return rank(left) - rank(right) || left.tier - right.tier || left.key.localeCompare(right.key);
233
+ }),
234
+ approvedCount,
235
+ totalCount: models.length,
236
+ ...active ? {} : { note: "Not routing here: no credential found for this provider on this machine." }
237
+ };
238
+ }).sort((left, right) => {
239
+ if (left.state !== right.state) return left.state === "active" ? -1 : 1;
240
+ return left.label.localeCompare(right.label);
241
+ });
242
+ };
243
+ }
244
+ });
245
+
246
+ // src/tui/index.tsx
247
+ import { Plugin } from "@opencode/plugin/tui";
248
+
249
+ // src/rpc.ts
250
+ import { Rpc } from "@opencode/plugin/rpc";
251
+ var objectOutput = { type: "object", additionalProperties: true };
252
+ var emptyInput = { type: "object", properties: {}, additionalProperties: false };
253
+ var sessionInput = {
254
+ type: "object",
255
+ properties: { sessionID: { type: "string" } },
256
+ required: ["sessionID"],
257
+ additionalProperties: false
258
+ };
259
+ var method = (input) => ({ input, output: objectOutput });
260
+ var SmartRouterRpc = Rpc.define({
261
+ id: "smart-router",
262
+ methods: {
263
+ status: method({
264
+ type: "object",
265
+ properties: { sessionID: { type: "string" } },
266
+ additionalProperties: false
267
+ }),
268
+ preview: method({
269
+ type: "object",
270
+ properties: {
271
+ sessionID: { type: "string" },
272
+ text: { type: "string" },
273
+ files: { type: "array", items: { type: "object", additionalProperties: true } }
274
+ },
275
+ required: ["sessionID", "text"],
276
+ additionalProperties: false
277
+ }),
278
+ setMode: method({
279
+ type: "object",
280
+ properties: { mode: { type: "string", enum: ["efficiency", "balanced", "premium"] } },
281
+ required: ["mode"],
282
+ additionalProperties: false
283
+ }),
284
+ setPaused: method({
285
+ type: "object",
286
+ properties: { paused: { type: "boolean" } },
287
+ required: ["paused"],
288
+ additionalProperties: false
289
+ }),
290
+ /**
291
+ * Turn automatic routing on or off for one session.
292
+ *
293
+ * Not the same as disabling the plugin or pausing every session: the router
294
+ * stays loaded, keeps reporting quota and decisions, and simply stops
295
+ * changing the model. The user keeps whatever model they have.
296
+ */
297
+ setSessionAuto: method({
298
+ type: "object",
299
+ properties: { sessionID: { type: "string" }, auto: { type: "boolean" } },
300
+ required: ["sessionID", "auto"],
301
+ additionalProperties: false
302
+ }),
303
+ setDryRun: method({
304
+ type: "object",
305
+ properties: { dryRun: { type: "boolean" } },
306
+ required: ["dryRun"],
307
+ additionalProperties: false
308
+ }),
309
+ resumeAuto: method(sessionInput),
310
+ pin: method({
311
+ type: "object",
312
+ properties: {
313
+ sessionID: { type: "string" },
314
+ providerID: { type: "string" },
315
+ modelID: { type: "string" },
316
+ variant: { type: "string" }
317
+ },
318
+ required: ["sessionID", "providerID", "modelID"],
319
+ additionalProperties: false
320
+ }),
321
+ approvePremium: method(sessionInput),
322
+ denyPremium: method(sessionInput),
323
+ excludeModel: method({
324
+ type: "object",
325
+ properties: {
326
+ providerID: { type: "string" },
327
+ modelID: { type: "string" },
328
+ excluded: { type: "boolean" }
329
+ },
330
+ required: ["providerID", "modelID", "excluded"],
331
+ additionalProperties: false
332
+ }),
333
+ setPremiumBudget: method({
334
+ type: "object",
335
+ properties: { usd: { type: "number", minimum: 0, maximum: 1e5 } },
336
+ required: ["usd"],
337
+ additionalProperties: false
338
+ }),
339
+ setReserve: method({
340
+ type: "object",
341
+ properties: {
342
+ providerID: { type: "string" },
343
+ monthlyPct: { type: "number", minimum: 0, maximum: 90 },
344
+ weeklyPct: { type: "number", minimum: 0, maximum: 90 },
345
+ fiveHourPct: { type: "number", minimum: 0, maximum: 90 }
346
+ },
347
+ required: ["providerID"],
348
+ additionalProperties: false
349
+ }),
350
+ /**
351
+ * The intelligence floor in force, and why.
352
+ *
353
+ * Calibration can only be trusted if its result is visible: a number with
354
+ * no explanation is indistinguishable from the hand-picked one it replaced.
355
+ * This previews without enabling anything.
356
+ */
357
+ floor: method(emptyInput),
358
+ setCalibrateFloor: method({
359
+ type: "object",
360
+ properties: { enabled: { type: "boolean" } },
361
+ required: ["enabled"],
362
+ additionalProperties: false
363
+ }),
364
+ refreshQuota: method(emptyInput),
365
+ /**
366
+ * What this machine has, for the first-run screen.
367
+ *
368
+ * Onboarding must show evidence, not a questionnaire. The router already
369
+ * detects credentials and imports history at startup; this reports what it
370
+ * found so the first thing a new user sees is the truth about their own
371
+ * machine rather than a form asking them to type it in.
372
+ */
373
+ onboarding: method(emptyInput),
374
+ /** Mark the first run as seen, so it does not reappear. */
375
+ completeOnboarding: method(emptyInput),
376
+ /**
377
+ * Record what the user's account page showed, so the estimate reflects the
378
+ * whole account rather than only the traffic this router routed.
379
+ *
380
+ * Takes the percentage the page displays, not a dollar figure: asking a user
381
+ * to divide by a cap they may not know is where the arithmetic mistakes come
382
+ * from. The server converts it against the window the page was describing.
383
+ */
384
+ /**
385
+ * Record the account's balance page, pasted whole.
386
+ *
387
+ * One field, because that is the actual task: select the page, copy, paste.
388
+ * The server reads the plan, the percentage, which window it belongs to, the
389
+ * reset time, the billing period and the wallet out of the text. A form with
390
+ * four boxes asks the user to do that parsing by hand and get it right.
391
+ */
392
+ recordPlanPage: method({
393
+ type: "object",
394
+ properties: {
395
+ /** The pasted page text. */
396
+ page: { type: "string" },
397
+ /** Apply it without previewing. Defaults to false: the user confirms first. */
398
+ confirm: { type: "boolean" }
399
+ },
400
+ required: ["page"],
401
+ additionalProperties: false
402
+ }),
403
+ report: method(emptyInput),
404
+ usage: method(emptyInput),
405
+ getConfig: method(emptyInput),
406
+ resetOverrides: method(emptyInput),
407
+ resetMetrics: method(emptyInput),
408
+ refreshIndicator: method(sessionInput),
409
+ diagnostics: method(emptyInput),
410
+ setTitleIndicator: method({
411
+ type: "object",
412
+ properties: { enabled: { type: "boolean" } },
413
+ required: ["enabled"],
414
+ additionalProperties: false
415
+ })
416
+ },
417
+ events: {
418
+ decision: {
419
+ schema: {
420
+ type: "object",
421
+ properties: {
422
+ decision: { type: "object", additionalProperties: true }
423
+ },
424
+ required: ["decision"],
425
+ additionalProperties: false
426
+ }
427
+ },
428
+ status: {
429
+ schema: {
430
+ type: "object",
431
+ properties: {
432
+ sessionID: { type: "string" },
433
+ summary: { type: "object", additionalProperties: true }
434
+ },
435
+ required: ["summary"],
436
+ additionalProperties: false
437
+ }
438
+ },
439
+ quota: {
440
+ schema: {
441
+ type: "object",
442
+ properties: {
443
+ providers: { type: "array", items: { type: "object", additionalProperties: true } }
444
+ },
445
+ required: ["providers"],
446
+ additionalProperties: false
447
+ }
448
+ }
449
+ }
450
+ });
451
+
452
+ // src/tui/index.tsx
453
+ var tick = (ok) => ok ? "\u2713" : "\xB7";
454
+ var formatDecision = (status, view) => {
455
+ if (!status) return "Smart Router: no response from the router.";
456
+ const head = `${status.runtime ?? "unknown"} \xB7 ${status.mode ?? "unknown"} \xB7 ${status.current ? `${status.current.providerID}/${status.current.id}` : "no model yet"}`;
457
+ if (!view) return `${head}
458
+
459
+ No decision yet in this session.`;
460
+ const lines = [head, "", `Chosen ${view.chosen ?? "nothing eligible"}`];
461
+ lines.push(
462
+ ` ${view.taskKind} \xB7 tier ${view.taskTier} \xB7 ${Math.round(view.contextTokens / 1e3)}k context`
463
+ );
464
+ if (view.reasons.length > 0) {
465
+ lines.push("", "Why");
466
+ for (const reason of view.reasons.slice(0, 5)) lines.push(` \u2022 ${reason}`);
467
+ }
468
+ if (view.rows.length > 0) {
469
+ lines.push("", `Candidates (${view.eligibleCount} eligible, ${view.blockedCount} blocked)`);
470
+ for (const row of view.rows.slice(0, 10)) {
471
+ lines.push(` ${tick(row.verdict !== "blocked")} ${row.key}`);
472
+ lines.push(` ${row.reason}`);
473
+ }
474
+ }
475
+ if (view.warnings.length > 0) {
476
+ lines.push("", "Warnings");
477
+ for (const warning of view.warnings.slice(0, 3)) lines.push(` ! ${warning}`);
478
+ }
479
+ return lines.join("\n");
480
+ };
481
+ var formatProviders = (providers) => {
482
+ if (providers.length === 0) return "No providers reported.";
483
+ const lines = [];
484
+ for (const provider of providers) {
485
+ const windows = provider.windows.filter((window) => window.reported).map((window) => `${window.label} ${window.lowerBound ? "\u2265" : ""}${window.usedPct}%`);
486
+ lines.push(`${provider.label}${provider.exhausted ? " (exhausted)" : ""}`);
487
+ lines.push(` ${windows.length > 0 ? windows.join(" \xB7 ") : "no windows reported"} \xB7 ${provider.sourceLabel}`);
488
+ if (provider.caveat) lines.push(` ! ${provider.caveat}`);
489
+ }
490
+ return lines.join("\n");
491
+ };
492
+ var index_default = Plugin.define({
493
+ /**
494
+ * Deliberately NOT nested under the server part's id.
495
+ *
496
+ * The GUI docs are explicit: if a plugin's id has another id nested under it,
497
+ * or nests under one, its GUI does not load. The TUI previously used
498
+ * "opencode-smart-router.tui", which nests under the server part's
499
+ * "opencode-smart-router" — so adding a TUI silently disabled the desktop
500
+ * panel, with no error beyond a generic request failure.
501
+ *
502
+ * A sibling id avoids the namespace collision entirely.
503
+ */
504
+ id: "opencode-smart-router-tui",
505
+ setup(context) {
506
+ const stop = context.data.on("session.execution.succeeded", () => {
507
+ void refresh();
508
+ });
509
+ const refresh = async () => {
510
+ try {
511
+ const result = await context.client.rpc(SmartRouterRpc).status({});
512
+ setFooter(`${result?.runtime ?? "?"} \xB7 ${result?.mode ?? "?"}`);
513
+ } catch {
514
+ setFooter("router unreachable");
515
+ }
516
+ };
517
+ let setFooter = (_text) => {
518
+ };
519
+ context.ui.slot({
520
+ append: "prompt.footer.status",
521
+ render: () => {
522
+ const [text, set] = createTerminalSignal("router");
523
+ setFooter = set;
524
+ void refresh();
525
+ return /* @__PURE__ */ React.createElement("text", null, text());
526
+ }
527
+ });
528
+ context.keymap.layer(() => ({
529
+ mode: "global",
530
+ commands: [
531
+ {
532
+ id: "router.decision",
533
+ title: "Router: why this model",
534
+ slash: { name: "router-why" },
535
+ palette: true,
536
+ run: async () => {
537
+ try {
538
+ const result = await context.client.rpc(SmartRouterRpc).status({});
539
+ const { summariseDecision: summariseDecision2, toProviderRow: toProviderRow2 } = await Promise.resolve().then(() => (init_viewmodel(), viewmodel_exports));
540
+ const body = formatDecision(result, summariseDecision2(result?.lastDecision));
541
+ await context.ui.dialog.alert({ title: "Smart Router", message: body });
542
+ } catch (cause) {
543
+ await context.ui.dialog.alert({
544
+ title: "Smart Router",
545
+ message: `Could not read router status: ${cause instanceof Error ? cause.message : String(cause)}`
546
+ });
547
+ }
548
+ }
549
+ },
550
+ {
551
+ id: "router.providers",
552
+ title: "Router: providers and allowances",
553
+ slash: { name: "router-quota" },
554
+ palette: true,
555
+ run: async () => {
556
+ try {
557
+ const result = await context.client.rpc(SmartRouterRpc).status({});
558
+ const { toProviderRow: toProviderRow2 } = await Promise.resolve().then(() => (init_viewmodel(), viewmodel_exports));
559
+ const rows = (result?.quota ?? []).map((snapshot) => toProviderRow2(snapshot));
560
+ await context.ui.dialog.alert({ title: "Providers", message: formatProviders(rows) });
561
+ } catch (cause) {
562
+ await context.ui.dialog.alert({
563
+ title: "Providers",
564
+ message: `Could not read provider status: ${cause instanceof Error ? cause.message : String(cause)}`
565
+ });
566
+ }
567
+ }
568
+ }
569
+ ],
570
+ bindings: ["router.decision", "router.providers"]
571
+ }));
572
+ return () => stop();
573
+ }
574
+ });
575
+ function createTerminalSignal(initial) {
576
+ let value = initial;
577
+ const listeners = /* @__PURE__ */ new Set();
578
+ return [() => value, (next) => {
579
+ value = next;
580
+ for (const listener of listeners) listener(next);
581
+ }];
582
+ }
583
+ export {
584
+ index_default as default,
585
+ formatDecision,
586
+ formatProviders
587
+ };
@@ -0,0 +1,104 @@
1
+ # Architecture
2
+
3
+ ## Runtime path
4
+
5
+ ```text
6
+ OpenCode prompt admission
7
+ → session.hook("prompt")
8
+ → load session model and context tokens
9
+ → local classifier
10
+ → hard-requirement eligibility
11
+ → provider and per-model quota snapshots
12
+ → expected-cost ranking with cache-loss and hysteresis
13
+ → session.switchModel(...) when a switch is justified
14
+ → admission completes
15
+ → request dispatch
16
+ ```
17
+
18
+ The plugin does not modify the prompt text, attachments, tools, or agent. It never injects a classification prompt and never calls a model to decide routing.
19
+
20
+ ## Modules
21
+
22
+ | Module | Responsibility |
23
+ |---|---|
24
+ | `src/core/models/seed.ts` | Curated model intelligence, design, tier, privacy, and allowance metadata |
25
+ | `src/core/models/registry.ts` | Merges live OpenCode metadata, approval state, exclusions, and tier overrides |
26
+ | `src/core/classify/classifier.ts` | Deterministic task tier, kind, vision, design, scope, and constraints |
27
+ | `src/core/policy/eligibility.ts` | Hard requirements, privacy, premium policy, context fit, quota blocks |
28
+ | `src/core/quota/ledger.ts` | Bounded local usage ledger, rolling windows, per-model estimates, premium spend |
29
+ | `src/core/quota/commandcode.ts` | Read-only Command Code alpha billing adapter |
30
+ | `src/core/quota/opencode-go.ts` | Read-only Go usage adapter |
31
+ | `src/core/quota/credentials.ts` | Owner-only credential discovery; never logs or persists keys |
32
+ | `src/core/quota/service.ts` | Caching, polling, stale detection, per-provider backoff |
33
+ | `src/core/routing/engine.ts` | Cost estimation, success probability, quota pressure, affinity, decision record |
34
+ | `src/core/failures.ts` | Failure classification and bounded escalation |
35
+ | `src/core/config.ts` | Defaults, validation, global/project precedence, project guardrails |
36
+ | `src/server/shared-core.ts` | One process-wide core: ledger, quota service, persistence, event stream, session ownership, catalog cache |
37
+ | `src/server/controller.ts` | Per-location bridge: hooks, RPC, commands, title queue, session-local state |
38
+ | `src/index.ts` | Plugin definition; creates one bridge per loaded location |
39
+
40
+ ## Prompt timing
41
+
42
+ The V2 prompt hook runs once during admission, before attachment and skill resolution and before the durable inbox admission. The plugin awaits `ctx.session.switchModel` inside that hook. Integration tests and live desktop tests confirmed that the request-context hook observes the new model for the same prompt.
43
+
44
+ The router skips switching when:
45
+
46
+ - routing is paused or disabled;
47
+ - the session is manually overridden or pinned;
48
+ - another routing decision is already in flight for the same session;
49
+ - the session is busy in an active model loop;
50
+ - no eligible route exists.
51
+
52
+ It never switches in the middle of a response.
53
+
54
+ ## Session state and affinity
55
+
56
+ Each session tracks the last model, last routed model, context size, cache-read share, step count, failures, retry count, manual override, premium one-shot, and pause state.
57
+
58
+ The engine prices a model switch as a cache loss. It keeps the current model when the expected savings are below the configured hysteresis. Above 300K context tokens the hysteresis is wider. The router never truncates or rewrites the conversation to make a route cheaper.
59
+
60
+ ## Quota model
61
+
62
+ Two layers are kept separate:
63
+
64
+ 1. **Provider-reported plan state**: Go percentages, Command Code remaining credits, windows, resets, plan, and billing period.
65
+ 2. **Local per-model ledger**: observed step costs and tokens, used where the provider has no per-model breakdown.
66
+
67
+ Provider values override the ledger for plan-level pressure. Local values supply per-model estimates. Every displayed value carries its source (`provider`, `estimate`, or `unknown`). Polling is read-only and spends no inference allowance.
68
+
69
+ The ledger keeps 2,000 *raw* usage events for per-request detail, but nothing is discarded when that cap is exceeded. Events older than a 48-hour horizon, or pushed out by the cap, are folded into hourly `UsageBucket` aggregates keyed by provider, model and billing period, retained for 400 days. Aggregation reads both sources, so a heavy week stays fully accounted at a few hundred rows instead of losing its own beginning.
70
+
71
+ History is also preserved across an upgrade: hydrating a ledger written before aggregates existed folds the overflow into buckets rather than applying the old truncation.
72
+
73
+ Decisions (120) and sessions (250) remain bounded, and writes are debounced and JSON-sanitized.
74
+
75
+ Plan definitions live in `src/core/plans/catalog.ts` as data, not as branches in routing or quota code. A plan is a set of credit pools plus the window rule for each pool, so supporting a different user's tier requires no source change, and a provider's plan strings never appear in the routing or policy layers.
76
+
77
+ ## Concurrency
78
+
79
+ - One shared core is active per server process; each loaded location registers a lightweight bridge.
80
+ - A reload disposes only that location’s bridge. The core persists across reloads, stops its timers after a grace period, and revives on the next setup.
81
+ - Prompt routing is serialized per session. Concurrent submissions for the same session do not double-switch.
82
+ - Different sessions can route concurrently.
83
+ - The ledger is updated in one event loop; persistence is debounced. There is no cross-process writer.
84
+ - Provider probes are single-flight per provider and back off independently.
85
+
86
+ ## Failure handling
87
+
88
+ - Context overflow → no escalation; recommend compaction or a handoff session.
89
+ - Network, rate limit, provider outage → retry/failover, never premium escalation.
90
+ - Auth → terminal retry decision, cooldown, no model escalation.
91
+ - Tool or command failure → no model escalation.
92
+ - Invalid image → no text-only fallback; require a verified vision route.
93
+ - Capability failure → one bounded escalation opportunity, then stop.
94
+ - Partial generation or side effects → never automatically replayed; the next prompt is routed with the failure history.
95
+
96
+ ## Security
97
+
98
+ - No API keys or tokens are logged, persisted in router state, or included in RPC output.
99
+ - Command Code keys are read only from owner-only files or the connected integration and sent only to `https://api.commandcode.ai`.
100
+ - Go OAuth tokens are used only for `https://opencode.ai/zen/go/v1/usage`.
101
+ - Training-enabled and free promotional models are blocked by default.
102
+ - Project config files cannot loosen global privacy, extra-credit, or premium policy.
103
+ - The router does not read repository files to classify a prompt; it uses prompt structure and attachments only.
104
+ - Prompt text is not stored by default. Decision records contain signals and reasons, not the prompt.