@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
|
@@ -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,48 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin"
|
|
2
|
+
import { RouterBlockedError, RouterController } from "./server/controller.js"
|
|
3
|
+
import { stripTitlePrefix } from "./core/ui.js"
|
|
4
|
+
import { sharedCore } from "./server/shared-core.js"
|
|
5
|
+
|
|
6
|
+
const describe = (error: unknown) => (error instanceof Error ? error.message : String(error))
|
|
7
|
+
|
|
8
|
+
export default Plugin.define({
|
|
9
|
+
id: "opencode-smart-router",
|
|
10
|
+
async setup(ctx) {
|
|
11
|
+
const controller = new RouterController({ ctx, pluginOptions: ctx.options })
|
|
12
|
+
try {
|
|
13
|
+
return await controller.initialize()
|
|
14
|
+
} catch (error) {
|
|
15
|
+
// A broken location must not take down the host or the other bridges.
|
|
16
|
+
sharedCore.recordError(`setup ${ctx.location?.directory ?? ""}: ${describe(error)}`)
|
|
17
|
+
await controller.dispose().catch(() => undefined)
|
|
18
|
+
// A broken location must not silently execute prompts with no routing.
|
|
19
|
+
// Keep a minimal fail-closed hook until the next reload.
|
|
20
|
+
try {
|
|
21
|
+
await ctx.session.hook("prompt", async (event: any) => {
|
|
22
|
+
const sessionID = event?.sessionID
|
|
23
|
+
if (!sessionID) throw new RouterBlockedError("Smart Router setup failed")
|
|
24
|
+
let info: any
|
|
25
|
+
try {
|
|
26
|
+
info = await ctx.session.get({ sessionID })
|
|
27
|
+
} catch {
|
|
28
|
+
info = undefined
|
|
29
|
+
}
|
|
30
|
+
const localID = ctx.location?.project?.id
|
|
31
|
+
if (info?.projectID && localID && info.projectID !== localID) return
|
|
32
|
+
if (info) {
|
|
33
|
+
try {
|
|
34
|
+
const base = stripTitlePrefix(typeof info.title === "string" ? info.title : "") || "Session"
|
|
35
|
+
await ctx.session.update({ sessionID, title: `BLOCKED · router setup failed — ${base}` })
|
|
36
|
+
} catch {
|
|
37
|
+
// The rejection below still stops admission.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
throw new RouterBlockedError("Smart Router setup failed; routing is unavailable")
|
|
41
|
+
})
|
|
42
|
+
} catch (hookError) {
|
|
43
|
+
sharedCore.recordError(`setup fallback hook ${ctx.location?.directory ?? ""}: ${describe(hookError)}`)
|
|
44
|
+
}
|
|
45
|
+
return () => undefined
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
})
|