@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.
Files changed (65) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/CONTRIBUTING.md +96 -0
  3. package/LICENSE +21 -0
  4. package/README.md +341 -2
  5. package/SECURITY.md +174 -0
  6. package/config.example.json +108 -0
  7. package/dist/allowance.js +487 -0
  8. package/dist/gui-experimental.cjs +1 -0
  9. package/dist/index.js +11111 -0
  10. package/dist/npm/index.js +40 -0
  11. package/dist/tui.js +587 -0
  12. package/docs/ARCHITECTURE.md +104 -0
  13. package/docs/DEBUGGING-GUI.md +70 -0
  14. package/docs/INSTALL.md +120 -0
  15. package/docs/LIMITATIONS.md +213 -0
  16. package/docs/MODELS.md +124 -0
  17. package/docs/OPENDESIGN-INTEGRATION.md +635 -0
  18. package/docs/QA.md +142 -0
  19. package/docs/SHARED-CORE.md +72 -0
  20. package/install +75 -0
  21. package/package.json +92 -4
  22. package/scripts/install.sh +324 -0
  23. package/scripts/uninstall.sh +91 -0
  24. package/src/allowance.ts +135 -0
  25. package/src/core/classify/classifier.ts +305 -0
  26. package/src/core/config-writer.ts +167 -0
  27. package/src/core/config.ts +1030 -0
  28. package/src/core/context/strategy.ts +188 -0
  29. package/src/core/context-health.ts +39 -0
  30. package/src/core/failures.ts +398 -0
  31. package/src/core/history/opencode.ts +173 -0
  32. package/src/core/models/protocol.ts +22 -0
  33. package/src/core/models/registry.ts +203 -0
  34. package/src/core/models/seed.ts +771 -0
  35. package/src/core/offline/mode.ts +321 -0
  36. package/src/core/plan-page.ts +390 -0
  37. package/src/core/plans/catalog.ts +481 -0
  38. package/src/core/policy/calibration.ts +259 -0
  39. package/src/core/policy/eligibility.ts +578 -0
  40. package/src/core/providers/enablement.ts +143 -0
  41. package/src/core/providers/opendesign-credits.ts +206 -0
  42. package/src/core/providers/opendesign-models.ts +628 -0
  43. package/src/core/providers/opendesign.ts +474 -0
  44. package/src/core/quota/calibration.ts +166 -0
  45. package/src/core/quota/commandcode.ts +471 -0
  46. package/src/core/quota/credentials.ts +490 -0
  47. package/src/core/quota/ledger.ts +1273 -0
  48. package/src/core/quota/opencode-go.ts +148 -0
  49. package/src/core/quota/opendesign.ts +1043 -0
  50. package/src/core/quota/reconcile.ts +155 -0
  51. package/src/core/quota/report.ts +261 -0
  52. package/src/core/quota/service.ts +273 -0
  53. package/src/core/reprompt.ts +48 -0
  54. package/src/core/routing/engine.ts +919 -0
  55. package/src/core/types.ts +825 -0
  56. package/src/core/ui.ts +236 -0
  57. package/src/core/util.ts +114 -0
  58. package/src/gui/index.tsx +1670 -0
  59. package/src/gui/viewmodel.ts +456 -0
  60. package/src/index.ts +48 -0
  61. package/src/rpc.ts +204 -0
  62. package/src/server/controller.ts +3281 -0
  63. package/src/server/route-tracker.ts +60 -0
  64. package/src/server/shared-core.ts +671 -0
  65. package/src/tui/index.tsx +168 -0
