@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,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
|
+
}
|