dsh-opencode-routing 0.0.0-stage → 2.1.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/router.js ADDED
@@ -0,0 +1,1027 @@
1
+ /**
2
+ * OpenCode Go quota router with a multi-account pool and per-model isolation.
3
+ *
4
+ * OpenCode meters every model against its own monthly limit (5-hour = 20% of
5
+ * it, weekly = 50%, monthly = 100%), so an exhausted DeepSeek V4 Pro budget must
6
+ * not drag DeepSeek V4.1 Flash back to the DeepSeek account. This plugin
7
+ * therefore keeps three independent signals:
8
+ *
9
+ * - `accounts[]` — the pool of OpenCode Go credentials, in priority order. The
10
+ * active one is mirrored into the provider's credential reference
11
+ * (`apiKeyRef`), because pi-ai resolves that reference once per stream call;
12
+ * writing it at a step boundary is what makes the next request use another
13
+ * account. An exhausted account is parked until its window resets, and the
14
+ * next enabled account takes over.
15
+ * - `quarantine[model]` — one primary model reported exhaustion; only requests
16
+ * for that model fall back until its window resets.
17
+ * - `mode` — a plan-wide fallback for a failure that no account can serve;
18
+ * every primary model falls back.
19
+ *
20
+ * Both seams act at step boundaries of one agent: `agent/request` rewrites the
21
+ * call config, `agent/request-error` quarantines and retries the failed step in
22
+ * the same turn. Streaming requests and other agents are never interrupted.
23
+ * Both listeners register with `prepend: true`: a Cordis waterfall returns the
24
+ * outermost listener's value, so an observer appended later could otherwise
25
+ * shadow the rewrite.
26
+ *
27
+ * The Settings page talks to this module over two JSON routes served on the
28
+ * host webserver: `GET <path>.json` returns a secret-free snapshot, and
29
+ * `POST <path>.json` applies one action (`add`, `update`, `remove`, `activate`,
30
+ * `move`, `probe`, `reset`).
31
+ */
32
+
33
+ import fs from 'node:fs'
34
+ import os from 'node:os'
35
+ import path from 'node:path'
36
+
37
+ export const name = 'opencode-quota-router'
38
+
39
+ /** Routing needs the provider registry to resolve model metadata. */
40
+ export const inject = ['llm']
41
+
42
+ const DEFAULT_USAGE_URL = 'https://opencode.ai/inference/go/v1/usage'
43
+ const DEFAULT_API_KEY_REF = 'OPENCODE_API_KEY'
44
+ const DEFAULT_PATH = '/opencode-quota-router'
45
+ const DEFAULT_PROBE_INTERVAL_MS = 30 * 60 * 1000
46
+ const MIN_PROBE_DELAY_MS = 60 * 1000
47
+ const UNCONFIRMED_DELAY_MS = 2 * 60 * 1000
48
+ const MODEL_QUARANTINE_MS = 30 * 60 * 1000
49
+ const AUTH_COOLDOWN_MS = 30 * 60 * 1000
50
+ const REQUEST_TIMEOUT_MS = 10 * 1000
51
+ const EFFORT_CACHE_TTL_MS = 10 * 60 * 1000
52
+ const KEY_REF_PREFIX = 'OPENCODE_API_KEY_'
53
+ const BODY_LIMIT_BYTES = 64 * 1024
54
+ const AUTH_CODES = new Set([
55
+ 'AUTH',
56
+ 'INVALID_CREDENTIAL',
57
+ 'MISSING_CREDENTIAL',
58
+ 'ACCOUNT_SIGN_IN_REQUIRED',
59
+ 'CLIENT_UPGRADE_REQUIRED',
60
+ ])
61
+ const QUOTA_MESSAGE = /allowance|usage limit|quota|limit reached|exceed/i
62
+
63
+ function routePair(raw) {
64
+ const primary = raw?.primary
65
+ const fallback = raw?.fallback
66
+ if (typeof primary?.provider !== 'string' || typeof primary?.model !== 'string') return undefined
67
+ if (typeof fallback?.provider !== 'string' || typeof fallback?.model !== 'string') return undefined
68
+ return { primary: { provider: primary.provider, model: primary.model }, fallback: { provider: fallback.provider, model: fallback.model } }
69
+ }
70
+
71
+ function normalize(config) {
72
+ const routes = Array.isArray(config?.routes) ? config.routes.map(routePair).filter((entry) => entry !== undefined) : []
73
+ const endpoint = typeof config?.path === 'string' && config.path.startsWith('/') ? config.path.replace(/\/+$/, '') : DEFAULT_PATH
74
+ return {
75
+ routes,
76
+ usageUrl: typeof config?.usageUrl === 'string' && config.usageUrl.length > 0 ? config.usageUrl : DEFAULT_USAGE_URL,
77
+ apiKeyRef: typeof config?.apiKeyRef === 'string' && config.apiKeyRef.length > 0 ? config.apiKeyRef : DEFAULT_API_KEY_REF,
78
+ probeIntervalMs: Number.isFinite(config?.probeIntervalMs) && config.probeIntervalMs >= 1000 ? config.probeIntervalMs : DEFAULT_PROBE_INTERVAL_MS,
79
+ minProbeDelayMs: Number.isFinite(config?.minProbeDelayMs) && config.minProbeDelayMs >= 1000 ? config.minProbeDelayMs : MIN_PROBE_DELAY_MS,
80
+ unconfirmedDelayMs: Number.isFinite(config?.unconfirmedDelayMs) && config.unconfirmedDelayMs >= 1000 ? config.unconfirmedDelayMs : UNCONFIRMED_DELAY_MS,
81
+ modelQuarantineMs: Number.isFinite(config?.modelQuarantineMs) && config.modelQuarantineMs >= 1000 ? config.modelQuarantineMs : MODEL_QUARANTINE_MS,
82
+ authCooldownMs: Number.isFinite(config?.authCooldownMs) && config.authCooldownMs >= 1000 ? config.authCooldownMs : AUTH_COOLDOWN_MS,
83
+ statePath: typeof config?.statePath === 'string' && config.statePath.length > 0 ? config.statePath : path.join(dshHome(), 'opencode-quota-router.json'),
84
+ path: endpoint,
85
+ }
86
+ }
87
+
88
+ function dshHome() {
89
+ const configured = process.env.DSH_HOME
90
+ return configured !== undefined && configured.length > 0 ? configured : path.join(os.homedir(), '.dsh')
91
+ }
92
+
93
+ /** Whether one quota window reports exhaustion. */
94
+ function windowExhausted(window) {
95
+ if (window === null || typeof window !== 'object') return false
96
+ const percent = typeof window.percent === 'number' ? window.percent : undefined
97
+ if (percent !== undefined && percent >= 100) return true
98
+ const status = typeof window.status === 'string' ? window.status.toLowerCase() : ''
99
+ return /limit|exhaust|block|denied|exceed/.test(status)
100
+ }
101
+
102
+ /** Whether any quota window reports plan-wide exhaustion. */
103
+ export function isExhausted(usage) {
104
+ return windowExhausted(usage?.rolling) || windowExhausted(usage?.weekly) || windowExhausted(usage?.monthly)
105
+ }
106
+
107
+ /** The moment an exhausted account can be tried again: the latest exhausted window reset. */
108
+ export function retryAfter(usage, now, fallbackMs) {
109
+ let until = 0
110
+ for (const name of ['rolling', 'weekly', 'monthly']) {
111
+ const window = usage?.[name]
112
+ if (!windowExhausted(window)) continue
113
+ const reset = Date.parse(window?.resetsAt ?? '')
114
+ until = Math.max(until, Number.isFinite(reset) && reset > now ? reset + 2000 : now + fallbackMs)
115
+ }
116
+ return until
117
+ }
118
+
119
+ /** When to probe next: the rolling-window reset when it is near, otherwise the fixed interval. */
120
+ export function nextProbeAt(usage, now, intervalMs, minDelayMs = MIN_PROBE_DELAY_MS) {
121
+ const ceiling = now + intervalMs
122
+ const reset = Date.parse(usage?.rolling?.resetsAt ?? '')
123
+ if (Number.isFinite(reset) && reset > now) return Math.min(ceiling, Math.max(now + minDelayMs, reset + 2000))
124
+ return ceiling
125
+ }
126
+
127
+ /** The visible part of a stored secret: enough to tell two keys apart, never enough to use one. */
128
+ export function maskKey(key) {
129
+ if (typeof key !== 'string' || key.length === 0) return undefined
130
+ if (key.length <= 12) return `${key.slice(0, 3)}…`
131
+ return `${key.slice(0, 7)}…${key.slice(-4)}`
132
+ }
133
+
134
+ function normalizeAccount(raw) {
135
+ if (raw === null || typeof raw !== 'object') return undefined
136
+ const id = typeof raw.id === 'string' && /^[a-z0-9][a-z0-9-]{0,31}$/.test(raw.id) ? raw.id : undefined
137
+ if (id === undefined) return undefined
138
+ const account = {
139
+ id,
140
+ label: typeof raw.label === 'string' && raw.label.trim().length > 0 ? raw.label.trim().slice(0, 60) : id,
141
+ keyRef: typeof raw.keyRef === 'string' && raw.keyRef.length > 0 ? raw.keyRef : `${KEY_REF_PREFIX}${id.toUpperCase()}`,
142
+ enabled: raw.enabled !== false,
143
+ exhaustedUntil: Number.isFinite(raw.exhaustedUntil) ? raw.exhaustedUntil : 0,
144
+ usage: raw.usage !== null && typeof raw.usage === 'object' ? raw.usage : null,
145
+ checkedAt: Number.isFinite(raw.checkedAt) ? raw.checkedAt : 0,
146
+ }
147
+ if (typeof raw.exhaustReason === 'string' && raw.exhaustReason.length > 0) account.exhaustReason = raw.exhaustReason
148
+ if (typeof raw.lastError === 'string' && raw.lastError.length > 0) account.lastError = raw.lastError.slice(0, 300)
149
+ // Inline secrets only appear when the credential service refused the write.
150
+ if (typeof raw.key === 'string' && raw.key.length > 0) account.key = raw.key
151
+ return account
152
+ }
153
+
154
+ function initialState() {
155
+ return {
156
+ version: 3,
157
+ mode: 'primary',
158
+ reason: 'initial',
159
+ until: 0,
160
+ active: null,
161
+ accounts: [],
162
+ quarantine: {},
163
+ usage: null,
164
+ checkedAt: 0,
165
+ updatedAt: Date.now(),
166
+ }
167
+ }
168
+
169
+ function readState(statePath) {
170
+ try {
171
+ const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8'))
172
+ if (parsed === null || typeof parsed !== 'object') return undefined
173
+ const quarantine = {}
174
+ if (parsed.quarantine !== null && typeof parsed.quarantine === 'object') {
175
+ for (const [model, entry] of Object.entries(parsed.quarantine)) {
176
+ if (entry !== null && typeof entry === 'object' && Number.isFinite(entry.until)) quarantine[model] = { until: entry.until, reason: String(entry.reason ?? 'quota') }
177
+ }
178
+ }
179
+ const accounts = Array.isArray(parsed.accounts) ? parsed.accounts.map(normalizeAccount).filter((entry) => entry !== undefined) : []
180
+ const active = typeof parsed.active === 'string' && accounts.some((account) => account.id === parsed.active) ? parsed.active : accounts[0]?.id ?? null
181
+ return {
182
+ version: 3,
183
+ mode: parsed.mode === 'fallback' ? 'fallback' : 'primary',
184
+ reason: typeof parsed.reason === 'string' ? parsed.reason : 'loaded',
185
+ until: Number.isFinite(parsed.until) ? parsed.until : 0,
186
+ active,
187
+ accounts,
188
+ quarantine,
189
+ usage: parsed.usage !== null && typeof parsed.usage === 'object' ? parsed.usage : null,
190
+ checkedAt: Number.isFinite(parsed.checkedAt) ? parsed.checkedAt : 0,
191
+ updatedAt: Number.isFinite(parsed.updatedAt) ? parsed.updatedAt : Date.now(),
192
+ }
193
+ } catch {
194
+ return undefined
195
+ }
196
+ }
197
+
198
+ function mtimeOf(target) {
199
+ try {
200
+ return fs.statSync(target).mtimeMs
201
+ } catch {
202
+ return 0
203
+ }
204
+ }
205
+
206
+ function retarget(config, target, reasoningEffort) {
207
+ const { reasoningEffort: _dropped, ...rest } = config
208
+ return {
209
+ ...rest,
210
+ provider: target.provider,
211
+ model: target.model,
212
+ ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
213
+ }
214
+ }
215
+
216
+ export function apply(ctx, config = {}) {
217
+ const cfg = normalize(config)
218
+ if (cfg.routes.length === 0) {
219
+ ctx.logger.warn('opencode-quota-router: no route pairs configured; the plugin stays idle')
220
+ return
221
+ }
222
+
223
+ let state = readState(cfg.statePath) ?? initialState()
224
+ let lastMtime = mtimeOf(cfg.statePath)
225
+ let hasKey = true
226
+ let mirroredKey
227
+ let timer
228
+ let bootTimer
229
+ let disposed = false
230
+ const effortCache = new Map()
231
+ const lifetime = new AbortController()
232
+
233
+ const log = {
234
+ info: (message) => safeLog(ctx, 'info', message),
235
+ warn: (message) => safeLog(ctx, 'warn', message),
236
+ }
237
+
238
+ function saveState() {
239
+ try {
240
+ fs.mkdirSync(path.dirname(cfg.statePath), { recursive: true })
241
+ const temp = `${cfg.statePath}.${process.pid}.tmp`
242
+ fs.writeFileSync(temp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 })
243
+ fs.renameSync(temp, cfg.statePath)
244
+ lastMtime = mtimeOf(cfg.statePath)
245
+ } catch (error) {
246
+ log.warn(`could not persist quota state to ${cfg.statePath}: ${describeError(error)}`)
247
+ }
248
+ }
249
+
250
+ function refreshStateFromDisk() {
251
+ const mtime = mtimeOf(cfg.statePath)
252
+ if (mtime === 0 || mtime === lastMtime) return
253
+ lastMtime = mtime
254
+ const loaded = readState(cfg.statePath)
255
+ if (loaded === undefined) return
256
+ const changed =
257
+ loaded.mode !== state.mode ||
258
+ loaded.until !== state.until ||
259
+ loaded.active !== state.active ||
260
+ loaded.accounts.length !== state.accounts.length ||
261
+ JSON.stringify(loaded.quarantine) !== JSON.stringify(state.quarantine)
262
+ state = loaded
263
+ if (changed) {
264
+ log.info(`state reloaded from disk: ${state.mode}; ${state.accounts.length} account(s); quarantined ${Object.keys(state.quarantine).length}`)
265
+ schedule()
266
+ }
267
+ }
268
+
269
+ function clearTimer() {
270
+ if (timer !== undefined) {
271
+ clearTimeout(timer)
272
+ timer = undefined
273
+ }
274
+ }
275
+
276
+ /** Drop quarantine entries whose window has passed. */
277
+ function expireQuarantine() {
278
+ const now = Date.now()
279
+ let changed = false
280
+ for (const [model, entry] of Object.entries(state.quarantine)) {
281
+ if (entry.until <= now) {
282
+ delete state.quarantine[model]
283
+ changed = true
284
+ log.info(`${model}: quarantine expired; the next request tries OpenCode again`)
285
+ }
286
+ }
287
+ if (changed) {
288
+ state.updatedAt = now
289
+ saveState()
290
+ }
291
+ return changed
292
+ }
293
+
294
+ function schedule() {
295
+ clearTimer()
296
+ if (disposed) return
297
+ const now = Date.now()
298
+ const marks = []
299
+ if (state.mode === 'fallback' && state.until > now) marks.push(state.until)
300
+ for (const entry of Object.values(state.quarantine)) if (entry.until > now) marks.push(entry.until)
301
+ for (const account of state.accounts) if (account.exhaustedUntil > now) marks.push(account.exhaustedUntil)
302
+ const target = marks.length > 0 ? Math.min(...marks) : now + cfg.probeIntervalMs
303
+ timer = setTimeout(() => {
304
+ timer = undefined
305
+ void runProbe()
306
+ }, Math.min(Math.max(cfg.minProbeDelayMs, target - now), 2 ** 31 - 1))
307
+ timer.unref?.()
308
+ }
309
+
310
+ const credentials = () => {
311
+ try {
312
+ return ctx.get('credentials')
313
+ } catch {
314
+ return undefined
315
+ }
316
+ }
317
+
318
+ /** The secret behind one account: credential store first, then inline, then the ambient environment. */
319
+ async function accountKey(account) {
320
+ const service = credentials()
321
+ if (service !== undefined && typeof service.resolve === 'function') {
322
+ try {
323
+ const hit = await service.resolve(account.keyRef)
324
+ if (typeof hit?.value === 'string' && hit.value.length > 0) return hit.value
325
+ } catch (error) {
326
+ log.warn(`could not resolve ${account.keyRef}: ${describeError(error)}`)
327
+ }
328
+ }
329
+ if (typeof account.key === 'string' && account.key.length > 0) return account.key
330
+ const ambient = process.env[account.keyRef]
331
+ return typeof ambient === 'string' && ambient.length > 0 ? ambient : undefined
332
+ }
333
+
334
+ /** Store a secret where the credential seam can find it; fall back to the state file. */
335
+ async function storeKey(account, key) {
336
+ const service = credentials()
337
+ if (service !== undefined && typeof service.set === 'function') {
338
+ try {
339
+ await service.set(account.keyRef, key)
340
+ delete account.key
341
+ return true
342
+ } catch (error) {
343
+ log.warn(`could not store ${account.keyRef} in the credential store: ${describeError(error)}`)
344
+ }
345
+ }
346
+ account.key = key
347
+ return false
348
+ }
349
+
350
+ /**
351
+ * Push the active account's key into the reference the provider reads. pi-ai
352
+ * resolves that reference once per stream call, so a write before the call
353
+ * reaches the wire; the guard keeps a steady state from rewriting the file.
354
+ */
355
+ async function mirrorActive(force) {
356
+ const account = activeAccount()
357
+ if (account === undefined) {
358
+ hasKey = false
359
+ return false
360
+ }
361
+ const key = await accountKey(account)
362
+ if (key === undefined) {
363
+ hasKey = false
364
+ return false
365
+ }
366
+ hasKey = true
367
+ if (!force && mirroredKey === key) return true
368
+ const service = credentials()
369
+ if (service !== undefined && typeof service.set === 'function') {
370
+ try {
371
+ await service.set(cfg.apiKeyRef, key)
372
+ mirroredKey = key
373
+ return true
374
+ } catch (error) {
375
+ log.warn(`could not mirror the active key into ${cfg.apiKeyRef}: ${describeError(error)}`)
376
+ }
377
+ }
378
+ try {
379
+ process.env[cfg.apiKeyRef] = key
380
+ mirroredKey = key
381
+ return true
382
+ } catch {
383
+ return false
384
+ }
385
+ }
386
+
387
+ function activeAccount() {
388
+ return state.accounts.find((account) => account.id === state.active) ?? state.accounts[0]
389
+ }
390
+
391
+ function usableAccount(now = Date.now()) {
392
+ return state.accounts.find((account) => account.enabled !== false && account.exhaustedUntil <= now)
393
+ }
394
+
395
+ function nextAccountId() {
396
+ for (let index = 1; index <= 99; index += 1) {
397
+ const id = `a${index}`
398
+ if (!state.accounts.some((account) => account.id === id)) return id
399
+ }
400
+ return `a${Date.now().toString(36)}`
401
+ }
402
+
403
+ async function switchTo(account, reason) {
404
+ if (account === undefined) return false
405
+ const previous = state.active
406
+ state.active = account.id
407
+ state.reason = reason
408
+ state.updatedAt = Date.now()
409
+ const mirrored = await mirrorActive(true)
410
+ saveState()
411
+ if (previous !== account.id) log.info(`active account ${previous ?? 'none'} -> ${account.id} (${reason})`)
412
+ return mirrored
413
+ }
414
+
415
+ /** Adopt the key that was configured before this pool existed as the first account. */
416
+ async function seedAccounts() {
417
+ if (state.accounts.length === 0) {
418
+ const id = nextAccountId()
419
+ state.accounts.push({
420
+ id,
421
+ label: '主账号',
422
+ keyRef: `${KEY_REF_PREFIX}${id.toUpperCase()}`,
423
+ enabled: true,
424
+ exhaustedUntil: 0,
425
+ usage: null,
426
+ checkedAt: 0,
427
+ })
428
+ state.active = id
429
+ state.updatedAt = Date.now()
430
+ saveState()
431
+ }
432
+ const first = state.accounts[0]
433
+ const service = credentials()
434
+ if (first !== undefined && service !== undefined && typeof service.resolve === 'function' && typeof service.set === 'function') {
435
+ try {
436
+ const own = await service.resolve(first.keyRef)
437
+ if (own?.value === undefined) {
438
+ const legacy = await service.resolve(cfg.apiKeyRef)
439
+ if (typeof legacy?.value === 'string' && legacy.value.length > 0) {
440
+ await service.set(first.keyRef, legacy.value)
441
+ log.info(`adopted ${cfg.apiKeyRef} as account ${first.id}`)
442
+ }
443
+ }
444
+ } catch (error) {
445
+ log.warn(`could not adopt the existing key: ${describeError(error)}`)
446
+ }
447
+ }
448
+ if (state.active === null || !state.accounts.some((account) => account.id === state.active)) state.active = state.accounts[0]?.id ?? null
449
+ }
450
+
451
+ async function probeAccount(account, force) {
452
+ const now = Date.now()
453
+ if (!force && account.checkedAt > 0 && now - account.checkedAt < cfg.probeIntervalMs) return account.usage
454
+ const key = await accountKey(account)
455
+ if (key === undefined) {
456
+ account.checkedAt = now
457
+ account.lastError = 'no credential'
458
+ return undefined
459
+ }
460
+ try {
461
+ const response = await fetch(cfg.usageUrl, {
462
+ headers: { authorization: `Bearer ${key}`, accept: 'application/json' },
463
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
464
+ })
465
+ account.checkedAt = Date.now()
466
+ if (!response.ok) {
467
+ account.lastError = `usage endpoint answered HTTP ${response.status}`
468
+ if (response.status === 401 || response.status === 403) {
469
+ account.exhaustedUntil = Date.now() + cfg.authCooldownMs
470
+ account.exhaustReason = `auth (HTTP ${response.status})`
471
+ }
472
+ return undefined
473
+ }
474
+ const usage = (await response.json())?.usage
475
+ if (usage === null || typeof usage !== 'object') {
476
+ account.lastError = 'usage endpoint returned no usage block'
477
+ return undefined
478
+ }
479
+ account.usage = usage
480
+ account.lastError = undefined
481
+ if (isExhausted(usage)) {
482
+ account.exhaustedUntil = retryAfter(usage, Date.now(), cfg.probeIntervalMs)
483
+ account.exhaustReason = 'quota'
484
+ } else if (account.exhaustedUntil > 0 && account.exhaustReason === 'quota') {
485
+ account.exhaustedUntil = 0
486
+ account.exhaustReason = undefined
487
+ }
488
+ return usage
489
+ } catch (error) {
490
+ account.checkedAt = Date.now()
491
+ account.lastError = describeError(error)
492
+ return undefined
493
+ }
494
+ }
495
+
496
+ async function probeAll(options = {}) {
497
+ const force = options.force === true
498
+ const targets = options.only === undefined ? state.accounts : [options.only]
499
+ const results = await Promise.all(targets.map((account) => probeAccount(account, force)))
500
+ const active = activeAccount()
501
+ const usage = active?.usage
502
+ if (usage !== undefined && usage !== null) {
503
+ state.usage = usage
504
+ state.checkedAt = Date.now()
505
+ }
506
+ state.updatedAt = Date.now()
507
+ saveState()
508
+ return results
509
+ }
510
+
511
+ async function runProbe() {
512
+ if (disposed) return
513
+ expireQuarantine()
514
+ await probeAll({ force: true })
515
+ if (disposed) return
516
+ const now = Date.now()
517
+ const active = activeAccount()
518
+ const activeBlocked = active === undefined || active.enabled === false || active.exhaustedUntil > now
519
+ if (activeBlocked) {
520
+ const next = usableAccount(now)
521
+ if (next !== undefined) await switchTo(next, `failover: ${active === undefined ? 'no account' : active.id} unavailable`)
522
+ }
523
+ if (state.mode === 'fallback') {
524
+ const usable = usableAccount(now)
525
+ if (usable !== undefined) {
526
+ await switchTo(usable, 'allowance recovered')
527
+ resume('allowance recovered')
528
+ return
529
+ }
530
+ const usage = activeAccount()?.usage
531
+ state.until = usage ? nextProbeAt(usage, now, cfg.probeIntervalMs) : now + cfg.probeIntervalMs
532
+ state.updatedAt = Date.now()
533
+ saveState()
534
+ schedule()
535
+ return
536
+ }
537
+ schedule()
538
+ }
539
+
540
+ function planFallback(reason, failure, delayMs) {
541
+ state.mode = 'fallback'
542
+ state.reason = reason
543
+ state.until = Date.now() + Math.max(cfg.minProbeDelayMs, delayMs)
544
+ state.lastFailure = failure === undefined ? state.lastFailure ?? null : { code: String(failure.code ?? 'UNKNOWN'), status: typeof failure.status === 'number' ? failure.status : undefined, message: String(failure.message ?? '').slice(0, 400), at: Date.now() }
545
+ state.updatedAt = Date.now()
546
+ saveState()
547
+ schedule()
548
+ log.warn(`plan-wide fallback (${reason}); next probe in ~${Math.round(Math.max(cfg.minProbeDelayMs, delayMs) / 60000)} min`)
549
+ }
550
+
551
+ function quarantineModel(model, reason, untilMs) {
552
+ state.quarantine[model] = { until: untilMs, reason }
553
+ state.updatedAt = Date.now()
554
+ saveState()
555
+ schedule()
556
+ log.warn(`${model} is quarantined (${reason}); only that model falls back until ${new Date(untilMs).toLocaleTimeString()}`)
557
+ }
558
+
559
+ function resume(reason) {
560
+ const changed = state.mode !== 'primary' || Object.keys(state.quarantine).length > 0
561
+ state.mode = 'primary'
562
+ state.reason = reason
563
+ state.until = 0
564
+ state.quarantine = {}
565
+ state.updatedAt = Date.now()
566
+ saveState()
567
+ clearTimer()
568
+ if (changed) log.info(`OpenCode Go is usable again (${reason})`)
569
+ }
570
+
571
+ function providerRegistered(provider) {
572
+ try {
573
+ return ctx.llm.listProviders().some((entry) => entry.id === provider)
574
+ } catch {
575
+ return false
576
+ }
577
+ }
578
+
579
+ async function effortFor(target, requested) {
580
+ if (requested === undefined || requested === null || requested === '') return undefined
581
+ const key = `${target.provider}\u0000${target.model}`
582
+ let efforts = effortCache.get(key)
583
+ if (efforts === undefined) {
584
+ try {
585
+ const info = await ctx.llm.resolveModelInfo(target.provider, target.model)
586
+ efforts = (info?.reasoning?.efforts ?? []).map((effort) => effort.id)
587
+ } catch {
588
+ return undefined
589
+ }
590
+ effortCache.set(key, efforts)
591
+ const eviction = setTimeout(() => effortCache.delete(key), EFFORT_CACHE_TTL_MS)
592
+ eviction.unref?.()
593
+ }
594
+ if (efforts.length === 0) return undefined
595
+ if (efforts.includes(requested)) return requested
596
+ if ((requested === 'off' || requested === 'minimal') && efforts.includes('low')) return 'low'
597
+ if (efforts.includes('high')) return 'high'
598
+ return efforts[efforts.length - 1]
599
+ }
600
+
601
+ function matchRoute(config) {
602
+ for (const route of cfg.routes) {
603
+ if (config.provider === route.primary.provider && config.model === route.primary.model) return { route, side: 'primary' }
604
+ if (config.provider === route.fallback.provider && config.model === route.fallback.model) return { route, side: 'fallback' }
605
+ }
606
+ return undefined
607
+ }
608
+
609
+ /** Whether this route's primary model must currently fall back. */
610
+ function primaryBlocked(route) {
611
+ if (state.mode === 'fallback') return true
612
+ if (usableAccount() === undefined) return true
613
+ const entry = state.quarantine[route.primary.model]
614
+ return entry !== undefined && entry.until > Date.now()
615
+ }
616
+
617
+ async function route(config) {
618
+ if (typeof config?.provider !== 'string' || typeof config?.model !== 'string') return config
619
+ refreshStateFromDisk()
620
+ expireQuarantine()
621
+ const match = matchRoute(config)
622
+ if (match === undefined) return config
623
+ const desired = primaryBlocked(match.route) ? 'fallback' : 'primary'
624
+ if (match.side === desired) {
625
+ if (desired === 'primary') await mirrorActive(false)
626
+ return config
627
+ }
628
+ const target = desired === 'primary' ? match.route.primary : match.route.fallback
629
+ if (!providerRegistered(target.provider)) return config
630
+ if (desired === 'primary') {
631
+ const mirrored = await mirrorActive(false)
632
+ if (!mirrored || !hasKey) return config
633
+ }
634
+ const reasoningEffort = await effortFor(target, config.reasoningEffort)
635
+ log.info(`route ${match.side} -> ${desired}: ${target.provider}/${target.model}${reasoningEffort === undefined ? '' : ` @ ${reasoningEffort}`}`)
636
+ return retarget(config, target, reasoningEffort)
637
+ }
638
+
639
+ /** The model the failed attempt actually used, from the durable request header. */
640
+ function failedModel(payload) {
641
+ try {
642
+ const header = payload?.agent?.session?.requestHeader?.()
643
+ const config = header?.config ?? header?.header?.config
644
+ if (typeof config?.model === 'string' && typeof config?.provider === 'string' && config.provider === cfg.routes.find((route) => route.primary.provider === payload.provider)?.primary.provider) return config.model
645
+ return typeof config?.model === 'string' ? config.model : undefined
646
+ } catch {
647
+ return undefined
648
+ }
649
+ }
650
+
651
+ async function recover(payload, next) {
652
+ const failure = payload?.failure
653
+ if (failure === undefined || failure === null) return next()
654
+ const provider = payload.provider
655
+ const routes = cfg.routes.filter((route) => route.primary.provider === provider)
656
+ if (routes.length === 0) return next()
657
+ const code = String(failure.code ?? '').toUpperCase()
658
+ const model = failedModel(payload)
659
+
660
+ if (AUTH_CODES.has(code)) {
661
+ const current = activeAccount()
662
+ if (current !== undefined) {
663
+ current.exhaustedUntil = Date.now() + cfg.authCooldownMs
664
+ current.exhaustReason = `auth (${code})`
665
+ current.lastError = String(failure.message ?? '').slice(0, 300)
666
+ state.updatedAt = Date.now()
667
+ saveState()
668
+ }
669
+ const alternative = usableAccount()
670
+ if (alternative !== undefined && alternative.id !== current?.id) {
671
+ await switchTo(alternative, `account failover after ${code}`)
672
+ return { kind: 'retry' }
673
+ }
674
+ planFallback(`OpenCode authentication failed (${code})`, failure, cfg.unconfirmedDelayMs)
675
+ return { kind: 'retry' }
676
+ }
677
+
678
+ const hard = code === 'QUOTA' || code === 'ACCOUNT_QUOTA'
679
+ const soft = code === 'RATE_LIMIT' || failure.status === 429
680
+ if (!hard && !soft) return next()
681
+
682
+ await probeAll({ force: true })
683
+ const now = Date.now()
684
+ const current = activeAccount()
685
+ const currentBlocked = current === undefined || current.enabled === false || current.exhaustedUntil > now
686
+ const alternative = usableAccount(now)
687
+ const quoted = QUOTA_MESSAGE.test(String(failure.message ?? ''))
688
+
689
+ // Another account still has allowance: switch to it and retry this step.
690
+ if (currentBlocked && alternative !== undefined && alternative.id !== current?.id) {
691
+ await switchTo(alternative, `quota failover from ${current?.id ?? 'none'}`)
692
+ return { kind: 'retry' }
693
+ }
694
+
695
+ const usage = current?.usage ?? state.usage
696
+ const usageExhausted = usage !== undefined && usage !== null && isExhausted(usage)
697
+
698
+ // A transient rate limit with room left belongs to the provider retry policy.
699
+ if (!hard && !usageExhausted && !quoted) return next()
700
+
701
+ if (alternative === undefined) {
702
+ const delay = usage === undefined ? cfg.unconfirmedDelayMs : Math.max(cfg.minProbeDelayMs, nextProbeAt(usage, Date.now(), cfg.probeIntervalMs) - Date.now())
703
+ planFallback(hard ? 'every OpenCode account is out of allowance' : 'OpenCode quota window exhausted', failure, delay)
704
+ return { kind: 'retry' }
705
+ }
706
+
707
+ if (model !== undefined) {
708
+ const delay = usage === undefined ? cfg.modelQuarantineMs : Math.max(cfg.minProbeDelayMs, nextProbeAt(usage, Date.now(), cfg.probeIntervalMs) - Date.now())
709
+ quarantineModel(model, hard ? 'reported quota exhaustion' : 'quota window exhausted', Date.now() + Math.min(delay, cfg.modelQuarantineMs))
710
+ return { kind: 'retry' }
711
+ }
712
+
713
+ const delay = usage === undefined ? cfg.unconfirmedDelayMs : nextProbeAt(usage, Date.now(), cfg.probeIntervalMs) - Date.now()
714
+ planFallback(hard ? 'OpenCode quota exhausted' : 'OpenCode quota window exhausted', failure, delay)
715
+ return { kind: 'retry' }
716
+ }
717
+
718
+ const disposeRoute = ctx.on('agent/request', async (payload, next) => {
719
+ const base = await next()
720
+ try {
721
+ return await route(base)
722
+ } catch (error) {
723
+ log.warn(`could not rewrite the request route: ${describeError(error)}`)
724
+ return base
725
+ }
726
+ }, { prepend: true })
727
+
728
+ const disposeRecover = ctx.on('agent/request-error', (payload, next) => {
729
+ if (disposed || lifetime.signal.aborted) return Promise.resolve(undefined)
730
+ return recover(payload, next).catch((error) => {
731
+ log.warn(`request recovery failed: ${describeError(error)}`)
732
+ return next()
733
+ })
734
+ }, { prepend: true })
735
+
736
+ ctx.effect(() => () => {
737
+ disposed = true
738
+ disposeRoute()
739
+ disposeRecover()
740
+ clearTimer()
741
+ if (bootTimer !== undefined) clearTimeout(bootTimer)
742
+ lifetime.abort(new Error('opencode-quota-router disposed'))
743
+ }, 'opencode-quota-router: stop routing and probing')
744
+
745
+ // ---- Settings surface ---------------------------------------------------
746
+
747
+ async function describeAccount(account) {
748
+ const service = credentials()
749
+ let configured = typeof account.key === 'string' && account.key.length > 0
750
+ let source
751
+ let hint
752
+ if (service !== undefined && typeof service.resolve === 'function') {
753
+ try {
754
+ const hit = await service.resolve(account.keyRef)
755
+ if (typeof hit?.value === 'string' && hit.value.length > 0) {
756
+ configured = true
757
+ source = hit.source
758
+ hint = maskKey(hit.value)
759
+ }
760
+ } catch {
761
+ /* a broken store must not hide the rest of the pool */
762
+ }
763
+ }
764
+ if (hint === undefined && typeof account.key === 'string') hint = maskKey(account.key)
765
+ if (hint === undefined) {
766
+ const ambient = process.env[account.keyRef]
767
+ if (typeof ambient === 'string' && ambient.length > 0) {
768
+ configured = true
769
+ source = 'env'
770
+ hint = maskKey(ambient)
771
+ }
772
+ }
773
+ const now = Date.now()
774
+ const status = account.enabled === false
775
+ ? 'disabled'
776
+ : account.exhaustedUntil > now
777
+ ? account.exhaustReason !== undefined && account.exhaustReason.startsWith('auth') ? 'auth' : 'exhausted'
778
+ : configured ? 'ready' : 'missing'
779
+ return {
780
+ id: account.id,
781
+ label: account.label,
782
+ keyRef: account.keyRef,
783
+ enabled: account.enabled !== false,
784
+ active: state.active === account.id,
785
+ configured,
786
+ source: source ?? null,
787
+ hint: hint ?? null,
788
+ status,
789
+ exhaustedUntil: account.exhaustedUntil > now ? account.exhaustedUntil : 0,
790
+ exhaustReason: account.exhaustReason ?? null,
791
+ lastError: account.lastError ?? null,
792
+ checkedAt: account.checkedAt,
793
+ usage: account.usage,
794
+ }
795
+ }
796
+
797
+ async function snapshot() {
798
+ const now = Date.now()
799
+ const accounts = await Promise.all(state.accounts.map((account) => describeAccount(account)))
800
+ const active = activeAccount()
801
+ const next = (() => {
802
+ const marks = []
803
+ if (state.mode === 'fallback' && state.until > now) marks.push(state.until)
804
+ for (const entry of Object.values(state.quarantine)) if (entry.until > now) marks.push(entry.until)
805
+ for (const account of state.accounts) if (account.exhaustedUntil > now) marks.push(account.exhaustedUntil)
806
+ return marks.length > 0 ? Math.min(...marks) : null
807
+ })()
808
+ return {
809
+ version: 3,
810
+ mode: state.mode,
811
+ reason: state.reason,
812
+ until: state.until,
813
+ active: active?.id ?? null,
814
+ activeLabel: active?.label ?? null,
815
+ updatedAt: state.updatedAt,
816
+ checkedAt: state.checkedAt,
817
+ nextProbeAt: next,
818
+ probeIntervalMs: cfg.probeIntervalMs,
819
+ usage: active?.usage ?? state.usage,
820
+ accounts,
821
+ quarantine: Object.entries(state.quarantine)
822
+ .filter(([, entry]) => entry.until > now)
823
+ .map(([model, entry]) => ({ model, until: entry.until, reason: entry.reason })),
824
+ routes: cfg.routes,
825
+ lastFailure: state.lastFailure ?? null,
826
+ }
827
+ }
828
+
829
+ function sendJson(response, status, body) {
830
+ const text = JSON.stringify(body, null, 2)
831
+ response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
832
+ response.end(text)
833
+ }
834
+
835
+ function readBody(request) {
836
+ return new Promise((resolve, reject) => {
837
+ let size = 0
838
+ const chunks = []
839
+ request.on('data', (chunk) => {
840
+ size += chunk.length
841
+ if (size > BODY_LIMIT_BYTES) {
842
+ reject(new Error('request body is too large'))
843
+ request.destroy()
844
+ return
845
+ }
846
+ chunks.push(chunk)
847
+ })
848
+ request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
849
+ request.on('error', reject)
850
+ })
851
+ }
852
+
853
+ function findAccount(id) {
854
+ return state.accounts.find((account) => account.id === id)
855
+ }
856
+
857
+ async function applyAction(body) {
858
+ const action = typeof body?.action === 'string' ? body.action : ''
859
+ if (action === 'add') {
860
+ const key = String(body.key ?? '').trim()
861
+ if (key.length < 8) throw new Error('密钥不完整:至少 8 个字符')
862
+ const id = nextAccountId()
863
+ const label = String(body.label ?? '').trim().slice(0, 60) || `账户 ${state.accounts.length + 1}`
864
+ const account = { id, label, keyRef: `${KEY_REF_PREFIX}${id.toUpperCase()}`, enabled: true, exhaustedUntil: 0, usage: null, checkedAt: 0 }
865
+ await storeKey(account, key)
866
+ state.accounts.push(account)
867
+ state.updatedAt = Date.now()
868
+ if (state.active === null || activeAccount() === undefined) state.active = id
869
+ saveState()
870
+ await probeAccount(account, true)
871
+ if (state.accounts.length === 1) await switchTo(account, 'first account added')
872
+ saveState()
873
+ return { ok: true, id }
874
+ }
875
+
876
+ if (action === 'update') {
877
+ const account = findAccount(String(body.id ?? ''))
878
+ if (account === undefined) throw new Error(`没有账户 ${body.id}`)
879
+ if (typeof body.label === 'string' && body.label.trim().length > 0) account.label = body.label.trim().slice(0, 60)
880
+ if (typeof body.enabled === 'boolean') account.enabled = body.enabled
881
+ if (typeof body.key === 'string' && body.key.trim().length > 0) {
882
+ await storeKey(account, body.key.trim())
883
+ account.exhaustedUntil = 0
884
+ account.exhaustReason = undefined
885
+ account.lastError = undefined
886
+ if (state.active === account.id) await mirrorActive(true)
887
+ await probeAccount(account, true)
888
+ }
889
+ state.updatedAt = Date.now()
890
+ saveState()
891
+ return { ok: true }
892
+ }
893
+
894
+ if (action === 'remove') {
895
+ const account = findAccount(String(body.id ?? ''))
896
+ if (account === undefined) throw new Error(`没有账户 ${body.id}`)
897
+ if (state.accounts.length === 1) throw new Error('至少保留一个账户')
898
+ state.accounts = state.accounts.filter((entry) => entry.id !== account.id)
899
+ const service = credentials()
900
+ if (service !== undefined && typeof service.unset === 'function') {
901
+ try {
902
+ await service.unset(account.keyRef)
903
+ } catch (error) {
904
+ log.warn(`could not clear ${account.keyRef}: ${describeError(error)}`)
905
+ }
906
+ }
907
+ if (state.active === account.id) {
908
+ const next = usableAccount() ?? state.accounts[0]
909
+ await switchTo(next, `active account ${account.id} removed`)
910
+ }
911
+ state.updatedAt = Date.now()
912
+ saveState()
913
+ return { ok: true }
914
+ }
915
+
916
+ if (action === 'activate') {
917
+ const account = findAccount(String(body.id ?? ''))
918
+ if (account === undefined) throw new Error(`没有账户 ${body.id}`)
919
+ account.enabled = true
920
+ account.exhaustedUntil = 0
921
+ account.exhaustReason = undefined
922
+ const mirrored = await switchTo(account, 'selected in Settings')
923
+ if (!mirrored) throw new Error(`账户 ${account.id} 没有可用的密钥`)
924
+ return { ok: true }
925
+ }
926
+
927
+ if (action === 'move') {
928
+ const account = findAccount(String(body.id ?? ''))
929
+ if (account === undefined) throw new Error(`没有账户 ${body.id}`)
930
+ const index = state.accounts.indexOf(account)
931
+ const direction = body.direction === 'up' ? -1 : 1
932
+ const target = index + direction
933
+ if (target < 0 || target >= state.accounts.length) return { ok: true, moved: false }
934
+ state.accounts.splice(index, 1)
935
+ state.accounts.splice(target, 0, account)
936
+ state.updatedAt = Date.now()
937
+ saveState()
938
+ return { ok: true, moved: true }
939
+ }
940
+
941
+ if (action === 'probe') {
942
+ // A per-account probe lets the settings dialog refresh one card without
943
+ // spending a request on every other account's key.
944
+ const wanted = body?.id
945
+ const only = typeof wanted === 'string' && wanted.length > 0
946
+ ? state.accounts.find((entry) => entry.id === wanted)
947
+ : undefined
948
+ if (wanted !== undefined && only === undefined) throw new Error(`没有账户 ${wanted}`)
949
+ await probeAll({ force: true, ...(only === undefined ? {} : { only }) })
950
+ return { ok: true }
951
+ }
952
+
953
+ if (action === 'reset') {
954
+ for (const account of state.accounts) {
955
+ account.exhaustedUntil = 0
956
+ account.exhaustReason = undefined
957
+ }
958
+ resume('reset in Settings')
959
+ schedule()
960
+ return { ok: true }
961
+ }
962
+
963
+ throw new Error(`未知操作:${action}`)
964
+ }
965
+
966
+ async function handleApi(request, response) {
967
+ try {
968
+ if (request.method === 'POST') {
969
+ const text = await readBody(request)
970
+ let body
971
+ try {
972
+ body = text.length === 0 ? {} : JSON.parse(text)
973
+ } catch {
974
+ throw new Error('请求体不是合法 JSON')
975
+ }
976
+ await applyAction(body)
977
+ }
978
+ sendJson(response, 200, await snapshot())
979
+ } catch (error) {
980
+ sendJson(response, 400, { error: describeError(error) })
981
+ }
982
+ }
983
+
984
+ let disposeApi
985
+ if (typeof ctx.inject === 'function') {
986
+ ctx.inject(['webServer'], (scope) => {
987
+ try {
988
+ disposeApi = scope.webServer.register({ kind: 'exact', path: `${cfg.path}.json`, handler: handleApi })
989
+ scope.effect(() => () => {
990
+ if (disposeApi !== undefined) disposeApi()
991
+ }, 'opencode-quota-router: unregister the Settings API')
992
+ } catch (error) {
993
+ log.warn(`could not serve the Settings API at ${cfg.path}.json: ${describeError(error)}`)
994
+ }
995
+ })
996
+ }
997
+
998
+ // ---- Boot ---------------------------------------------------------------
999
+
1000
+ const quarantined = Object.keys(state.quarantine)
1001
+ log.info(`watching ${cfg.routes.length} route(s); mode ${state.mode}; ${state.accounts.length} account(s); quarantined ${quarantined.length === 0 ? 'none' : quarantined.join(', ')}`)
1002
+
1003
+ schedule()
1004
+ bootTimer = setTimeout(() => {
1005
+ bootTimer = undefined
1006
+ void (async () => {
1007
+ if (disposed) return
1008
+ await seedAccounts()
1009
+ await mirrorActive(true)
1010
+ await runProbe()
1011
+ })().catch((error) => log.warn(`startup probe failed: ${describeError(error)}`))
1012
+ }, 1500)
1013
+ bootTimer.unref?.()
1014
+ }
1015
+
1016
+ function safeLog(ctx, level, message) {
1017
+ try {
1018
+ ctx.logger[level](`opencode-quota-router: ${message}`)
1019
+ } catch {
1020
+ /* logging must never break routing */
1021
+ }
1022
+ }
1023
+
1024
+ function describeError(error) {
1025
+ if (error instanceof Error) return error.message
1026
+ return String(error)
1027
+ }