@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.
- package/CHANGELOG.md +286 -0
- package/CONTRIBUTING.md +103 -0
- package/LICENSE +21 -0
- package/README.md +425 -2
- package/SECURITY.md +174 -0
- package/config.example.json +107 -0
- package/dist/npm/index.js +40 -0
- package/docs/ARCHITECTURE.md +104 -0
- package/docs/DEBUGGING-GUI.md +70 -0
- package/docs/INSTALL.md +151 -0
- package/docs/LIMITATIONS.md +230 -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 +114 -0
- package/install +75 -0
- package/package.json +94 -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 +1047 -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 +204 -0
- package/src/core/models/seed.ts +781 -0
- package/src/core/offline/mode.ts +321 -0
- package/src/core/plan-page.ts +390 -0
- package/src/core/plans/catalog.ts +487 -0
- package/src/core/policy/calibration.ts +259 -0
- package/src/core/policy/eligibility.ts +630 -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 +654 -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 +1304 -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 +978 -0
- package/src/core/types.ts +866 -0
- package/src/core/ui.ts +236 -0
- package/src/core/util.ts +114 -0
- package/src/gui/index.tsx +1675 -0
- package/src/gui/viewmodel.ts +456 -0
- package/src/index.ts +33 -0
- package/src/rpc.ts +204 -0
- package/src/server/containment.ts +131 -0
- package/src/server/controller.ts +3405 -0
- package/src/server/route-tracker.ts +60 -0
- package/src/server/shared-core.ts +701 -0
- package/src/tui/index.tsx +168 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import type { CandidateEvaluation, ModelKey, ProviderQuotaSnapshot, RoutingDecision } from "../core/types.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The GUI's view-model.
|
|
5
|
+
*
|
|
6
|
+
* Pure functions over the data the server already produces. No Solid, no DOM,
|
|
7
|
+
* no RPC — so every rule below is testable in plain Node, and the TUI renderer
|
|
8
|
+
* can reuse it unchanged.
|
|
9
|
+
*
|
|
10
|
+
* The rule this file exists to enforce: the panel shows the router's own words.
|
|
11
|
+
* If the engine says a model was ineligible because the weekly window is spent,
|
|
12
|
+
* that sentence is displayed. The view-model formats and orders; it never
|
|
13
|
+
* invents or softens a reason.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type Verdict = "chosen" | "eligible" | "blocked"
|
|
17
|
+
|
|
18
|
+
export interface CandidateRow {
|
|
19
|
+
readonly key: string
|
|
20
|
+
readonly name: string
|
|
21
|
+
readonly tier: number
|
|
22
|
+
readonly verdict: Verdict
|
|
23
|
+
readonly reason: string
|
|
24
|
+
readonly estimatedUsd: number
|
|
25
|
+
readonly effectiveCost: number
|
|
26
|
+
readonly successProbability: number
|
|
27
|
+
/** Why the ranking moved this model, when a multiplier applied. Empty otherwise. */
|
|
28
|
+
readonly adjustments: ReadonlyArray<string>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DecisionView {
|
|
32
|
+
readonly id: string
|
|
33
|
+
readonly chosen: string | undefined
|
|
34
|
+
readonly switched: boolean
|
|
35
|
+
readonly switchReason: string
|
|
36
|
+
readonly taskKind: string
|
|
37
|
+
readonly taskTier: number
|
|
38
|
+
readonly contextTokens: number
|
|
39
|
+
readonly estimatedUsd: number
|
|
40
|
+
readonly reasons: ReadonlyArray<string>
|
|
41
|
+
readonly warnings: ReadonlyArray<string>
|
|
42
|
+
/** One line about a self-healed deterministic rejection, when the decision is one. */
|
|
43
|
+
readonly selfheal?: string
|
|
44
|
+
readonly eligibleCount: number
|
|
45
|
+
readonly blockedCount: number
|
|
46
|
+
readonly rows: ReadonlyArray<CandidateRow>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type WindowID = "fiveHour" | "weekly" | "monthly"
|
|
50
|
+
|
|
51
|
+
export interface QuotaWindowView {
|
|
52
|
+
readonly id: WindowID
|
|
53
|
+
/** Short label used in the panel: 5h, 7d, Monthly. */
|
|
54
|
+
readonly label: string
|
|
55
|
+
readonly usedPct: number
|
|
56
|
+
readonly remainingPct: number
|
|
57
|
+
readonly usedUsd?: number
|
|
58
|
+
readonly capUsd?: number
|
|
59
|
+
readonly resetsAt?: number
|
|
60
|
+
readonly resetLabel?: string
|
|
61
|
+
readonly source: "provider" | "estimate" | "unknown"
|
|
62
|
+
readonly sourceLabel: string
|
|
63
|
+
readonly exceeded: boolean
|
|
64
|
+
/**
|
|
65
|
+
* Whether the provider reports this window at all. A window that does not
|
|
66
|
+
* exist for a plan is different from a window at 0%, and showing "0%" for a
|
|
67
|
+
* five-hour cap that does not apply would be a lie.
|
|
68
|
+
*/
|
|
69
|
+
readonly reported: boolean
|
|
70
|
+
/**
|
|
71
|
+
* True when the figure is a lower bound the router derived itself, not a
|
|
72
|
+
* measurement from the provider.
|
|
73
|
+
*
|
|
74
|
+
* This matters more than it looks. OpenDesign publishes no quota endpoint for
|
|
75
|
+
* an inference key, so the router estimates from the traffic it routed itself.
|
|
76
|
+
* A user whose account was already 42% used before installing the router saw
|
|
77
|
+
* "5% monthly" — a true statement about the router's own traffic, and a
|
|
78
|
+
* dangerously wrong impression of their account. A lower bound must never be
|
|
79
|
+
* rendered as a measurement.
|
|
80
|
+
*/
|
|
81
|
+
readonly lowerBound: boolean
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface PoolRow {
|
|
85
|
+
readonly id: string
|
|
86
|
+
readonly label: string
|
|
87
|
+
readonly source: "provider" | "estimate" | "unknown"
|
|
88
|
+
readonly sourceLabel: string
|
|
89
|
+
readonly monthlyUsd: number
|
|
90
|
+
readonly usedUsd: number
|
|
91
|
+
readonly remainingUsd: number
|
|
92
|
+
readonly usedPct: number
|
|
93
|
+
readonly weeklyPct?: number
|
|
94
|
+
readonly fiveHourPct?: number
|
|
95
|
+
readonly resetLabel?: string
|
|
96
|
+
readonly note: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ProviderRow {
|
|
100
|
+
readonly providerID: string
|
|
101
|
+
readonly label: string
|
|
102
|
+
readonly plan: string
|
|
103
|
+
/** `provider` and `estimate` are facts of different strength and are never merged. */
|
|
104
|
+
readonly source: "provider" | "estimate" | "unknown"
|
|
105
|
+
readonly sourceLabel: string
|
|
106
|
+
readonly quotaState?: string
|
|
107
|
+
readonly stale: boolean
|
|
108
|
+
readonly fetchedAt: number
|
|
109
|
+
/** The tightest window that is exhausted, if any. */
|
|
110
|
+
readonly exhausted: boolean
|
|
111
|
+
readonly exhaustedWindow?: string
|
|
112
|
+
/** 5h, 7d, monthly — always all three, with `reported` telling the truth. */
|
|
113
|
+
readonly windows: ReadonlyArray<QuotaWindowView>
|
|
114
|
+
readonly pools: ReadonlyArray<PoolRow>
|
|
115
|
+
readonly monthlyRemainingUsd?: number
|
|
116
|
+
readonly extraCreditsUsd?: number
|
|
117
|
+
readonly error?: string
|
|
118
|
+
readonly notes: ReadonlyArray<string>
|
|
119
|
+
/** The sentence that explains why a figure is a lower bound, if it is one. */
|
|
120
|
+
readonly caveat?: string
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const sourceLabelOf = (source: "provider" | "estimate" | "unknown"): string => {
|
|
124
|
+
switch (source) {
|
|
125
|
+
case "provider":
|
|
126
|
+
return "verified by provider"
|
|
127
|
+
case "estimate":
|
|
128
|
+
return "estimated — lower bound"
|
|
129
|
+
default:
|
|
130
|
+
return "not reported"
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const windowLabels: Record<WindowID, string> = {
|
|
135
|
+
fiveHour: "5h",
|
|
136
|
+
weekly: "7d",
|
|
137
|
+
monthly: "Monthly",
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Human reset time. Absolute once it is far away; relative while it matters. */
|
|
141
|
+
export const formatReset = (at: number | undefined, now = Date.now()): string | undefined => {
|
|
142
|
+
if (at === undefined) return undefined
|
|
143
|
+
const delta = at - now
|
|
144
|
+
if (delta <= 0) return "resetting"
|
|
145
|
+
const minutes = Math.round(delta / 60_000)
|
|
146
|
+
if (minutes < 60) return `in ${minutes}m`
|
|
147
|
+
const hours = Math.round(minutes / 60)
|
|
148
|
+
if (hours < 36) return `in ${hours}h`
|
|
149
|
+
const days = Math.round(hours / 24)
|
|
150
|
+
return `in ${days}d`
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const toWindow = (
|
|
154
|
+
id: WindowID,
|
|
155
|
+
window: { usedPct?: number; remainingPct?: number; usedUsd?: number; capUsd?: number; resetsAt?: number; exceeded?: boolean; source?: "provider" | "estimate" | "unknown" } | undefined,
|
|
156
|
+
now: number,
|
|
157
|
+
): QuotaWindowView => {
|
|
158
|
+
const source = window?.source ?? "unknown"
|
|
159
|
+
const usedPct = window?.usedPct ?? 0
|
|
160
|
+
// A window is reported when the provider said anything about it. A cap with no
|
|
161
|
+
// source is an absent window, not an empty one.
|
|
162
|
+
const reported = source !== "unknown" && (window?.capUsd !== undefined || usedPct > 0)
|
|
163
|
+
return {
|
|
164
|
+
id,
|
|
165
|
+
label: windowLabels[id],
|
|
166
|
+
usedPct,
|
|
167
|
+
remainingPct: window?.remainingPct ?? Math.max(0, 100 - usedPct),
|
|
168
|
+
...(window?.usedUsd !== undefined ? { usedUsd: window.usedUsd } : {}),
|
|
169
|
+
...(window?.capUsd !== undefined ? { capUsd: window.capUsd } : {}),
|
|
170
|
+
...(window?.resetsAt !== undefined ? { resetsAt: window.resetsAt } : {}),
|
|
171
|
+
...(formatReset(window?.resetsAt, now) ? { resetLabel: formatReset(window?.resetsAt, now)! } : {}),
|
|
172
|
+
source,
|
|
173
|
+
sourceLabel: sourceLabelOf(source),
|
|
174
|
+
exceeded: Boolean(window?.exceeded),
|
|
175
|
+
reported,
|
|
176
|
+
lowerBound: source === "estimate",
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export const toProviderRow = (snapshot: ProviderQuotaSnapshot, now = Date.now()): ProviderRow => {
|
|
181
|
+
const source: ProviderRow["source"] =
|
|
182
|
+
snapshot.monthly?.source === "provider"
|
|
183
|
+
? "provider"
|
|
184
|
+
: snapshot.monthly?.source === "estimate"
|
|
185
|
+
? "estimate"
|
|
186
|
+
: "unknown"
|
|
187
|
+
const windows = [
|
|
188
|
+
toWindow("fiveHour", snapshot.rolling, now),
|
|
189
|
+
toWindow("weekly", snapshot.weekly, now),
|
|
190
|
+
toWindow("monthly", snapshot.monthly, now),
|
|
191
|
+
]
|
|
192
|
+
const exhaustedWindow = windows.find((window) => window.exceeded)
|
|
193
|
+
const pools: PoolRow[] = (snapshot.pools ?? []).map((pool) => ({
|
|
194
|
+
id: pool.id,
|
|
195
|
+
label: pool.label,
|
|
196
|
+
source: pool.source,
|
|
197
|
+
sourceLabel: sourceLabelOf(pool.source),
|
|
198
|
+
monthlyUsd: pool.monthlyUsd,
|
|
199
|
+
usedUsd: pool.usedUsd,
|
|
200
|
+
remainingUsd: pool.remainingUsd,
|
|
201
|
+
usedPct: pool.usedPct,
|
|
202
|
+
...(pool.weeklyUsedPct !== undefined ? { weeklyPct: pool.weeklyUsedPct } : {}),
|
|
203
|
+
...(pool.fiveHourUsedPct !== undefined ? { fiveHourPct: pool.fiveHourUsedPct } : {}),
|
|
204
|
+
...(formatReset(pool.resetsAt, now) ? { resetLabel: formatReset(pool.resetsAt, now)! } : {}),
|
|
205
|
+
note: pool.note,
|
|
206
|
+
}))
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
providerID: snapshot.providerID,
|
|
210
|
+
label: snapshot.label ?? snapshot.providerID,
|
|
211
|
+
plan: snapshot.plan ?? "",
|
|
212
|
+
source,
|
|
213
|
+
sourceLabel: sourceLabelOf(source),
|
|
214
|
+
...(snapshot.quotaState ? { quotaState: snapshot.quotaState } : {}),
|
|
215
|
+
stale: Boolean(snapshot.stale),
|
|
216
|
+
fetchedAt: snapshot.fetchedAt ?? now,
|
|
217
|
+
exhausted: Boolean(exhaustedWindow),
|
|
218
|
+
...(exhaustedWindow ? { exhaustedWindow: exhaustedWindow.label } : {}),
|
|
219
|
+
windows,
|
|
220
|
+
pools,
|
|
221
|
+
...(snapshot.monthlyRemainingUsd !== undefined ? { monthlyRemainingUsd: snapshot.monthlyRemainingUsd } : {}),
|
|
222
|
+
...(snapshot.extraCredits ? { extraCreditsUsd: snapshot.extraCredits.totalUsd } : {}),
|
|
223
|
+
...(snapshot.error ? { error: snapshot.error } : {}),
|
|
224
|
+
notes: snapshot.notes ?? [],
|
|
225
|
+
...(source === "estimate" ? { caveat: (snapshot.notes ?? [])[0] } : {}),
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Money at the precision the router actually estimates in. */
|
|
230
|
+
export const formatUsd = (value: number): string => {
|
|
231
|
+
if (!Number.isFinite(value)) return "—"
|
|
232
|
+
if (value === 0) return "$0"
|
|
233
|
+
if (value < 0.01) return `$${value.toFixed(4)}`
|
|
234
|
+
if (value < 1) return `$${value.toFixed(3)}`
|
|
235
|
+
return `$${value.toFixed(2)}`
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export const formatPercent = (value: number): string => (Number.isFinite(value) ? `${Math.round(value)}%` : "—")
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* A percentage as the panel should show it.
|
|
242
|
+
*
|
|
243
|
+
* `≥` is not decoration: it tells the reader the real figure is at least this,
|
|
244
|
+
* and may be far higher. Without it, an estimate reads as a measurement.
|
|
245
|
+
*/
|
|
246
|
+
export const formatWindowUsed = (window: { usedPct: number; lowerBound: boolean; reported: boolean }): string => {
|
|
247
|
+
if (!window.reported) return "—"
|
|
248
|
+
const value = formatPercent(window.usedPct)
|
|
249
|
+
return window.lowerBound ? `≥${value}` : value
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Explain the multipliers that changed a candidate's rank.
|
|
254
|
+
*
|
|
255
|
+
* These exist because a user who sees two prices and one winner will assume the
|
|
256
|
+
* cheaper one won. When it did not, the panel has to say which multiplier moved
|
|
257
|
+
* it, or the ranking looks arbitrary.
|
|
258
|
+
*/
|
|
259
|
+
export const rankingAdjustments = (candidate: CandidateEvaluation): ReadonlyArray<string> => {
|
|
260
|
+
const notes: string[] = []
|
|
261
|
+
const credit = candidate.creditMultiplier
|
|
262
|
+
if (credit !== undefined && credit !== 1) notes.push(`${credit}x plan credits`)
|
|
263
|
+
const speed = candidate.speedFactor
|
|
264
|
+
if (speed !== undefined && speed !== 1) notes.push(`${speed}x measured speed`)
|
|
265
|
+
const marginal = candidate.marginalPriceFactor
|
|
266
|
+
if (marginal !== undefined && marginal !== 1) notes.push(`${marginal}x expiring allowance`)
|
|
267
|
+
if (candidate.switchPenaltyUsd > 0) notes.push(`${formatUsd(candidate.switchPenaltyUsd)} switch cost`)
|
|
268
|
+
return notes
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** `provider/model`, the same shape the engine uses for model identity. */
|
|
272
|
+
export const keyOf = (model: ModelKey): string => `${model.providerID}/${model.modelID}`
|
|
273
|
+
|
|
274
|
+
export const toCandidateRow = (candidate: CandidateEvaluation, chosen: string | undefined): CandidateRow => {
|
|
275
|
+
const key = keyOf(candidate.model)
|
|
276
|
+
return {
|
|
277
|
+
key,
|
|
278
|
+
name: candidate.name,
|
|
279
|
+
tier: candidate.tier,
|
|
280
|
+
verdict: key === chosen && candidate.eligible ? "chosen" : candidate.eligible ? "eligible" : "blocked",
|
|
281
|
+
reason: candidate.reason,
|
|
282
|
+
estimatedUsd: candidate.estimatedUsd,
|
|
283
|
+
effectiveCost: candidate.effectiveCost,
|
|
284
|
+
successProbability: candidate.successProbability,
|
|
285
|
+
adjustments: rankingAdjustments(candidate),
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Order for display: what won, then what could have, cheapest first, then what
|
|
291
|
+
* could not and why. A blocked model is never hidden — "why can't I use this"
|
|
292
|
+
* is the question a user asks most often.
|
|
293
|
+
*/
|
|
294
|
+
const verdictRank: Record<Verdict, number> = { chosen: 0, eligible: 1, blocked: 2 }
|
|
295
|
+
|
|
296
|
+
export const orderCandidateRows = (rows: ReadonlyArray<CandidateRow>): ReadonlyArray<CandidateRow> =>
|
|
297
|
+
[...rows].sort((left, right) => {
|
|
298
|
+
const byVerdict = verdictRank[left.verdict] - verdictRank[right.verdict]
|
|
299
|
+
if (byVerdict !== 0) return byVerdict
|
|
300
|
+
if (left.verdict === "blocked") return left.key.localeCompare(right.key)
|
|
301
|
+
return left.effectiveCost - right.effectiveCost || left.key.localeCompare(right.key)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
export const summariseDecision = (decision: RoutingDecision | undefined | null): DecisionView | undefined => {
|
|
305
|
+
if (!decision) return undefined
|
|
306
|
+
const chosenKey = decision.chosen ? `${decision.chosen.providerID}/${decision.chosen.id}` : undefined
|
|
307
|
+
const rows = orderCandidateRows(decision.candidates.map((candidate) => toCandidateRow(candidate, chosenKey)))
|
|
308
|
+
return {
|
|
309
|
+
id: decision.id,
|
|
310
|
+
chosen: chosenKey,
|
|
311
|
+
switched: decision.switched,
|
|
312
|
+
switchReason: decision.switchReason,
|
|
313
|
+
taskKind: decision.task?.kind ?? "unknown",
|
|
314
|
+
taskTier: decision.task?.tier ?? 0,
|
|
315
|
+
contextTokens: decision.contextTokens,
|
|
316
|
+
estimatedUsd: decision.estimatedUsd,
|
|
317
|
+
reasons: decision.reasons ?? [],
|
|
318
|
+
warnings: decision.warnings ?? [],
|
|
319
|
+
selfheal: decision.selfheal?.rejected
|
|
320
|
+
? `rejected ${decision.selfheal.rejected} · ${
|
|
321
|
+
decision.selfheal.action === "noted" ? "noted for the next prompt" : decision.selfheal.action
|
|
322
|
+
}`
|
|
323
|
+
: undefined,
|
|
324
|
+
eligibleCount: rows.filter((row) => row.verdict !== "blocked").length,
|
|
325
|
+
blockedCount: rows.filter((row) => row.verdict === "blocked").length,
|
|
326
|
+
rows,
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** The one-line state shown above the decision, and in the footer slot. */
|
|
331
|
+
export const statusLine = (input: {
|
|
332
|
+
runtime?: string
|
|
333
|
+
mode?: string
|
|
334
|
+
current?: { providerID: string; id: string } | undefined
|
|
335
|
+
}): string => {
|
|
336
|
+
const parts = [input.runtime ?? "unknown", input.mode ?? "unknown"]
|
|
337
|
+
parts.push(input.current ? `${input.current.providerID}/${input.current.id}` : "no model yet")
|
|
338
|
+
return parts.join(" · ")
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// Providers and models (P3)
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
export interface ModelRowView {
|
|
346
|
+
readonly key: string
|
|
347
|
+
readonly providerID: string
|
|
348
|
+
readonly name: string
|
|
349
|
+
readonly tier: number
|
|
350
|
+
readonly intelligence: number
|
|
351
|
+
readonly design: number
|
|
352
|
+
readonly contextLabel: string
|
|
353
|
+
readonly costLabel: string
|
|
354
|
+
readonly vision: boolean
|
|
355
|
+
readonly state: "approved" | "excluded" | "not approved"
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export interface ProviderGroupView {
|
|
359
|
+
readonly providerID: string
|
|
360
|
+
readonly label: string
|
|
361
|
+
readonly state: "active" | "not configured"
|
|
362
|
+
readonly models: ReadonlyArray<ModelRowView>
|
|
363
|
+
readonly approvedCount: number
|
|
364
|
+
readonly totalCount: number
|
|
365
|
+
/** Why this provider is not being used, when it is not. */
|
|
366
|
+
readonly note?: string
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const contextLabel = (tokens: number): string => {
|
|
370
|
+
if (!Number.isFinite(tokens) || tokens <= 0) return "unknown"
|
|
371
|
+
// "1M" reads better than "1.0M"; a fraction still needs its decimal.
|
|
372
|
+
return tokens >= 1_000_000
|
|
373
|
+
? `${Number((tokens / 1_000_000).toFixed(1))}M`
|
|
374
|
+
: `${Math.round(tokens / 1000)}k`
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Providers and their models, as a user needs them to answer "why is that model
|
|
379
|
+
* not being used?".
|
|
380
|
+
*
|
|
381
|
+
* The distinction that matters is between a provider that is off and a model
|
|
382
|
+
* that is unapproved: one is a decision about an account, the other about a
|
|
383
|
+
* model. Collapsing them into "unavailable" is what makes people think a model
|
|
384
|
+
* is missing when it is simply not enabled.
|
|
385
|
+
*/
|
|
386
|
+
export const providerGroups = (input: {
|
|
387
|
+
models: ReadonlyArray<{
|
|
388
|
+
key: string
|
|
389
|
+
providerID: string
|
|
390
|
+
name: string
|
|
391
|
+
tier: number
|
|
392
|
+
intelligence: number
|
|
393
|
+
design: number
|
|
394
|
+
vision: boolean
|
|
395
|
+
context: number
|
|
396
|
+
costInput: number
|
|
397
|
+
costOutput: number
|
|
398
|
+
approved: boolean
|
|
399
|
+
excluded: boolean
|
|
400
|
+
}>
|
|
401
|
+
quota: ReadonlyArray<{ providerID: string; label?: string }>
|
|
402
|
+
/** Provider ids the router will route to right now. */
|
|
403
|
+
activeProviders: ReadonlyArray<string>
|
|
404
|
+
}): ReadonlyArray<ProviderGroupView> => {
|
|
405
|
+
const byProvider = new Map<string, ModelRowView[]>()
|
|
406
|
+
for (const model of input.models) {
|
|
407
|
+
const state: ModelRowView["state"] = model.excluded
|
|
408
|
+
? "excluded"
|
|
409
|
+
: model.approved
|
|
410
|
+
? "approved"
|
|
411
|
+
: "not approved"
|
|
412
|
+
const row: ModelRowView = {
|
|
413
|
+
key: model.key,
|
|
414
|
+
providerID: model.providerID,
|
|
415
|
+
name: model.name,
|
|
416
|
+
tier: model.tier,
|
|
417
|
+
intelligence: model.intelligence,
|
|
418
|
+
design: model.design,
|
|
419
|
+
contextLabel: contextLabel(model.context),
|
|
420
|
+
costLabel: `${formatUsd(model.costInput)} / ${formatUsd(model.costOutput)} per 1M`,
|
|
421
|
+
vision: model.vision,
|
|
422
|
+
state,
|
|
423
|
+
}
|
|
424
|
+
const list = byProvider.get(model.providerID) ?? []
|
|
425
|
+
list.push(row)
|
|
426
|
+
byProvider.set(model.providerID, list)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const quotaLabel = new Map(input.quota.map((entry) => [entry.providerID, entry.label ?? entry.providerID]))
|
|
430
|
+
|
|
431
|
+
return [...byProvider.entries()]
|
|
432
|
+
.map(([providerID, models]) => {
|
|
433
|
+
const active = input.activeProviders.includes(providerID)
|
|
434
|
+
const approvedCount = models.filter((model) => model.state === "approved").length
|
|
435
|
+
return {
|
|
436
|
+
providerID,
|
|
437
|
+
label: quotaLabel.get(providerID) ?? providerID,
|
|
438
|
+
state: active ? ("active" as const) : ("not configured" as const),
|
|
439
|
+
// Approved first, then by tier and cost: the order a routing decision
|
|
440
|
+
// would consider them.
|
|
441
|
+
models: [...models].sort((left, right) => {
|
|
442
|
+
const rank = (row: ModelRowView) => (row.state === "approved" ? 0 : row.state === "excluded" ? 2 : 1)
|
|
443
|
+
return rank(left) - rank(right) || left.tier - right.tier || left.key.localeCompare(right.key)
|
|
444
|
+
}),
|
|
445
|
+
approvedCount,
|
|
446
|
+
totalCount: models.length,
|
|
447
|
+
...(active
|
|
448
|
+
? {}
|
|
449
|
+
: { note: "Not routing here: no credential found for this provider on this machine." }),
|
|
450
|
+
}
|
|
451
|
+
})
|
|
452
|
+
.sort((left, right) => {
|
|
453
|
+
if (left.state !== right.state) return left.state === "active" ? -1 : 1
|
|
454
|
+
return left.label.localeCompare(right.label)
|
|
455
|
+
})
|
|
456
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin"
|
|
2
|
+
import { RouterController } from "./server/controller.js"
|
|
3
|
+
import { sharedCore } from "./server/shared-core.js"
|
|
4
|
+
|
|
5
|
+
const describe = (error: unknown) => (error instanceof Error ? error.message : String(error))
|
|
6
|
+
|
|
7
|
+
export default Plugin.define({
|
|
8
|
+
id: "opencode-smart-router",
|
|
9
|
+
async setup(ctx) {
|
|
10
|
+
const controller = new RouterController({ ctx, pluginOptions: ctx.options })
|
|
11
|
+
try {
|
|
12
|
+
return await controller.initialize()
|
|
13
|
+
} catch (error) {
|
|
14
|
+
// A broken location must not take down the host or the other bridges, and
|
|
15
|
+
// it must not take the owner's ability to send a prompt with it.
|
|
16
|
+
//
|
|
17
|
+
// This branch used to install a "minimal fail-closed hook" that rejected
|
|
18
|
+
// every prompt until the next reload. That turned a failed setup into a
|
|
19
|
+
// disabled editor, which is the worst possible response to a plugin that
|
|
20
|
+
// is already not working. Verified in scripts/isolated-e2e.sh: a plugin
|
|
21
|
+
// hook that throws fails the request outright, so a hook here is not a
|
|
22
|
+
// safety net.
|
|
23
|
+
//
|
|
24
|
+
// The correct outcome is that this location has no router. OpenCode then
|
|
25
|
+
// behaves exactly as it would with the plugin uninstalled, which is the
|
|
26
|
+
// guarantee the plugin owes its host. The failure stays visible in
|
|
27
|
+
// diagnostics and in the status RPC of any bridge that did start.
|
|
28
|
+
sharedCore.recordError(`setup ${ctx.location?.directory ?? ""}: ${describe(error)}`)
|
|
29
|
+
await controller.dispose().catch(() => undefined)
|
|
30
|
+
return () => undefined
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
})
|
package/src/rpc.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { Rpc } from "@opencode/plugin/rpc"
|
|
2
|
+
|
|
3
|
+
const objectOutput = { type: "object", additionalProperties: true } as const
|
|
4
|
+
const emptyInput = { type: "object", properties: {}, additionalProperties: false } as const
|
|
5
|
+
const sessionInput = {
|
|
6
|
+
type: "object",
|
|
7
|
+
properties: { sessionID: { type: "string" } },
|
|
8
|
+
required: ["sessionID"],
|
|
9
|
+
additionalProperties: false,
|
|
10
|
+
} as const
|
|
11
|
+
|
|
12
|
+
const method = (input: any) => ({ input, output: objectOutput })
|
|
13
|
+
|
|
14
|
+
export const SmartRouterRpc = Rpc.define({
|
|
15
|
+
id: "smart-router",
|
|
16
|
+
methods: {
|
|
17
|
+
status: method({
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: { sessionID: { type: "string" } },
|
|
20
|
+
additionalProperties: false,
|
|
21
|
+
}),
|
|
22
|
+
preview: method({
|
|
23
|
+
type: "object",
|
|
24
|
+
properties: {
|
|
25
|
+
sessionID: { type: "string" },
|
|
26
|
+
text: { type: "string" },
|
|
27
|
+
files: { type: "array", items: { type: "object", additionalProperties: true } },
|
|
28
|
+
},
|
|
29
|
+
required: ["sessionID", "text"],
|
|
30
|
+
additionalProperties: false,
|
|
31
|
+
}),
|
|
32
|
+
setMode: method({
|
|
33
|
+
type: "object",
|
|
34
|
+
properties: { mode: { type: "string", enum: ["efficiency", "balanced", "premium"] } },
|
|
35
|
+
required: ["mode"],
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
}),
|
|
38
|
+
setPaused: method({
|
|
39
|
+
type: "object",
|
|
40
|
+
properties: { paused: { type: "boolean" } },
|
|
41
|
+
required: ["paused"],
|
|
42
|
+
additionalProperties: false,
|
|
43
|
+
}),
|
|
44
|
+
/**
|
|
45
|
+
* Turn automatic routing on or off for one session.
|
|
46
|
+
*
|
|
47
|
+
* Not the same as disabling the plugin or pausing every session: the router
|
|
48
|
+
* stays loaded, keeps reporting quota and decisions, and simply stops
|
|
49
|
+
* changing the model. The user keeps whatever model they have.
|
|
50
|
+
*/
|
|
51
|
+
setSessionAuto: method({
|
|
52
|
+
type: "object",
|
|
53
|
+
properties: { sessionID: { type: "string" }, auto: { type: "boolean" } },
|
|
54
|
+
required: ["sessionID", "auto"],
|
|
55
|
+
additionalProperties: false,
|
|
56
|
+
}),
|
|
57
|
+
setDryRun: method({
|
|
58
|
+
type: "object",
|
|
59
|
+
properties: { dryRun: { type: "boolean" } },
|
|
60
|
+
required: ["dryRun"],
|
|
61
|
+
additionalProperties: false,
|
|
62
|
+
}),
|
|
63
|
+
resumeAuto: method(sessionInput),
|
|
64
|
+
pin: method({
|
|
65
|
+
type: "object",
|
|
66
|
+
properties: {
|
|
67
|
+
sessionID: { type: "string" },
|
|
68
|
+
providerID: { type: "string" },
|
|
69
|
+
modelID: { type: "string" },
|
|
70
|
+
variant: { type: "string" },
|
|
71
|
+
},
|
|
72
|
+
required: ["sessionID", "providerID", "modelID"],
|
|
73
|
+
additionalProperties: false,
|
|
74
|
+
}),
|
|
75
|
+
approvePremium: method(sessionInput),
|
|
76
|
+
denyPremium: method(sessionInput),
|
|
77
|
+
excludeModel: method({
|
|
78
|
+
type: "object",
|
|
79
|
+
properties: {
|
|
80
|
+
providerID: { type: "string" },
|
|
81
|
+
modelID: { type: "string" },
|
|
82
|
+
excluded: { type: "boolean" },
|
|
83
|
+
},
|
|
84
|
+
required: ["providerID", "modelID", "excluded"],
|
|
85
|
+
additionalProperties: false,
|
|
86
|
+
}),
|
|
87
|
+
setPremiumBudget: method({
|
|
88
|
+
type: "object",
|
|
89
|
+
properties: { usd: { type: "number", minimum: 0, maximum: 100000 } },
|
|
90
|
+
required: ["usd"],
|
|
91
|
+
additionalProperties: false,
|
|
92
|
+
}),
|
|
93
|
+
setReserve: method({
|
|
94
|
+
type: "object",
|
|
95
|
+
properties: {
|
|
96
|
+
providerID: { type: "string" },
|
|
97
|
+
monthlyPct: { type: "number", minimum: 0, maximum: 90 },
|
|
98
|
+
weeklyPct: { type: "number", minimum: 0, maximum: 90 },
|
|
99
|
+
fiveHourPct: { type: "number", minimum: 0, maximum: 90 },
|
|
100
|
+
},
|
|
101
|
+
required: ["providerID"],
|
|
102
|
+
additionalProperties: false,
|
|
103
|
+
}),
|
|
104
|
+
/**
|
|
105
|
+
* The intelligence floor in force, and why.
|
|
106
|
+
*
|
|
107
|
+
* Calibration can only be trusted if its result is visible: a number with
|
|
108
|
+
* no explanation is indistinguishable from the hand-picked one it replaced.
|
|
109
|
+
* This previews without enabling anything.
|
|
110
|
+
*/
|
|
111
|
+
floor: method(emptyInput),
|
|
112
|
+
setCalibrateFloor: method({
|
|
113
|
+
type: "object",
|
|
114
|
+
properties: { enabled: { type: "boolean" } },
|
|
115
|
+
required: ["enabled"],
|
|
116
|
+
additionalProperties: false,
|
|
117
|
+
}),
|
|
118
|
+
refreshQuota: method(emptyInput),
|
|
119
|
+
/**
|
|
120
|
+
* What this machine has, for the first-run screen.
|
|
121
|
+
*
|
|
122
|
+
* Onboarding must show evidence, not a questionnaire. The router already
|
|
123
|
+
* detects credentials and imports history at startup; this reports what it
|
|
124
|
+
* found so the first thing a new user sees is the truth about their own
|
|
125
|
+
* machine rather than a form asking them to type it in.
|
|
126
|
+
*/
|
|
127
|
+
onboarding: method(emptyInput),
|
|
128
|
+
/** Mark the first run as seen, so it does not reappear. */
|
|
129
|
+
completeOnboarding: method(emptyInput),
|
|
130
|
+
/**
|
|
131
|
+
* Record what the user's account page showed, so the estimate reflects the
|
|
132
|
+
* whole account rather than only the traffic this router routed.
|
|
133
|
+
*
|
|
134
|
+
* Takes the percentage the page displays, not a dollar figure: asking a user
|
|
135
|
+
* to divide by a cap they may not know is where the arithmetic mistakes come
|
|
136
|
+
* from. The server converts it against the window the page was describing.
|
|
137
|
+
*/
|
|
138
|
+
/**
|
|
139
|
+
* Record the account's balance page, pasted whole.
|
|
140
|
+
*
|
|
141
|
+
* One field, because that is the actual task: select the page, copy, paste.
|
|
142
|
+
* The server reads the plan, the percentage, which window it belongs to, the
|
|
143
|
+
* reset time, the billing period and the wallet out of the text. A form with
|
|
144
|
+
* four boxes asks the user to do that parsing by hand and get it right.
|
|
145
|
+
*/
|
|
146
|
+
recordPlanPage: method({
|
|
147
|
+
type: "object",
|
|
148
|
+
properties: {
|
|
149
|
+
/** The pasted page text. */
|
|
150
|
+
page: { type: "string" },
|
|
151
|
+
/** Apply it without previewing. Defaults to false: the user confirms first. */
|
|
152
|
+
confirm: { type: "boolean" },
|
|
153
|
+
},
|
|
154
|
+
required: ["page"],
|
|
155
|
+
additionalProperties: false,
|
|
156
|
+
}),
|
|
157
|
+
report: method(emptyInput),
|
|
158
|
+
usage: method(emptyInput),
|
|
159
|
+
getConfig: method(emptyInput),
|
|
160
|
+
resetOverrides: method(emptyInput),
|
|
161
|
+
resetMetrics: method(emptyInput),
|
|
162
|
+
refreshIndicator: method(sessionInput),
|
|
163
|
+
diagnostics: method(emptyInput),
|
|
164
|
+
setTitleIndicator: method({
|
|
165
|
+
type: "object",
|
|
166
|
+
properties: { enabled: { type: "boolean" } },
|
|
167
|
+
required: ["enabled"],
|
|
168
|
+
additionalProperties: false,
|
|
169
|
+
}),
|
|
170
|
+
},
|
|
171
|
+
events: {
|
|
172
|
+
decision: {
|
|
173
|
+
schema: {
|
|
174
|
+
type: "object",
|
|
175
|
+
properties: {
|
|
176
|
+
decision: { type: "object", additionalProperties: true },
|
|
177
|
+
},
|
|
178
|
+
required: ["decision"],
|
|
179
|
+
additionalProperties: false,
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
status: {
|
|
183
|
+
schema: {
|
|
184
|
+
type: "object",
|
|
185
|
+
properties: {
|
|
186
|
+
sessionID: { type: "string" },
|
|
187
|
+
summary: { type: "object", additionalProperties: true },
|
|
188
|
+
},
|
|
189
|
+
required: ["summary"],
|
|
190
|
+
additionalProperties: false,
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
quota: {
|
|
194
|
+
schema: {
|
|
195
|
+
type: "object",
|
|
196
|
+
properties: {
|
|
197
|
+
providers: { type: "array", items: { type: "object", additionalProperties: true } },
|
|
198
|
+
},
|
|
199
|
+
required: ["providers"],
|
|
200
|
+
additionalProperties: false,
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
})
|