@@ -0,0 +1,474 @@
1
+ import type { FailureClass } from "../types.js"
2
+ import { redact } from "../util.js"
3
+ import {
4
+ OPENDESIGN_FALLBACK_CONTEXT,
5
+ OPENDESIGN_FALLBACK_OUTPUT,
6
+ OPEN_DESIGN_MODEL_BY_ID,
7
+ } from "./opendesign-models.js"
8
+
9
+ /**
10
+ * OpenDesign provider adapter.
11
+ *
12
+ * The adapter owns three things and nothing else:
13
+ *
14
+ * 1. Base URL and protocol handling for the OpenAI-compatible gateway.
15
+ * 2. Read-only model discovery from `GET /v1/models`.
16
+ * 3. Error and size normalization.
17
+ *
18
+ * It never performs inference. OpenCode owns the inference call, so this
19
+ * adapter only makes read-only metadata requests, which consume no plan
20
+ * credits.
21
+ */
22
+
23
+ export const OPENDESIGN_PROVIDER_ID = "opendesign"
24
+
25
+ /** Documented OpenAI-compatible gateway. The path already contains `/v1`. */
26
+ export const OPENDESIGN_DEFAULT_BASE_URL = "https://amr-link.open-design.ai/v1"
27
+
28
+ /** Undocumented account-control host. Only used with an explicitly supplied control key. */
29
+ export const OPENDESIGN_DEFAULT_CONTROL_BASE_URL = "https://amr-api.open-design.ai/api"
30
+
31
+ /** OpenDesign documents a 50 MiB limit for the entire request body. */
32
+ export const OPENDESIGN_MAX_REQUEST_BYTES = 50 * 1024 * 1024
33
+
34
+ export const OPENDESIGN_DISCOVERY_TIMEOUT_MS = 15_000
35
+ export const OPENDESIGN_MAX_RESPONSE_BYTES = 2 * 1024 * 1024
36
+ export const OPENDESIGN_USER_AGENT = "opencode-smart-router/0.1"
37
+
38
+ export type OpenDesignProtocol = "chat-completions" | "responses" | "messages"
39
+
40
+ export const OPENDESIGN_PROTOCOLS: ReadonlyArray<OpenDesignProtocol> = [
41
+ "chat-completions",
42
+ "responses",
43
+ "messages",
44
+ ]
45
+
46
+ /**
47
+ * OpenDesign issue #8687 reports that the Anthropic-messages endpoint drops
48
+ * reasoning for `deepseek-v4.1-flash`, so agents stop with `end_turn` and no
49
+ * tool call. The OpenAI chat-completions endpoint is unaffected. The router
50
+ * therefore refuses the messages protocol for these models unless the owner
51
+ * explicitly overrides it.
52
+ */
53
+ export const OPENDESIGN_MESSAGES_UNSAFE_MODELS: ReadonlySet<string> = new Set(["deepseek-v4.1-flash"])
54
+
55
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>
56
+
57
+ const isLoopback = (hostname: string) =>
58
+ hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]"
59
+
60
+ /**
61
+ * Normalize a configured gateway base URL.
62
+ *
63
+ * The path must never gain a second `/v1`. A URL that already ends in `/v1`
64
+ * is kept as-is, a bare host gets `/v1` appended, and anything else is left
65
+ * alone so a versioned path such as `/v1beta` survives.
66
+ */
67
+ export const normalizeOpenDesignBaseURL = (raw?: string): string => {
68
+ const trimmed = (raw ?? "").trim()
69
+ if (!trimmed) return OPENDESIGN_DEFAULT_BASE_URL
70
+ let parsed: URL
71
+ try {
72
+ parsed = new URL(trimmed)
73
+ } catch {
74
+ return OPENDESIGN_DEFAULT_BASE_URL
75
+ }
76
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopback(parsed.hostname))) {
77
+ return OPENDESIGN_DEFAULT_BASE_URL
78
+ }
79
+ let path = parsed.pathname.replace(/\/+$/, "")
80
+ if (path === "") path = "/v1"
81
+ parsed.pathname = path
82
+ parsed.search = ""
83
+ parsed.hash = ""
84
+ return parsed.toString().replace(/\/+$/, "")
85
+ }
86
+
87
+ export const openDesignModelsEndpoint = (baseURL: string) => `${normalizeOpenDesignBaseURL(baseURL)}/models`
88
+
89
+ export const openDesignInferenceEndpoint = (baseURL: string, protocol: OpenDesignProtocol) => {
90
+ const base = normalizeOpenDesignBaseURL(baseURL)
91
+ switch (protocol) {
92
+ case "responses":
93
+ return `${base}/responses`
94
+ case "messages":
95
+ return `${base}/messages`
96
+ default:
97
+ return `${base}/chat/completions`
98
+ }
99
+ }
100
+
101
+ export const openDesignControlEndpoint = (controlBaseURL: string, path: string) => {
102
+ const base = (controlBaseURL ?? "").trim().replace(/\/+$/, "") || OPENDESIGN_DEFAULT_CONTROL_BASE_URL
103
+ const suffix = path.startsWith("/") ? path : `/${path}`
104
+ return `${base}${suffix}`
105
+ }
106
+
107
+ /**
108
+ * Normalize the account-control base URL. Only https is accepted for a
109
+ * remote host, because the control key is a privileged credential. A loopback
110
+ * http URL is allowed so an isolated lab can exercise the verified path
111
+ * without touching a real account.
112
+ */
113
+ export const normalizeOpenDesignControlBaseURL = (raw?: string): string => {
114
+ const trimmed = (raw ?? "").trim()
115
+ if (!trimmed) return OPENDESIGN_DEFAULT_CONTROL_BASE_URL
116
+ try {
117
+ const parsed = new URL(trimmed)
118
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopback(parsed.hostname))) {
119
+ return OPENDESIGN_DEFAULT_CONTROL_BASE_URL
120
+ }
121
+ let path = parsed.pathname.replace(/\/+$/, "")
122
+ if (path === "") path = "/api"
123
+ parsed.pathname = path
124
+ parsed.search = ""
125
+ parsed.hash = ""
126
+ return parsed.toString().replace(/\/+$/, "")
127
+ } catch {
128
+ return OPENDESIGN_DEFAULT_CONTROL_BASE_URL
129
+ }
130
+ }
131
+
132
+ export const failureClassForStatus = (status: number): FailureClass => {
133
+ if (status === 401) return "auth"
134
+ if (status === 403) return "auth"
135
+ if (status === 429) return "rate_limit"
136
+ if (status === 404) return "capability"
137
+ if (status === 413) return "invalid_request"
138
+ if (status >= 500) return "provider_outage"
139
+ if (status >= 400) return "invalid_request"
140
+ return "unknown"
141
+ }
142
+
143
+ /** Describe an oversized request without sending it. */
144
+ export const openDesignRequestSizeError = (bytes: number): string | undefined => {
145
+ if (!Number.isFinite(bytes) || bytes <= OPENDESIGN_MAX_REQUEST_BYTES) return undefined
146
+ const mib = (bytes / 1024 / 1024).toFixed(1)
147
+ return `Request body is ${mib} MiB, above the OpenDesign 50 MiB limit for the entire request.`
148
+ }
149
+
150
+ export interface OpenDesignDiscoveredModel {
151
+ readonly id: string
152
+ readonly name: string
153
+ /** Vendor context limit reported by the gateway. */
154
+ readonly contextLimit: number
155
+ /** Usable input budget reported by the gateway. Absent when unknown. */
156
+ readonly contextBudget?: number
157
+ /** Maximum output tokens reported by the gateway. Absent when unknown. */
158
+ readonly outputBudget?: number
159
+ readonly inputModalities: ReadonlyArray<string>
160
+ readonly outputModalities: ReadonlyArray<string>
161
+ readonly capability?: string
162
+ readonly priceTier?: string
163
+ readonly enabled: boolean
164
+ readonly default: boolean
165
+ }
166
+
167
+ export interface OpenDesignDiscoveryResult {
168
+ readonly ok: boolean
169
+ readonly models: ReadonlyArray<OpenDesignDiscoveredModel>
170
+ readonly error?: string
171
+ readonly status?: number
172
+ readonly failureClass?: FailureClass
173
+ readonly retryAfterMs?: number
174
+ readonly fetchedAt: number
175
+ }
176
+
177
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
178
+ typeof value === "object" && value !== null && !Array.isArray(value)
179
+
180
+ const MODEL_ID_PATTERN = /^[A-Za-z0-9._:/-]{1,128}$/
181
+
182
+ /** Map gateway modality names onto the router's modality vocabulary. */
183
+ export const normalizeModality = (value: string): string => {
184
+ const lower = value.trim().toLowerCase()
185
+ switch (lower) {
186
+ case "image":
187
+ case "images":
188
+ return "image"
189
+ case "file":
190
+ case "files":
191
+ case "pdf":
192
+ return "pdf"
193
+ case "audio":
194
+ return "audio"
195
+ case "video":
196
+ return "video"
197
+ case "text":
198
+ default:
199
+ return "text"
200
+ }
201
+ }
202
+
203
+ const parseModel = (value: unknown): OpenDesignDiscoveredModel | undefined => {
204
+ if (!isRecord(value)) return undefined
205
+ const id = typeof value.id === "string" ? value.id.trim() : ""
206
+ if (!id || !MODEL_ID_PATTERN.test(id)) return undefined
207
+ const architecture = isRecord(value.architecture) ? value.architecture : undefined
208
+ const metadata = isRecord(value.metadata) ? value.metadata : undefined
209
+ const rawInput = Array.isArray(architecture?.input_modalities)
210
+ ? (architecture!.input_modalities as unknown[]).filter((item): item is string => typeof item === "string")
211
+ : []
212
+ const rawOutput = Array.isArray(architecture?.output_modalities)
213
+ ? (architecture!.output_modalities as unknown[]).filter((item): item is string => typeof item === "string")
214
+ : []
215
+ const inputModalities = rawInput.length > 0 ? [...new Set(rawInput.map(normalizeModality))] : ["text"]
216
+ const outputModalities = rawOutput.length > 0 ? [...new Set(rawOutput.map(normalizeModality))] : ["text"]
217
+ const contextLimit = Number(metadata?.context_limit)
218
+ const contextBudget = Number(metadata?.context_budget)
219
+ const outputBudget = Number(metadata?.output_budget)
220
+ return {
221
+ id,
222
+ name: typeof value.name === "string" && value.name.trim() ? value.name.trim() : id,
223
+ contextLimit: Number.isFinite(contextLimit) && contextLimit > 0 ? contextLimit : 0,
224
+ ...(Number.isFinite(contextBudget) && contextBudget > 0 ? { contextBudget } : {}),
225
+ ...(Number.isFinite(outputBudget) && outputBudget > 0 ? { outputBudget } : {}),
226
+ inputModalities,
227
+ outputModalities,
228
+ ...(typeof metadata?.capability === "string" ? { capability: metadata.capability } : {}),
229
+ ...(typeof metadata?.cost === "string" ? { priceTier: metadata.cost } : {}),
230
+ enabled: value.enabled !== false,
231
+ default: value.default === true,
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Read the account's entitled model list from the gateway.
237
+ *
238
+ * This is a read-only metadata call. It consumes no plan credits and makes no
239
+ * inference request.
240
+ */
241
+ export const discoverOpenDesignModels = async (input: {
242
+ readonly baseURL?: string
243
+ readonly apiKey: string
244
+ readonly fetchImpl?: FetchLike
245
+ readonly now?: () => number
246
+ readonly timeoutMs?: number
247
+ }): Promise<OpenDesignDiscoveryResult> => {
248
+ const now = input.now?.() ?? Date.now()
249
+ const fetchImpl = input.fetchImpl ?? fetch
250
+ if (!input.apiKey) {
251
+ return { ok: false, models: [], error: "No OpenDesign API key is available", fetchedAt: now }
252
+ }
253
+ const controller = new AbortController()
254
+ const timer = setTimeout(() => controller.abort(), input.timeoutMs ?? OPENDESIGN_DISCOVERY_TIMEOUT_MS)
255
+ try {
256
+ const response = await fetchImpl(openDesignModelsEndpoint(input.baseURL ?? ""), {
257
+ method: "GET",
258
+ headers: {
259
+ authorization: `Bearer ${input.apiKey}`,
260
+ accept: "application/json",
261
+ "user-agent": OPENDESIGN_USER_AGENT,
262
+ },
263
+ redirect: "error",
264
+ signal: controller.signal,
265
+ })
266
+ if (!response.ok) {
267
+ const retryAfter = Number(response.headers.get("retry-after"))
268
+ const retryAfterMs =
269
+ Number.isFinite(retryAfter) && retryAfter >= 0 ? Math.min(retryAfter * 1000, 15 * 60_000) : undefined
270
+ return {
271
+ ok: false,
272
+ models: [],
273
+ error: `Model discovery returned HTTP ${response.status}`,
274
+ status: response.status,
275
+ failureClass: failureClassForStatus(response.status),
276
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
277
+ fetchedAt: now,
278
+ }
279
+ }
280
+ const text = await response.text()
281
+ if (text.length > OPENDESIGN_MAX_RESPONSE_BYTES) {
282
+ return {
283
+ ok: false,
284
+ models: [],
285
+ error: "Model discovery response was unexpectedly large",
286
+ failureClass: "invalid_request",
287
+ fetchedAt: now,
288
+ }
289
+ }
290
+ let body: unknown
291
+ try {
292
+ body = JSON.parse(text)
293
+ } catch {
294
+ return {
295
+ ok: false,
296
+ models: [],
297
+ error: "Model discovery returned a malformed response",
298
+ failureClass: "invalid_request",
299
+ fetchedAt: now,
300
+ }
301
+ }
302
+ const rows = isRecord(body) && Array.isArray(body.data) ? body.data : undefined
303
+ if (!rows) {
304
+ return {
305
+ ok: false,
306
+ models: [],
307
+ error: "Model discovery response did not contain a model list",
308
+ failureClass: "invalid_request",
309
+ fetchedAt: now,
310
+ }
311
+ }
312
+ const models = rows
313
+ .map(parseModel)
314
+ .filter((model): model is OpenDesignDiscoveredModel => Boolean(model))
315
+ .filter((model) => model.enabled)
316
+ return { ok: true, models, fetchedAt: now }
317
+ } catch (error) {
318
+ return {
319
+ ok: false,
320
+ models: [],
321
+ error: controller.signal.aborted
322
+ ? "Model discovery timed out"
323
+ : redact(error instanceof Error ? error.message : String(error)),
324
+ failureClass: controller.signal.aborted ? "network" : "network",
325
+ fetchedAt: now,
326
+ }
327
+ } finally {
328
+ clearTimeout(timer)
329
+ }
330
+ }
331
+
332
+ export interface OpenDesignLiveModel {
333
+ readonly id: string
334
+ readonly providerID: string
335
+ readonly name: string
336
+ readonly limit: { readonly context: number; readonly output: number }
337
+ readonly capabilities: {
338
+ readonly tools: boolean
339
+ readonly input: ReadonlyArray<string>
340
+ readonly output: ReadonlyArray<string>
341
+ }
342
+ readonly cost: ReadonlyArray<{ input: number; output: number; cache: { read: number; write: number } }>
343
+ readonly enabled: boolean
344
+ }
345
+
346
+ /**
347
+ * Models whose real input ceiling has been measured against the live gateway
348
+ * and found to be the vendor `context_limit`, not the advisory
349
+ * `context_budget`.
350
+ *
351
+ * `context_budget` is not a hard input limit. It is metadata describing the
352
+ * share one deployment expects to serve, and the gateway does not enforce it.
353
+ * Measured on 2026-10-30 with single-request probes on `deepseek-v4.1-flash`
354
+ * (`max_tokens: 1`, synthetic unique filler, so nothing could be prefix-cached):
355
+ *
356
+ * 355,588 tokens -> 200 753,476 tokens -> 200
357
+ * 999,593 tokens -> 200 1,043,337 tokens -> 200
358
+ * ~1,060,000 tokens -> 400 context_length_exceeded
359
+ *
360
+ * The enforced ceiling is therefore the vendor limit of 1,048,000, confirmed on
361
+ * both sides of the boundary. The total cost of the probes was $0.006.
362
+ *
363
+ * This is deliberately a per-model allowlist and not a blanket rule. Only one
364
+ * model has been measured; the others keep the conservative budget reading,
365
+ * because assuming a larger window than a provider accepts fails mid-session
366
+ * with an opaque rejection. Extending this set requires its own probe.
367
+ */
368
+ export const VERIFIED_FULL_WINDOW_MODELS: ReadonlySet<string> = new Set([
369
+ "deepseek-v4.1-flash",
370
+ ])
371
+
372
+ /**
373
+ * Convert gateway discovery into the router's live-model shape so the model
374
+ * registry merges gateway metadata over the curated seed.
375
+ *
376
+ * The window the router uses is the largest one the gateway is known to accept,
377
+ * which is not always `context_budget`:
378
+ *
379
+ * - For a model in `VERIFIED_FULL_WINDOW_MODELS`, the vendor `context_limit`
380
+ * has been measured as the real ceiling, so the advisory budget is ignored.
381
+ * On `deepseek-v4.1-flash` this is the difference between 328,000 and
382
+ * 1,048,000, and treating the budget as binding discarded roughly 716,000
383
+ * usable tokens.
384
+ * - For every other model the budget is still used, because it is the only
385
+ * claim the gateway makes and it has not been tested.
386
+ *
387
+ * `context_budget` is not always present. `minimax-m3.1-flash` reports only
388
+ * `context_limit: 1000000`. The fallback for that case used to be a
389
+ * hand-written 232k guess, which is not a property of the model: it made a
390
+ * live session's 309k context look four times larger than the model ever
391
+ * was, pushed the session off the cheap provider, and then failed closed
392
+ * because no provider was left that could serve it.
393
+ *
394
+ * The gateway's own `context_limit` is the correct fallback, and it is an upper
395
+ * bound in every case, so a reported budget can never exceed the vendor limit
396
+ * it lives inside.
397
+ */
398
+ export const toLiveModels = (
399
+ discovered: ReadonlyArray<OpenDesignDiscoveredModel>,
400
+ providerID = OPENDESIGN_PROVIDER_ID,
401
+ ): OpenDesignLiveModel[] => {
402
+ const out: OpenDesignLiveModel[] = []
403
+ for (const model of discovered) {
404
+ const spec = OPEN_DESIGN_MODEL_BY_ID.get(model.id)
405
+ const vendorLimit = model.contextLimit > 0 ? model.contextLimit : 0
406
+ const measured = VERIFIED_FULL_WINDOW_MODELS.has(model.id)
407
+ const reported = measured ? vendorLimit : (model.contextBudget ?? vendorLimit)
408
+ // A reported window can never be larger than the vendor limit it lives
409
+ // inside, whichever claim produced it.
410
+ const context =
411
+ reported > 0 && (vendorLimit === 0 || reported <= vendorLimit)
412
+ ? reported
413
+ : vendorLimit > 0
414
+ ? vendorLimit
415
+ : (spec?.context ?? OPENDESIGN_FALLBACK_CONTEXT)
416
+ const output = model.outputBudget ?? spec?.maxOutput ?? OPENDESIGN_FALLBACK_OUTPUT
417
+ const cost = spec?.cost ?? [{ input: 0, output: 0, cache: { read: 0, write: 0 } }]
418
+ out.push({
419
+ id: model.id,
420
+ providerID,
421
+ name: model.name,
422
+ limit: { context, output },
423
+ capabilities: {
424
+ tools: true,
425
+ input: model.inputModalities,
426
+ output: model.outputModalities,
427
+ },
428
+ cost: cost.map((tier) => ({
429
+ input: tier.input,
430
+ output: tier.output,
431
+ cache: { read: tier.cache?.read ?? 0, write: tier.cache?.write ?? 0 },
432
+ })),
433
+ enabled: model.enabled,
434
+ })
435
+ }
436
+ return out
437
+ }
438
+
439
+ /**
440
+ * Reject a protocol that is known to break specific models.
441
+ */
442
+ export const protocolWarning = (
443
+ protocol: OpenDesignProtocol,
444
+ modelIDs: ReadonlyArray<string>,
445
+ ): string | undefined => {
446
+ if (protocol !== "messages") return undefined
447
+ const risky = modelIDs.filter((id) => OPENDESIGN_MESSAGES_UNSAFE_MODELS.has(id))
448
+ if (risky.length === 0) return undefined
449
+ return `The Anthropic-messages protocol is reported to drop reasoning for ${risky.join(", ")} (OpenDesign issue #8687), which stalls agents that rely on tool calls. Use chat-completions instead.`
450
+ }
451
+
452
+ /** Build the OpenCode provider entry that points OpenCode at OpenDesign. */
453
+ export const openDesignProviderConfigTemplate = (input: {
454
+ readonly providerID?: string
455
+ readonly baseURL?: string
456
+ readonly apiKeyEnv?: string
457
+ readonly modelIDs?: ReadonlyArray<string>
458
+ }): Record<string, unknown> => {
459
+ const providerID = input.providerID ?? OPENDESIGN_PROVIDER_ID
460
+ const baseURL = normalizeOpenDesignBaseURL(input.baseURL)
461
+ const apiKeyEnv = input.apiKeyEnv ?? "OPENDESIGN_API_KEY"
462
+ const models = (input.modelIDs ?? []).map((id) => ({ id, name: id }))
463
+ return {
464
+ [providerID]: {
465
+ name: "OpenDesign Go",
466
+ package: "@opencode/ai/providers/openai-compatible",
467
+ options: {
468
+ baseURL,
469
+ apiKey: `{env:${apiKeyEnv}}`,
470
+ },
471
+ ...(models.length > 0 ? { models } : {}),
472
+ },
473
+ }
474
+ }
@@ -0,0 +1,166 @@
1
+ import { creditMultiplierFor } from "../providers/opendesign-credits.js"
2
+ import { estimateDesignPlanCredits } from "./opendesign.js"
3
+ import type { UsageEvent } from "../types.js"
4
+
5
+ /**
6
+ * Calibrate the estimate against what the provider actually charged.
7
+ *
8
+ * Every completed step carries a real charge, so the router can compare its own
9
+ * arithmetic with the truth on every request it routes. That makes the safety
10
+ * margin a measured quantity rather than a guess: a fresh install has no
11
+ * evidence and needs a wide margin; after a few dozen requests that agree
12
+ * closely, the margin can shrink because the disagreement has been observed to
13
+ * be small.
14
+ *
15
+ * This is the piece that removes the recurring paste. The paste still matters
16
+ * once, for usage that happened before the router existed — nothing local can
17
+ * see that — but the ongoing drift is now self-correcting.
18
+ *
19
+ * Two rules keep it honest:
20
+ *
21
+ * - **Only reported charges count.** An event with no cost teaches nothing
22
+ * about accuracy; counting it as agreement would manufacture confidence.
23
+ * - **Calibration never overrides the catalog.** It adjusts the margin applied
24
+ * to this account's traffic. A systematic disagreement big enough to imply a
25
+ * wrong price is reported, not silently absorbed, because that is a catalog
26
+ * bug rather than a property of one account.
27
+ */
28
+
29
+ export interface CalibrationSample {
30
+ readonly modelID: string
31
+ /** Credits the router computed from tokens and the catalog. */
32
+ readonly computedUsd: number
33
+ /** Credits the provider actually charged. */
34
+ readonly reportedUsd: number
35
+ }
36
+
37
+ export interface CalibrationResult {
38
+ readonly samples: number
39
+ /** Provider charged / router computed, weighted by the router's own figure. */
40
+ readonly ratio: number
41
+ /** Mean absolute percentage error between the two, 0 when they agree exactly. */
42
+ readonly meanErrorPct: number
43
+ /**
44
+ * Margin to apply instead of the default, in percent.
45
+ *
46
+ * Shrinks as the error shrinks, never below zero, and never removes the
47
+ * margin entirely: an account with fifty agreeing samples is still one price
48
+ * change away from being wrong.
49
+ */
50
+ readonly suggestedMarginPct: number
51
+ /** True when the evidence is strong enough to act on. */
52
+ readonly trustworthy: boolean
53
+ /** Set when the disagreement is large enough to look like a catalog error. */
54
+ readonly warning?: string
55
+ }
56
+
57
+ /** Below this, the sample is too small to distinguish agreement from luck. */
58
+ export const MIN_CALIBRATION_SAMPLES = 5
59
+
60
+ /** The margin applied when there is no evidence at all. */
61
+ export const DEFAULT_MARGIN_PCT = 20
62
+
63
+ /**
64
+ * A ratio this far from 1 means the catalog and the provider disagree
65
+ * systematically — a wrong price, not noise.
66
+ */
67
+ const SYSTEMATIC_DRIFT = 0.25
68
+
69
+ export const calibrationSamples = (
70
+ usage: ReadonlyArray<UsageEvent>,
71
+ providerID = "opendesign",
72
+ ): ReadonlyArray<CalibrationSample> => {
73
+ const samples: CalibrationSample[] = []
74
+ for (const event of usage) {
75
+ if (event.providerID !== providerID) continue
76
+ // No charge reported means no evidence about accuracy.
77
+ if (event.usd <= 0) continue
78
+ if (event.input + event.output + event.cacheRead + event.cacheWrite === 0) continue
79
+ const computed = estimateDesignPlanCredits({
80
+ modelID: event.modelID,
81
+ input: event.input,
82
+ output: event.output,
83
+ cacheRead: event.cacheRead,
84
+ cacheWrite: event.cacheWrite,
85
+ multiplierApplies: true,
86
+ }).creditsUsd
87
+ if (computed <= 0) continue
88
+ // The multiplier is applied to the reported charge as well, so the two are
89
+ // compared in the same units.
90
+ const multiplier = creditMultiplierFor(event.modelID).multiplier
91
+ samples.push({
92
+ modelID: event.modelID,
93
+ computedUsd: computed,
94
+ reportedUsd: event.usd * multiplier,
95
+ })
96
+ }
97
+ return samples
98
+ }
99
+
100
+ export const calibrate = (
101
+ samples: ReadonlyArray<CalibrationSample>,
102
+ options: { readonly baselineMarginPct?: number } = {},
103
+ ): CalibrationResult => {
104
+ const baseMargin = options.baselineMarginPct ?? DEFAULT_MARGIN_PCT
105
+ if (samples.length === 0) {
106
+ return {
107
+ samples: 0,
108
+ ratio: 1,
109
+ meanErrorPct: 0,
110
+ suggestedMarginPct: baseMargin,
111
+ trustworthy: false,
112
+ }
113
+ }
114
+
115
+ let computedTotal = 0
116
+ let reportedTotal = 0
117
+ let errorTotal = 0
118
+ for (const sample of samples) {
119
+ computedTotal += sample.computedUsd
120
+ reportedTotal += sample.reportedUsd
121
+ errorTotal += Math.abs(sample.reportedUsd - sample.computedUsd) / sample.computedUsd
122
+ }
123
+
124
+ const ratio = computedTotal > 0 ? reportedTotal / computedTotal : 1
125
+ const meanErrorPct = (errorTotal / samples.length) * 100
126
+ const trustworthy = samples.length >= MIN_CALIBRATION_SAMPLES
127
+
128
+ // With evidence, the margin tracks observed error with a small floor. Without
129
+ // it, the default stands.
130
+ const suggestedMarginPct = trustworthy
131
+ ? Math.max(5, Math.round(meanErrorPct))
132
+ : baseMargin
133
+
134
+ const systematic = Math.abs(ratio - 1) > SYSTEMATIC_DRIFT
135
+
136
+ return {
137
+ samples: samples.length,
138
+ ratio,
139
+ meanErrorPct,
140
+ suggestedMarginPct,
141
+ trustworthy,
142
+ ...(systematic
143
+ ? {
144
+ warning:
145
+ `Charges are ${(ratio * 100).toFixed(0)}% of what the catalog predicts across ${samples.length} requests. ` +
146
+ `That looks like a price error rather than noise, so the margin was not narrowed. Check the model catalog against the published prices.`,
147
+ }
148
+ : {}),
149
+ }
150
+ }
151
+
152
+ /**
153
+ * The margin the estimator should use.
154
+ *
155
+ * Calibration narrows the margin only when the evidence agrees. A systematic
156
+ * disagreement keeps the wide default and raises a warning, because trusting
157
+ * calibrated-but-wrong numbers would be worse than trusting none.
158
+ */
159
+ export const effectiveMarginPct = (
160
+ samples: ReadonlyArray<CalibrationSample>,
161
+ baselineMarginPct?: number,
162
+ ): number => {
163
+ const result = calibrate(samples, baselineMarginPct !== undefined ? { baselineMarginPct } : {})
164
+ if (result.warning) return baselineMarginPct ?? DEFAULT_MARGIN_PCT
165
+ return result.suggestedMarginPct
166
+ }