@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
package/src/core/ui.ts ADDED
@@ -0,0 +1,236 @@
1
+ import { execFile } from "node:child_process"
2
+ import { promisify } from "node:util"
3
+ import type { ProviderQuotaSnapshot } from "./types.js"
4
+ import { percent, usd } from "./util.js"
5
+
6
+ const run = promisify(execFile)
7
+
8
+ export type IndicatorState = "AUTO" | "MANUAL" | "PAUSED" | "DRY RUN" | "BLOCKED"
9
+
10
+ export interface Indicator {
11
+ readonly state: IndicatorState
12
+ readonly provider?: string
13
+ readonly model?: string
14
+ readonly modelLabel?: string
15
+ readonly title?: string
16
+ }
17
+
18
+ export const providerShort = (providerID: string | undefined): string => {
19
+ if (!providerID) return "?"
20
+ if (providerID === "opencode-go") return "Go"
21
+ if (providerID === "commandcode") return "CC"
22
+ if (providerID === "opendesign") return "OD"
23
+ return providerID
24
+ }
25
+
26
+ /**
27
+ * Human-readable confidence state for a provider quota snapshot.
28
+ *
29
+ * `verified` means the provider reported the numbers. `estimated` means the
30
+ * router modelled them from its own usage and they are a lower bound.
31
+ * `unknown` means nothing could be established safely.
32
+ */
33
+ export const quotaStateLabel = (snapshot: ProviderQuotaSnapshot): string => {
34
+ if (snapshot.stale) return "stale"
35
+ switch (snapshot.quotaState) {
36
+ case "verified":
37
+ return "verified"
38
+ case "estimated":
39
+ return "estimated"
40
+ case "exhausted":
41
+ return "exhausted"
42
+ case "unavailable":
43
+ return "unavailable"
44
+ case "unknown":
45
+ return "unknown"
46
+ case "stale":
47
+ return "stale"
48
+ default:
49
+ return snapshot.source === "provider"
50
+ ? "verified"
51
+ : snapshot.source === "estimate"
52
+ ? "estimated"
53
+ : "unknown"
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Render a provider quota line.
59
+ *
60
+ * OpenDesign is reported in Design Plan credits, which are USD-equivalent.
61
+ * A plan with more than one funding pool prints one line per pool rather
62
+ * than a summed balance: the pools have different rules, and adding them
63
+ * would suggest the total can be spent in one go.
64
+ *
65
+ * The purchased wallet balance is always shown separately so a non-zero
66
+ * balance can never be mistaken for permission to spend it.
67
+ */
68
+ export const formatQuotaLines = (snapshot: ProviderQuotaSnapshot): string[] => {
69
+ const pools = snapshot.pools ?? []
70
+ const lines: string[] = []
71
+ if (pools.length > 1) {
72
+ lines.push(`${snapshot.label} (${quotaStateLabel(snapshot)}):`)
73
+ for (const pool of pools) {
74
+ const windows: string[] = []
75
+ if (pool.fiveHourUsd !== undefined && pool.fiveHourUsedPct !== undefined) {
76
+ windows.push(`5h ${percent(pool.fiveHourUsedPct)} of ${usd(pool.fiveHourUsd)}`)
77
+ }
78
+ if (pool.weeklyUsd !== undefined && pool.weeklyUsedPct !== undefined) {
79
+ windows.push(`7d ${percent(pool.weeklyUsedPct)} of ${usd(pool.weeklyUsd)}`)
80
+ }
81
+ const windowText = windows.length > 0 ? ` · ${windows.join(" · ")}` : " · no usage windows"
82
+ lines.push(
83
+ ` ${pool.label}: ${usd(pool.remainingUsd)} of ${usd(pool.monthlyUsd)} remaining${windowText}`,
84
+ )
85
+ }
86
+ lines.push(" The pools are separate allowances and are never added together.")
87
+ } else {
88
+ const windows =
89
+ snapshot.rolling.source === "unknown" && snapshot.weekly.source === "unknown"
90
+ ? "no usage windows"
91
+ : `5h ${percent(snapshot.rolling.usedPct)} · weekly ${percent(snapshot.weekly.usedPct)}`
92
+ lines.push(
93
+ `${snapshot.label}: ${windows} · monthly ${percent(snapshot.monthly.usedPct)} (${quotaStateLabel(snapshot)})`,
94
+ )
95
+ }
96
+ if (snapshot.providerID === "opendesign") {
97
+ const purchased = snapshot.extraCredits?.purchasedUsd ?? 0
98
+ lines.push(
99
+ ` Purchased wallet ${usd(purchased)} is separate from both allowances and is never spent automatically.`,
100
+ )
101
+ if (snapshot.monthlyRemainingUsd !== undefined) {
102
+ lines.push(` Design Plan allowance remaining ${usd(snapshot.monthlyRemainingUsd)}.`)
103
+ }
104
+ }
105
+ if (snapshot.error) lines.push(` ${snapshot.error}`)
106
+ return lines
107
+ }
108
+
109
+ export const indicatorState = (input: {
110
+ enabled: boolean
111
+ runtimePaused: boolean
112
+ manual: boolean
113
+ dryRun: boolean
114
+ }): IndicatorState => {
115
+ if (!input.enabled || input.runtimePaused) return "PAUSED"
116
+ if (input.manual) return "MANUAL"
117
+ if (input.dryRun) return "DRY RUN"
118
+ return "AUTO"
119
+ }
120
+
121
+ export const stripTitlePrefix = (title: string | undefined): string => {
122
+ if (!title) return ""
123
+ const match = /^(AUTO|MANUAL|PAUSED|DRY RUN|BLOCKED) · [^—]*(?: — ([\s\S]*))?$/.exec(title)
124
+ if (!match) return title.trim()
125
+ return (match[2] ?? "").trim()
126
+ }
127
+
128
+ /**
129
+ * The name a session should keep, independent of whatever status decoration is
130
+ * currently in front of it.
131
+ *
132
+ * The failure this exists to prevent: the router used to derive the base title
133
+ * from `session.title`, which holds the last title *the router itself wrote*. So
134
+ * the first time the separator was lost — compact mode, a BLOCKED rewrite, a
135
+ * title written before the generator named the chat — the prefix became the
136
+ * name, and every later write read that prefix back as if it were a title.
137
+ * There was no way out of that state.
138
+ *
139
+ * Rules:
140
+ * - A visible title the router did not write is the user's name. Adopt it.
141
+ * - Otherwise keep the base already on record.
142
+ * - Only fall back to stripping a prefix as a last resort, and return
143
+ * `undefined` rather than an empty string, so the caller can leave the
144
+ * session alone instead of writing a nameless title.
145
+ */
146
+ export const resolveBaseTitle = (input: {
147
+ currentTitle: string | undefined
148
+ /** The exact title this router last wrote, if any. */
149
+ lastWrittenTitle: string | undefined
150
+ storedBase: string | undefined
151
+ }): string | undefined => {
152
+ const visible = input.currentTitle?.trim()
153
+ const routerWroteIt = Boolean(visible) && visible === input.lastWrittenTitle
154
+ if (visible && !routerWroteIt) return visible
155
+ if (input.storedBase) return input.storedBase
156
+ const stripped = stripTitlePrefix(input.currentTitle)
157
+ return stripped || undefined
158
+ }
159
+
160
+ export const buildIndicatorTitle = (input: {
161
+ state: IndicatorState
162
+ provider?: string
163
+ modelLabel?: string
164
+ baseTitle?: string
165
+ compact?: boolean
166
+ }): string => {
167
+ const provider = providerShort(input.provider)
168
+ const model = input.modelLabel?.trim() || "model"
169
+ const prefix = input.compact ? `${input.state} · ${model}` : `${input.state} · ${provider} · ${model}`
170
+ const base = stripTitlePrefix(input.baseTitle)
171
+ // A prefix on its own is not a name, and neither is the placeholder OpenCode
172
+ // uses before the title generator has run. Compact shortens the prefix; it
173
+ // must never delete the name, which is what it used to do.
174
+ if (!base || base === prefix || base === "Session") return prefix
175
+ return `${prefix} — ${base.slice(0, 150)}`
176
+ }
177
+
178
+ export class ChangeNotifier {
179
+ private readonly last = new Map<string, number>()
180
+ private backend: "terminal-notifier" | "osascript" | "none" | undefined
181
+
182
+ shouldNotify(key: string, ttlMs = 10 * 60_000) {
183
+ const now = Date.now()
184
+ const previous = this.last.get(key)
185
+ if (previous && now - previous < ttlMs) return false
186
+ this.last.set(key, now)
187
+ if (this.last.size > 100) {
188
+ for (const [entry, at] of this.last) {
189
+ if (now - at > ttlMs) this.last.delete(entry)
190
+ }
191
+ }
192
+ return true
193
+ }
194
+
195
+ async notify(message: string): Promise<boolean> {
196
+ if (!message.trim()) return false
197
+ if (this.backend === "none") return false
198
+ if (this.backend === "terminal-notifier" || this.backend === undefined) {
199
+ try {
200
+ await run(
201
+ "terminal-notifier",
202
+ ["-title", "OpenCode Smart Router", "-message", message.slice(0, 220), "-group", "opencode-smart-router"],
203
+ { timeout: 1_500, maxBuffer: 4_096 },
204
+ )
205
+ this.backend = "terminal-notifier"
206
+ return true
207
+ } catch (error) {
208
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
209
+ // terminal-notifier exists but the notification was suppressed or
210
+ // denied. Fall through to the AppleScript path once.
211
+ }
212
+ }
213
+ }
214
+ try {
215
+ const escaped = message.replaceAll("\\", "/").replaceAll('"', "'").slice(0, 220)
216
+ await run(
217
+ "osascript",
218
+ ["-e", `display notification "${escaped}" with title "OpenCode Smart Router"`],
219
+ { timeout: 1_500, maxBuffer: 4_096 },
220
+ )
221
+ this.backend = "osascript"
222
+ return true
223
+ } catch {
224
+ this.backend = "none"
225
+ return false
226
+ }
227
+ }
228
+ }
229
+
230
+ export const __uiInternals = {
231
+ stripTitlePrefix,
232
+ buildIndicatorTitle,
233
+ resolveBaseTitle,
234
+ providerShort,
235
+ quotaStateLabel,
236
+ }
@@ -0,0 +1,114 @@
1
+ import type { ModelKey, ModelRef, TaskTier } from "./types.js"
2
+
3
+ export const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
4
+
5
+ export const pct = (used: number, total: number) => (total <= 0 ? 0 : clamp((used / total) * 100, 0, 100))
6
+
7
+ export const monthKey = (at = Date.now()) => {
8
+ const date = new Date(at)
9
+ return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`
10
+ }
11
+
12
+ export const usd = (value: number) => {
13
+ if (!Number.isFinite(value)) return "$0.00"
14
+ const abs = Math.abs(value)
15
+ if (abs > 0 && abs < 0.01) return `$${value.toFixed(5)}`
16
+ if (abs < 1) return `$${value.toFixed(4)}`
17
+ return `$${value.toFixed(2)}`
18
+ }
19
+
20
+ export const percent = (value: number) => `${value.toFixed(value >= 10 ? 0 : 1)}%`
21
+
22
+ /**
23
+ * Describe a plan id for display when the catalog could not resolve it.
24
+ *
25
+ * An unrecognized configured id must not be echoed as if it were a tier this
26
+ * build understands, and must not be silently replaced by a default: either
27
+ * would imply knowledge of an allowance that was never established. The raw
28
+ * value is kept only as a suffix so a user can see what they typed to fix it.
29
+ */
30
+ export const describePlan = (resolvedPlanID: string | undefined, configuredPlanID: string | undefined): string => {
31
+ if (resolvedPlanID) return resolvedPlanID
32
+ const configured = (configuredPlanID ?? "").trim()
33
+ return configured ? `unresolved (${configured})` : "unresolved"
34
+ }
35
+
36
+ /** Group an integer with thousands separators, for token counts in prose. */
37
+ export const thousands = (value: number) =>
38
+ Number.isFinite(value) ? Math.round(value).toLocaleString("en-US") : "0"
39
+
40
+ export const modelKey = (providerID: string, modelID: string): string => `${providerID}/${modelID}`
41
+
42
+ export const parseModelKey = (key: string): ModelKey => {
43
+ const index = key.indexOf("/")
44
+ if (index <= 0) return { providerID: key, modelID: "" }
45
+ return { providerID: key.slice(0, index), modelID: key.slice(index + 1) }
46
+ }
47
+
48
+ export const keyOfRef = (ref: ModelRef | undefined): string | undefined =>
49
+ ref ? modelKey(ref.providerID, ref.id) : undefined
50
+
51
+ export const refOfKey = (key: string, variant?: string): ModelRef => {
52
+ const parsed = parseModelKey(key)
53
+ return variant ? { providerID: parsed.providerID, id: parsed.modelID, variant } : { providerID: parsed.providerID, id: parsed.modelID }
54
+ }
55
+
56
+ export const refKey = (ref: ModelRef): string => modelKey(ref.providerID, ref.id)
57
+
58
+ /**
59
+ * Small, stable hash used only for local diagnostics. It is not a security
60
+ * primitive and must not be used to protect secrets.
61
+ */
62
+ export const stableHash = (input: string): string => {
63
+ let hash = 0x811c9dc5
64
+ for (let index = 0; index < input.length; index += 1) {
65
+ hash ^= input.charCodeAt(index)
66
+ hash = Math.imul(hash, 0x01000193)
67
+ }
68
+ return (hash >>> 0).toString(16).padStart(8, "0")
69
+ }
70
+
71
+ export const truncate = (value: string, max: number) => (value.length <= max ? value : `${value.slice(0, Math.max(0, max - 1))}…`)
72
+
73
+ export const tierLabel = (tier: TaskTier) =>
74
+ tier === 0 ? "Deterministic" : tier === 1 ? "Economy" : tier === 2 ? "Standard" : "Premium"
75
+
76
+ export const shaLength = (value: string) => ({ hash: stableHash(value), chars: value.length })
77
+
78
+ const secretPatterns = [
79
+ /bearer\s+[A-Za-z0-9._~+/=-]+/gi,
80
+ /(?:sk|user|key|token)-[A-Za-z0-9_-]{12,}/gi,
81
+ /\b[A-Za-z0-9_-]{32,}\b/g,
82
+ ]
83
+
84
+ /** Remove values that look like credentials from diagnostic text. */
85
+ export const redact = (value: string): string => {
86
+ let result = value
87
+ for (const pattern of secretPatterns) result = result.replace(pattern, "[redacted]")
88
+ return truncate(result, 500)
89
+ }
90
+
91
+ export const safeError = (error: unknown): string => {
92
+ if (error instanceof Error) return redact(error.message)
93
+ if (typeof error === "string") return redact(error)
94
+ try {
95
+ return redact(JSON.stringify(error))
96
+ } catch {
97
+ return "Unknown error"
98
+ }
99
+ }
100
+
101
+ export const unique = <T>(values: ReadonlyArray<T>): T[] => [...new Set(values)]
102
+
103
+ export const isImageUri = (uri: string): boolean => {
104
+ const lower = uri.toLowerCase()
105
+ if (lower.startsWith("data:image/")) return true
106
+ return /\.(png|jpe?g|gif|webp|bmp|tiff?|heic|heif|avif)$/i.test(lower)
107
+ }
108
+
109
+ export const isImageName = (name: string | undefined): boolean => {
110
+ if (!name) return false
111
+ return /\.(png|jpe?g|gif|webp|bmp|tiff?|heic|heif|avif)$/i.test(name)
112
+ }
113
+
114
+ export const nowId = (prefix: string) => `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`