@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.
- package/CHANGELOG.md +118 -0
- package/CONTRIBUTING.md +96 -0
- package/LICENSE +21 -0
- package/README.md +341 -2
- package/SECURITY.md +174 -0
- package/config.example.json +108 -0
- package/dist/allowance.js +487 -0
- package/dist/gui-experimental.cjs +1 -0
- package/dist/index.js +11111 -0
- package/dist/npm/index.js +40 -0
- package/dist/tui.js +587 -0
- package/docs/ARCHITECTURE.md +104 -0
- package/docs/DEBUGGING-GUI.md +70 -0
- package/docs/INSTALL.md +120 -0
- package/docs/LIMITATIONS.md +213 -0
- package/docs/MODELS.md +124 -0
- package/docs/OPENDESIGN-INTEGRATION.md +635 -0
- package/docs/QA.md +142 -0
- package/docs/SHARED-CORE.md +72 -0
- package/install +75 -0
- package/package.json +92 -4
- package/scripts/install.sh +324 -0
- package/scripts/uninstall.sh +91 -0
- package/src/allowance.ts +135 -0
- package/src/core/classify/classifier.ts +305 -0
- package/src/core/config-writer.ts +167 -0
- package/src/core/config.ts +1030 -0
- package/src/core/context/strategy.ts +188 -0
- package/src/core/context-health.ts +39 -0
- package/src/core/failures.ts +398 -0
- package/src/core/history/opencode.ts +173 -0
- package/src/core/models/protocol.ts +22 -0
- package/src/core/models/registry.ts +203 -0
- package/src/core/models/seed.ts +771 -0
- package/src/core/offline/mode.ts +321 -0
- package/src/core/plan-page.ts +390 -0
- package/src/core/plans/catalog.ts +481 -0
- package/src/core/policy/calibration.ts +259 -0
- package/src/core/policy/eligibility.ts +578 -0
- package/src/core/providers/enablement.ts +143 -0
- package/src/core/providers/opendesign-credits.ts +206 -0
- package/src/core/providers/opendesign-models.ts +628 -0
- package/src/core/providers/opendesign.ts +474 -0
- package/src/core/quota/calibration.ts +166 -0
- package/src/core/quota/commandcode.ts +471 -0
- package/src/core/quota/credentials.ts +490 -0
- package/src/core/quota/ledger.ts +1273 -0
- package/src/core/quota/opencode-go.ts +148 -0
- package/src/core/quota/opendesign.ts +1043 -0
- package/src/core/quota/reconcile.ts +155 -0
- package/src/core/quota/report.ts +261 -0
- package/src/core/quota/service.ts +273 -0
- package/src/core/reprompt.ts +48 -0
- package/src/core/routing/engine.ts +919 -0
- package/src/core/types.ts +825 -0
- package/src/core/ui.ts +236 -0
- package/src/core/util.ts +114 -0
- package/src/gui/index.tsx +1670 -0
- package/src/gui/viewmodel.ts +456 -0
- package/src/index.ts +48 -0
- package/src/rpc.ts +204 -0
- package/src/server/controller.ts +3281 -0
- package/src/server/route-tracker.ts +60 -0
- package/src/server/shared-core.ts +671 -0
- 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
|
+
}
|
package/src/core/util.ts
ADDED
|
@@ -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)}`
|