@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,259 @@
1
+ /**
2
+ * Calibrated routing threshold.
3
+ *
4
+ * The intelligence floor used to be a hand-picked constant (39.5). That made it
5
+ * impossible to answer the only question that matters about it: why that number
6
+ * and not another one. It also silently sat *below* the cheapest eligible
7
+ * model, so changing it did nothing at all.
8
+ *
9
+ * This module derives the threshold from the owner's own recorded traffic
10
+ * instead. It follows the convention RouteLLM established for the same problem:
11
+ * pick the model that minimizes cost subject to a measured quality target,
12
+ * rather than by intuition.
13
+ *
14
+ * Crucially the target is a *cost* target, not a quality one. The router's job
15
+ * is to keep spending at the same level it has actually been spending while
16
+ * preferring capable models, not to decide how good the work should be. Quality
17
+ * targets expressed as "95% of a stronger model" need preference data over model
18
+ * pairs, which is expensive and which this owner does not have.
19
+ *
20
+ * When there is not enough recorded traffic, this returns undefined and the
21
+ * router keeps its configured floor. A calibration built on four requests would
22
+ * be a fabrication dressed up as evidence.
23
+ */
24
+
25
+ import type { ModelRecord, RouterSettings } from "../types.js"
26
+ /**
27
+ * One row of recorded spend per model.
28
+ *
29
+ * Mirrors the shape `LedgerStore.usageByModel` returns. Declared here rather
30
+ * than imported so calibration depends on the data it consumes, not on the
31
+ * ledger implementation that happens to produce it.
32
+ */
33
+ export interface ModelUsageRow {
34
+ readonly providerID: string
35
+ readonly modelID: string
36
+ readonly requests: number
37
+ readonly usd: number
38
+ }
39
+
40
+ /**
41
+ * How much recorded spend a model needs before its cost figure is trusted.
42
+ *
43
+ * Below this the per-model cost is dominated by whichever single request
44
+ * happened to be long, so including it would let one unlucky session set the
45
+ * baseline for everything after it.
46
+ */
47
+ export const MIN_REQUESTS_FOR_COST_EVIDENCE = 5
48
+
49
+ /**
50
+ * The traffic window used to calibrate.
51
+ *
52
+ * Two weeks is long enough to cover a normal working rhythm, including the
53
+ * occasional expensive day, and short enough that a price change or a new model
54
+ * is reflected rather than averaged away for months.
55
+ */
56
+ export const CALIBRATION_WINDOW_MS = 14 * 24 * 60 * 60 * 1000
57
+
58
+ /**
59
+ * How much above the measured average cost a model may sit and still be chosen.
60
+ *
61
+ * A small allowance matters because the baseline includes sessions whose model
62
+ * was badly chosen. Clamping to exactly the average would entrench those
63
+ * choices; a modest margin lets a slightly pricier but more capable model win
64
+ * when the difference is immaterial.
65
+ */
66
+ export const CALIBRATION_TOLERANCE = 1.05
67
+
68
+ /**
69
+ * A hard ceiling on how far calibration may move the threshold.
70
+ *
71
+ * The measured baseline can drift low if recent traffic happened to be trivial.
72
+ * Without a bound, one cheap afternoon could lower the effective quality gate
73
+ * far enough to send substantive work to a model that cannot do it. This keeps
74
+ * a bad baseline from becoming a routable decision: at most this many points of
75
+ * intelligence may be given up relative to the configured floor.
76
+ *
77
+ * The bound is asymmetric on purpose. Calibration may never *raise* the
78
+ * threshold above the configured floor, because raising it would silently
79
+ * exclude models the owner already approved and would make the router stricter
80
+ * than its own settings claim.
81
+ */
82
+ export const MAX_CALIBRATION_DROP = 12
83
+
84
+ export interface CalibrationInput {
85
+ /** Every approved model the router may choose, with its intelligence score. */
86
+ readonly models: ReadonlyArray<ModelRecord>
87
+ /** Recorded per-model spend for the calibration window. */
88
+ readonly usage: ReadonlyArray<ModelUsageRow>
89
+ /** The configured floor, used as the ceiling and as the evidence-free answer. */
90
+ readonly configuredFloor: number
91
+ readonly settings: RouterSettings
92
+ /** Injected for determinism in tests. */
93
+ readonly now?: number
94
+ }
95
+
96
+ export interface CalibrationResult {
97
+ /** The threshold routing should actually use. */
98
+ readonly floor: number
99
+ /**
100
+ * Whether the value was derived from recorded traffic.
101
+ *
102
+ * False means the configured floor was returned because there was not enough
103
+ * evidence. Callers surface this, so a user can tell the difference between
104
+ * "calibrated" and "not yet calibrated" instead of seeing one number and
105
+ * assuming it was measured.
106
+ */
107
+ readonly calibrated: boolean
108
+ /** Average cost per request across the window, in USD. Null when unmeasured. */
109
+ readonly baselineUsdPerRequest: number | null
110
+ /** How the threshold was arrived at, in one line, for the status output. */
111
+ readonly reason: string
112
+ /** Cost of the model the threshold admits as the cheapest capable option. */
113
+ readonly admittedUsdPerRequest: number | null
114
+ }
115
+
116
+ /**
117
+ * The configured floor if the owner pinned it, otherwise a calibrated value.
118
+ *
119
+ * An explicit `intelligenceFloor` in settings always wins. Calibration is for
120
+ * the owner who asked not to maintain that number by hand, not a way to
121
+ * override someone who set it deliberately.
122
+ */
123
+ export const resolveIntelligenceFloor = (
124
+ settings: RouterSettings,
125
+ input: Omit<CalibrationInput, "settings" | "configuredFloor">,
126
+ ): CalibrationResult => {
127
+ const configured = settings.routing.intelligenceFloor
128
+ const off = offlineFloorBypassActive(settings)
129
+ const base: CalibrationResult = {
130
+ floor: configured,
131
+ calibrated: false,
132
+ baselineUsdPerRequest: null,
133
+ reason: "using the configured floor",
134
+ admittedUsdPerRequest: null,
135
+ }
136
+ if (settings.routing.calibrateFloor !== true) return base
137
+ const result = calibrateFloor({ ...input, settings, configuredFloor: configured })
138
+ // Offline mode has its own allow-list and its own exemption from the floor.
139
+ // Calibration is an online-cost concern, so it must not change offline
140
+ // behavior in either direction.
141
+ return off ? base : result
142
+ }
143
+
144
+ const offlineFloorBypassActive = (settings: RouterSettings): boolean => settings.offline?.enabled === true
145
+
146
+ /**
147
+ * Derive a floor from recorded spend.
148
+ *
149
+ * The algorithm is deliberately small and auditable:
150
+ *
151
+ * 1. Total recorded spend and requests across the window give an average cost
152
+ * per request. That average *is* the budget: it is what the owner already
153
+ * spends, so honoring it is not a quality trade-off, it is a consistency
154
+ * requirement.
155
+ * 2. Among approved, non-escalation models that clear the hard context gate,
156
+ * find the cheapest one whose cost per request does not exceed the average
157
+ * by more than the tolerance.
158
+ * 3. That model's intelligence score becomes the floor, bounded so it can only
159
+ * move down from the configured value and never by more than
160
+ * MAX_CALIBRATION_DROP.
161
+ *
162
+ * The floor is then a statement of fact about the catalog: "this is the least
163
+ * capable model I am willing to run, and it costs no more than I already spend."
164
+ */
165
+ export const calibrateFloor = (input: CalibrationInput): CalibrationResult => {
166
+ const configured = input.configuredFloor
167
+ const usage = input.usage ?? []
168
+
169
+ let totalUsd = 0
170
+ let totalRequests = 0
171
+ for (const row of usage) {
172
+ if (!Number.isFinite(row.usd) || row.usd < 0) continue
173
+ if (!Number.isFinite(row.requests) || row.requests <= 0) continue
174
+ totalUsd += row.usd
175
+ totalRequests += row.requests
176
+ }
177
+
178
+ if (totalRequests < MIN_REQUESTS_FOR_COST_EVIDENCE) {
179
+ return {
180
+ floor: configured,
181
+ calibrated: false,
182
+ baselineUsdPerRequest: null,
183
+ reason: `not enough recorded traffic to calibrate (${totalRequests} of ${MIN_REQUESTS_FOR_COST_EVIDENCE} requests)`,
184
+ admittedUsdPerRequest: null,
185
+ }
186
+ }
187
+
188
+ const baseline = totalUsd / totalRequests
189
+ if (!Number.isFinite(baseline) || baseline <= 0) {
190
+ return {
191
+ floor: configured,
192
+ calibrated: false,
193
+ baselineUsdPerRequest: null,
194
+ reason: "recorded traffic has no measurable cost, so there is nothing to calibrate against",
195
+ admittedUsdPerRequest: null,
196
+ }
197
+ }
198
+
199
+ const ceiling = baseline * CALIBRATION_TOLERANCE
200
+ const perRequest = perRequestCosts(usage)
201
+
202
+ // Only models that actually served traffic have a measured cost. A model with
203
+ // no recorded requests has no evidence about *this owner's* workload, and
204
+ // substituting a list price would calibrate against the vendor's assumptions
205
+ // rather than the owner's usage.
206
+ const candidates = input.models
207
+ .filter((model) => !model.escalationOnly && !model.premium)
208
+ .filter((model) => model.intelligence > 0)
209
+ .map((model) => {
210
+ const measured = perRequest.get(`${model.providerID}/${model.modelID}`)
211
+ return measured === undefined ? undefined : { model, cost: measured }
212
+ })
213
+ .filter((entry): entry is { model: ModelRecord; cost: number } => entry !== undefined)
214
+ .filter((entry) => entry.cost <= ceiling)
215
+ .sort((a, b) => b.model.intelligence - a.model.intelligence)
216
+
217
+ if (candidates.length === 0) {
218
+ return {
219
+ floor: configured,
220
+ calibrated: false,
221
+ baselineUsdPerRequest: baseline,
222
+ reason:
223
+ "every model with recorded traffic costs more than the measured baseline, so no threshold could be met",
224
+ admittedUsdPerRequest: null,
225
+ }
226
+ }
227
+
228
+ // The most capable model that fits the budget is the one worth naming: it is
229
+ // what the threshold exists to admit.
230
+ const best = candidates[0]!
231
+ const bounded = Math.min(configured, Math.max(best.model.intelligence, configured - MAX_CALIBRATION_DROP))
232
+
233
+ return {
234
+ floor: bounded,
235
+ calibrated: true,
236
+ baselineUsdPerRequest: baseline,
237
+ reason:
238
+ `${best.model.providerID}/${best.model.modelID} costs $${best.cost.toFixed(4)} per request ` +
239
+ `against a measured baseline of $${baseline.toFixed(4)}`,
240
+ admittedUsdPerRequest: best.cost,
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Cost per request for each model that has recorded traffic.
246
+ *
247
+ * Requests rather than tokens, because a long cheap request and a short
248
+ * expensive one both occupy a turn of the owner's attention, and the budget
249
+ * being respected is spend for work done.
250
+ */
251
+ const perRequestCosts = (usage: ReadonlyArray<ModelUsageRow>): Map<string, number> => {
252
+ const costs = new Map<string, number>()
253
+ for (const row of usage) {
254
+ if (!Number.isFinite(row.usd) || row.usd < 0) continue
255
+ if (!Number.isFinite(row.requests) || row.requests <= 0) continue
256
+ costs.set(`${row.providerID}/${row.modelID}`, row.usd / row.requests)
257
+ }
258
+ return costs
259
+ }