@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,390 @@
1
+ import { parseResetIn } from "./config-writer.js"
2
+
3
+ /**
4
+ * Read any pasted OpenDesign console page and decide what it is.
5
+ *
6
+ * One normalisation pass, then a set of matchers run in confidence order. A
7
+ * user copies whatever page they happen to be on — Plan & balance, Usage,
8
+ * Billing — and the router works out which it got. Asking someone to choose the
9
+ * right page first is a step that exists only because the software was not
10
+ * paying attention.
11
+ *
12
+ * The two readings are different in kind and are kept apart deliberately:
13
+ *
14
+ * - **allowance** (Plan & balance): a percentage of a window. Cheap to
15
+ * produce, and the starting point for every estimate.
16
+ * - **usage** (Usage page): per-model tokens and, when the page shows it,
17
+ * credits or dollars actually charged.
18
+ *
19
+ * Neither is trusted over the other. A usage reading that agrees with the
20
+ * current estimate confirms it; one that disagrees is evidence the cost model is
21
+ * wrong, and that is worth knowing rather than averaging away.
22
+ */
23
+
24
+ export interface AllowanceReading {
25
+ readonly kind: "allowance"
26
+ readonly plan?: string
27
+ readonly designPlanUsedPct?: number
28
+ readonly pctOf?: "weekly" | "monthly"
29
+ readonly resetsAt?: number
30
+ readonly periodEndsAt?: number
31
+ readonly walletUsd?: number
32
+ readonly found: ReadonlyArray<string>
33
+ readonly missing: ReadonlyArray<string>
34
+ }
35
+
36
+ export interface UsageModelRow {
37
+ readonly modelName?: string
38
+ /** Present when the page shows token counts. */
39
+ readonly inputTokens?: number
40
+ readonly outputTokens?: number
41
+ readonly cacheReadTokens?: number
42
+ /** Present when the page shows credits or dollars. */
43
+ readonly chargedUsd?: number
44
+ }
45
+
46
+ export interface UsageReading {
47
+ readonly kind: "usage"
48
+ readonly rows: ReadonlyArray<UsageModelRow>
49
+ readonly totalUsd?: number
50
+ readonly periodStart?: number
51
+ readonly periodEnd?: number
52
+ readonly found: ReadonlyArray<string>
53
+ readonly missing: ReadonlyArray<string>
54
+ }
55
+
56
+ export type ConsoleReading = AllowanceReading | UsageReading
57
+
58
+ const normalize = (text: string): string =>
59
+ text
60
+ .replace(/\r\n?/g, "\n")
61
+ // Keep line structure; both pages are block-oriented and collapsing newlines
62
+ // would let a value from one card satisfy a label from another.
63
+ .replace(/[ \t\u00a0]+/g, " ")
64
+ .replace(/\n{3,}/g, "\n\n")
65
+
66
+ const numberFrom = (raw: string | undefined): number | undefined => {
67
+ if (raw === undefined) return undefined
68
+ // "1,234.5" and "1 234,5" both appear in localised consoles.
69
+ const cleaned = raw.replace(/,/g, "".replace(/x/, ""))
70
+ const value = Number(cleaned)
71
+ return Number.isFinite(value) ? value : undefined
72
+ }
73
+
74
+ const CARD_LABELS = [
75
+ /Design Plan credits/i,
76
+ /Pay-as-you-go credits/i,
77
+ /Credit breakdown/i,
78
+ /Auto top-up/i,
79
+ /Top up/i,
80
+ /Reset cards/i,
81
+ /Manage subscription/i,
82
+ /Total/i,
83
+ ]
84
+
85
+ /** The text belonging to one card: its label up to the next card or blank line. */
86
+ const cardAfter = (text: string, label: RegExp, limit = 200): string | undefined => {
87
+ const start = label.exec(text)
88
+ if (!start) return undefined
89
+ const rest = text.slice(start.index + start[0].length)
90
+ let end = Math.min(rest.length, limit)
91
+ for (const heading of CARD_LABELS) {
92
+ if (heading.source === label.source) continue
93
+ const found = heading.exec(rest)
94
+ if (found && found.index > 0) end = Math.min(end, found.index)
95
+ }
96
+ const blank = rest.indexOf("\n\n")
97
+ if (blank > 0) end = Math.min(end, blank)
98
+ return rest.slice(0, end)
99
+ }
100
+
101
+ const inCard = (text: string, label: RegExp, pattern: RegExp, limit = 200): string | undefined => {
102
+ const block = cardAfter(text, label, limit)
103
+ return block === undefined ? undefined : pattern.exec(block)?.[1]?.trim() || undefined
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Allowance: the Plan & balance page
108
+ // ---------------------------------------------------------------------------
109
+
110
+ const readAllowance = (text: string, now: number): AllowanceReading | undefined => {
111
+ const designPct = numberFrom(inCard(text, /Design Plan credits/i, /(\d{1,3})\s*%/, 120))
112
+ // An allowance page without a percentage is not an allowance page.
113
+ if (designPct === undefined) return undefined
114
+
115
+ const found = ["Design Plan usage"]
116
+ const missing: string[] = []
117
+
118
+ const planLine = /^\s*(Go|Plus|Pro|Max)\s*$/m.exec(text)?.[1]
119
+ if (planLine) found.push("plan")
120
+ else missing.push("plan")
121
+
122
+ let pctOf: "weekly" | "monthly" | undefined
123
+ if (/\d+\s*days?\s*of\s*quota/i.test(text) || /seven[-\s]?day/i.test(text)) pctOf = "weekly"
124
+ else if (/monthly\s*(allowance|quota|credits)/i.test(text)) pctOf = "monthly"
125
+ if (pctOf) found.push("allowance window")
126
+
127
+ const resetText = /\bReset in\s+([0-9]+\s*(?:days?|hours?|minutes?)[^\n]*)/i.exec(text)?.[1]
128
+ const resetsAt = resetText ? parseResetIn(resetText, now) : undefined
129
+ if (resetsAt !== undefined) found.push("reset time")
130
+ else missing.push("reset time")
131
+
132
+ const periodText = /This period ends on\s+([A-Z][a-z]+ \d{1,2},\s*\d{4})/i.exec(text)?.[1]
133
+ const periodEndsAt = periodText ? Date.parse(periodText) : undefined
134
+ if (periodEndsAt !== undefined && Number.isFinite(periodEndsAt)) found.push("billing period")
135
+ else missing.push("billing period")
136
+
137
+ const wallet = numberFrom(inCard(text, /Pay-as-you-go credits/i, /\$\s*([\d,]+(?:\.\d{1,2})?)/, 120))
138
+ if (wallet !== undefined) found.push("wallet balance")
139
+ else missing.push("wallet balance")
140
+
141
+ return {
142
+ kind: "allowance",
143
+ ...(planLine ? { plan: planLine } : {}),
144
+ designPlanUsedPct: Math.min(100, Math.max(0, designPct)),
145
+ ...(pctOf ? { pctOf } : {}),
146
+ ...(resetsAt !== undefined ? { resetsAt } : {}),
147
+ ...(periodEndsAt !== undefined && Number.isFinite(periodEndsAt) ? { periodEndsAt } : {}),
148
+ ...(wallet !== undefined ? { walletUsd: wallet } : {}),
149
+ found,
150
+ missing,
151
+ }
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Usage: the console's usage table
156
+ // ---------------------------------------------------------------------------
157
+
158
+ const datePatterns = [
159
+ /\b([A-Z][a-z]{2} \d{1,2},? \d{4})\b/,
160
+ /\b(\d{4}-\d{2}-\d{2})\b/,
161
+ /\b(\d{1,2}\/\d{1,2}\/\d{4})\b/,
162
+ ]
163
+
164
+ /**
165
+ * A usage row looks like: model name, then some numbers, one of which may be a
166
+ * dollar amount. Columns are separated by whitespace or tabs once the page is
167
+ * normalised, and the order varies, so a row is recognised by its shape rather
168
+ * than by fixed positions.
169
+ */
170
+ const parseUsageRow = (line: string): UsageModelRow | undefined => {
171
+ const usd = /\$\s*([\d,]+(?:\.\d{1,4})?)/.exec(line)?.[1]
172
+ const numerics = [...line.matchAll(/\b(\d{1,3}(?:,\d{3})+|\d+(?:\.\d+)?)\b/g)]
173
+ .map((m) => Number(m[1]!.replace(/,/g, "")))
174
+ .filter((n) => Number.isFinite(n))
175
+
176
+ // A row needs at least one number to be a usage row.
177
+ if (numerics.length === 0 && usd === undefined) return undefined
178
+
179
+ // The name is the leading text before the first number.
180
+ const firstNumberIndex = line.search(/\d/)
181
+ const name = firstNumberIndex > 2 ? line.slice(0, firstNumberIndex).trim().replace(/[-–—|:]+$/, "").trim() : undefined
182
+ if (!name || name.length < 2) return undefined
183
+ // Headers and navigation are not rows.
184
+ if (/^(model|member|caller|total|time|date|usage|credits|tokens|input|output|cached)$/i.test(name)) return undefined
185
+
186
+ const chargedUsd = usd !== undefined ? numberFrom(usd) : undefined
187
+ // Without a dollar column the numbers are tokens. Big numbers are tokens;
188
+ // small ones are more likely percentages or counts, which are not useful here.
189
+ const tokenCandidate = numerics.filter((n) => n >= 1_000)
190
+
191
+ return {
192
+ modelName: name,
193
+ ...(chargedUsd !== undefined ? { chargedUsd } : {}),
194
+ ...(tokenCandidate[0] !== undefined ? { inputTokens: tokenCandidate[0] } : {}),
195
+ ...(tokenCandidate[1] !== undefined ? { outputTokens: tokenCandidate[1] } : {}),
196
+ ...(tokenCandidate[2] !== undefined ? { cacheReadTokens: tokenCandidate[2] } : {}),
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Usage rows, in the shape a person's clipboard actually produces.
202
+ *
203
+ * Copying a table gives the cells on separate lines — model name, then each
204
+ * number, then the next model. Copying a row gives them on one line. Both are
205
+ * accepted: the parser tracks a current row and attaches numbers to it until the
206
+ * next name appears.
207
+ */
208
+ const HEADER = /^(model|member|caller|time|date|usage|credits?|tokens?|input|output|cached|cost|total|range|period)$/i
209
+ const NOISE = /^(usage|billing|plan & balance|api keys|members|settings|open design|invite colleagues|pricing|arena|models & pricing|help & docs|top up|credit breakdown|auto top-up|reset cards|manage subscription|upgrade)$/i
210
+
211
+ const looksLikeNumber = (line: string): boolean => /^\$?\s*[\d,]+(\.\d+)?%?$/.test(line)
212
+ const looksLikeName = (line: string): boolean =>
213
+ !looksLikeNumber(line) && !HEADER.test(line) && !NOISE.test(line) && /[A-Za-z]/.test(line) && line.length >= 2 && line.length <= 60
214
+
215
+ const readUsage = (text: string, now: number): UsageReading | undefined => {
216
+ const lines = text.split("\n").map((line) => line.trim()).filter(Boolean)
217
+ const rows: UsageModelRow[] = []
218
+
219
+ let name: string | undefined
220
+ let nums: number[] = []
221
+ let usd: number | undefined
222
+
223
+ const flush = () => {
224
+ if (!name || (nums.length === 0 && usd === undefined)) {
225
+ name = undefined
226
+ nums = []
227
+ usd = undefined
228
+ return
229
+ }
230
+ // Token counts are large; percentages and small counts are not usage.
231
+ const tokens = nums.filter((n) => n >= 1_000)
232
+ rows.push({
233
+ modelName: name,
234
+ ...(usd !== undefined ? { chargedUsd: usd } : {}),
235
+ ...(tokens[0] !== undefined ? { inputTokens: tokens[0] } : {}),
236
+ ...(tokens[1] !== undefined ? { outputTokens: tokens[1] } : {}),
237
+ ...(tokens[2] !== undefined ? { cacheReadTokens: tokens[2] } : {}),
238
+ })
239
+ name = undefined
240
+ nums = []
241
+ usd = undefined
242
+ }
243
+
244
+ const DATE_LINE = /\b\d{4}\b.*\b\d{4}\b|\b[A-Z][a-z]{2} \d{1,2}, \d{4}\b/
245
+ // A name followed by whitespace-separated numeric tokens, e.g.
246
+ // "GLM-5.3 Flash 2,000,000 $0.15". The trailing group must be numbers only:
247
+ // "DeepSeek V4.1 Flash" ends in letters and is therefore a name, not a row.
248
+ const INLINE = /^(.+?)\s+((?:\$?[\d,]+(?:\.\d+)?%?\s*)+)$/
249
+
250
+ for (const line of lines) {
251
+ // A date range is period information, not a usage row.
252
+ if (DATE_LINE.test(line)) continue
253
+
254
+ const dollarOnly = /^\$\s*([\d,]+(?:\.\d{1,4})?)$/.exec(line)
255
+ if (dollarOnly) {
256
+ // A second dollar figure while a row is still open belongs to a new row
257
+ // (the Total line), not to the one in progress. Without this the total
258
+ // overwrites the last model's charge and every reconciliation after it is
259
+ // wrong in a way that looks like a catalog error.
260
+ if (usd !== undefined) {
261
+ flush()
262
+ }
263
+ usd = numberFrom(dollarOnly[1])
264
+ continue
265
+ }
266
+ if (looksLikeNumber(line)) {
267
+ const value = Number(line.replace(/[$,%]/g, ""))
268
+ if (Number.isFinite(value)) nums.push(value)
269
+ continue
270
+ }
271
+
272
+ const inline = INLINE.exec(line)
273
+ // A trailing "5.5" is part of a model name ("Claude Haiku 5.5"), not a
274
+ // measurement. An inline row must carry a dollar amount or a real token
275
+ // count; anything smaller is version numbering.
276
+ const trailing = inline?.[2] ?? ""
277
+ const carriesData = /\$\s*\d/.test(trailing) || /\d{4,}/.test(trailing) || /\d{1,3}(?:,\d{3})+/.test(trailing)
278
+ if (inline && carriesData) {
279
+ const candidate = inline[1]!.trim()
280
+ if (looksLikeName(candidate)) {
281
+ flush()
282
+ const dollar = /\$\s*([\d,]+(?:\.\d{1,4})?)/.exec(inline[2]!)
283
+ usd = dollar ? numberFrom(dollar[1]) : undefined
284
+ nums = [...inline[2]!.replace(/\$[\d,]+(?:\.\d+)?/g, " ").matchAll(/\b(\d{1,3}(?:,\d{3})+|\d+(?:\.\d+)?)\b/g)]
285
+ .map((m) => Number(m[1]!.replace(/,/g, "")))
286
+ .filter((n) => Number.isFinite(n))
287
+ name = candidate
288
+ flush()
289
+ }
290
+ continue
291
+ }
292
+
293
+ if (looksLikeName(line)) {
294
+ flush()
295
+ name = line
296
+ }
297
+ }
298
+
299
+ flush()
300
+
301
+ const withCharge = rows.filter((row) => row.chargedUsd !== undefined)
302
+ if (rows.length < 3 && withCharge.length === 0) return undefined
303
+
304
+ const found = [`${rows.length} usage rows`]
305
+ const totalRaw = /\bTotal[^\n$]*\$\s*([\d,]+(?:\.\d{1,2})?)/i.exec(text)?.[1]
306
+ const totalUsd = totalRaw !== undefined ? numberFrom(totalRaw) : undefined
307
+ if (totalUsd !== undefined) found.push("total")
308
+
309
+ const dates: number[] = []
310
+ for (const pattern of datePatterns) {
311
+ for (const match of text.matchAll(new RegExp(pattern.source, "g"))) {
312
+ const parsed = Date.parse(match[1]!)
313
+ if (Number.isFinite(parsed)) dates.push(parsed)
314
+ }
315
+ }
316
+ dates.sort((a, b) => a - b)
317
+ const periodStart = dates[0]
318
+ const periodEnd = dates.length > 1 ? dates[dates.length - 1] : undefined
319
+ if (periodStart !== undefined) found.push("date range")
320
+
321
+ return {
322
+ kind: "usage",
323
+ rows,
324
+ ...(totalUsd !== undefined ? { totalUsd } : {}),
325
+ ...(periodStart !== undefined ? { periodStart } : {}),
326
+ ...(periodEnd !== undefined ? { periodEnd } : {}),
327
+ found,
328
+ missing: withCharge.length === 0 ? ["charged amounts (tokens only, credits cannot be confirmed)"] : [],
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Decide which page was pasted.
334
+ *
335
+ * Allowance is checked first: it requires a percentage inside a specific card,
336
+ * which a usage table does not have. Usage then requires several rows. A page
337
+ * matching neither is reported as unrecognised rather than half-read, because a
338
+ * half-read page produces a confident wrong number.
339
+ */
340
+ export const parseConsolePage = (raw: string, now = Date.now()): ConsoleReading | undefined => {
341
+ const text = normalize(raw)
342
+ return readAllowance(text, now) ?? readUsage(text, now)
343
+ }
344
+
345
+ /** Kept for the earlier allowlist-only call sites. */
346
+ export const parsePlanPage = (raw: string, now = Date.now()): AllowanceReading => {
347
+ const reading = readAllowance(normalize(raw), now)
348
+ if (reading) return reading
349
+ return {
350
+ kind: "allowance",
351
+ found: [],
352
+ missing: ["Design Plan usage"],
353
+ }
354
+ }
355
+
356
+ const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`
357
+
358
+ /**
359
+ * One line a person can check at a glance before committing it.
360
+ *
361
+ * Written as a sentence rather than a table because it is read once, in a form,
362
+ * and the point is whether it sounds like the page they just copied.
363
+ */
364
+ export const describeConsoleReading = (reading: ConsoleReading): string => {
365
+ if (reading.kind === "allowance") {
366
+ if (reading.designPlanUsedPct === undefined) {
367
+ return "Could not find a Design Plan percentage on that page. Copy the Plan & balance page and try again."
368
+ }
369
+ const parts = [
370
+ `${reading.designPlanUsedPct}% of the ${reading.pctOf === "monthly" ? "monthly" : "seven-day"} allowance`,
371
+ ]
372
+ if (reading.resetsAt !== undefined) {
373
+ const hours = Math.max(0, Math.round((reading.resetsAt - Date.now()) / 3_600_000))
374
+ parts.push(hours >= 36 ? `resets in ${Math.round(hours / 24)} days` : `resets in ${hours} hours`)
375
+ }
376
+ if (reading.plan) parts.unshift(`${reading.plan} plan`)
377
+ if (reading.walletUsd !== undefined) parts.push(`wallet ${reading.walletUsd} USD (never auto-spent)`)
378
+ return parts.join(" · ")
379
+ }
380
+
381
+ const charged = reading.rows.filter((row) => row.chargedUsd !== undefined)
382
+ if (charged.length > 0) {
383
+ const sum = charged.reduce((total, row) => total + (row.chargedUsd ?? 0), 0)
384
+ return `Usage page: ${plural(reading.rows.length, "model")}, ${sum.toFixed(2)} USD charged across ${plural(charged.length, "row")}.`
385
+ }
386
+ return `Usage page: ${plural(reading.rows.length, "model")} with token counts. No charged amounts, so this can refine the estimate but cannot confirm it.`
387
+ }
388
+
389
+ /** Kept for call sites that only describe an allowance reading. */
390
+ export const describePlanPage = (reading: AllowanceReading): string => describeConsoleReading(reading)