dsh-opencode-routing 0.0.0-stage → 2.2.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/LICENSE +21 -0
- package/README.md +122 -2
- package/client.js +1700 -0
- package/cordis.patch.yml +93 -0
- package/icon.svg +8 -0
- package/index.js +33 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
- package/router.js +1029 -0
- package/test.mjs +374 -0
- package/usage.js +749 -0
package/router.js
ADDED
|
@@ -0,0 +1,1029 @@
|
|
|
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
|
+
// Nothing is quarantined right now, but the periodic probe still runs: report
|
|
807
|
+
// when it will fire instead of leaving the page showing a dash.
|
|
808
|
+
return marks.length > 0 ? Math.min(...marks) : now + cfg.probeIntervalMs
|
|
809
|
+
})()
|
|
810
|
+
return {
|
|
811
|
+
version: 3,
|
|
812
|
+
mode: state.mode,
|
|
813
|
+
reason: state.reason,
|
|
814
|
+
until: state.until,
|
|
815
|
+
active: active?.id ?? null,
|
|
816
|
+
activeLabel: active?.label ?? null,
|
|
817
|
+
updatedAt: state.updatedAt,
|
|
818
|
+
checkedAt: state.checkedAt,
|
|
819
|
+
nextProbeAt: next,
|
|
820
|
+
probeIntervalMs: cfg.probeIntervalMs,
|
|
821
|
+
usage: active?.usage ?? state.usage,
|
|
822
|
+
accounts,
|
|
823
|
+
quarantine: Object.entries(state.quarantine)
|
|
824
|
+
.filter(([, entry]) => entry.until > now)
|
|
825
|
+
.map(([model, entry]) => ({ model, until: entry.until, reason: entry.reason })),
|
|
826
|
+
routes: cfg.routes,
|
|
827
|
+
lastFailure: state.lastFailure ?? null,
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function sendJson(response, status, body) {
|
|
832
|
+
const text = JSON.stringify(body, null, 2)
|
|
833
|
+
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
834
|
+
response.end(text)
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function readBody(request) {
|
|
838
|
+
return new Promise((resolve, reject) => {
|
|
839
|
+
let size = 0
|
|
840
|
+
const chunks = []
|
|
841
|
+
request.on('data', (chunk) => {
|
|
842
|
+
size += chunk.length
|
|
843
|
+
if (size > BODY_LIMIT_BYTES) {
|
|
844
|
+
reject(new Error('request body is too large'))
|
|
845
|
+
request.destroy()
|
|
846
|
+
return
|
|
847
|
+
}
|
|
848
|
+
chunks.push(chunk)
|
|
849
|
+
})
|
|
850
|
+
request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
851
|
+
request.on('error', reject)
|
|
852
|
+
})
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function findAccount(id) {
|
|
856
|
+
return state.accounts.find((account) => account.id === id)
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async function applyAction(body) {
|
|
860
|
+
const action = typeof body?.action === 'string' ? body.action : ''
|
|
861
|
+
if (action === 'add') {
|
|
862
|
+
const key = String(body.key ?? '').trim()
|
|
863
|
+
if (key.length < 8) throw new Error('密钥不完整:至少 8 个字符')
|
|
864
|
+
const id = nextAccountId()
|
|
865
|
+
const label = String(body.label ?? '').trim().slice(0, 60) || `账户 ${state.accounts.length + 1}`
|
|
866
|
+
const account = { id, label, keyRef: `${KEY_REF_PREFIX}${id.toUpperCase()}`, enabled: true, exhaustedUntil: 0, usage: null, checkedAt: 0 }
|
|
867
|
+
await storeKey(account, key)
|
|
868
|
+
state.accounts.push(account)
|
|
869
|
+
state.updatedAt = Date.now()
|
|
870
|
+
if (state.active === null || activeAccount() === undefined) state.active = id
|
|
871
|
+
saveState()
|
|
872
|
+
await probeAccount(account, true)
|
|
873
|
+
if (state.accounts.length === 1) await switchTo(account, 'first account added')
|
|
874
|
+
saveState()
|
|
875
|
+
return { ok: true, id }
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if (action === 'update') {
|
|
879
|
+
const account = findAccount(String(body.id ?? ''))
|
|
880
|
+
if (account === undefined) throw new Error(`没有账户 ${body.id}`)
|
|
881
|
+
if (typeof body.label === 'string' && body.label.trim().length > 0) account.label = body.label.trim().slice(0, 60)
|
|
882
|
+
if (typeof body.enabled === 'boolean') account.enabled = body.enabled
|
|
883
|
+
if (typeof body.key === 'string' && body.key.trim().length > 0) {
|
|
884
|
+
await storeKey(account, body.key.trim())
|
|
885
|
+
account.exhaustedUntil = 0
|
|
886
|
+
account.exhaustReason = undefined
|
|
887
|
+
account.lastError = undefined
|
|
888
|
+
if (state.active === account.id) await mirrorActive(true)
|
|
889
|
+
await probeAccount(account, true)
|
|
890
|
+
}
|
|
891
|
+
state.updatedAt = Date.now()
|
|
892
|
+
saveState()
|
|
893
|
+
return { ok: true }
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
if (action === 'remove') {
|
|
897
|
+
const account = findAccount(String(body.id ?? ''))
|
|
898
|
+
if (account === undefined) throw new Error(`没有账户 ${body.id}`)
|
|
899
|
+
if (state.accounts.length === 1) throw new Error('至少保留一个账户')
|
|
900
|
+
state.accounts = state.accounts.filter((entry) => entry.id !== account.id)
|
|
901
|
+
const service = credentials()
|
|
902
|
+
if (service !== undefined && typeof service.unset === 'function') {
|
|
903
|
+
try {
|
|
904
|
+
await service.unset(account.keyRef)
|
|
905
|
+
} catch (error) {
|
|
906
|
+
log.warn(`could not clear ${account.keyRef}: ${describeError(error)}`)
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
if (state.active === account.id) {
|
|
910
|
+
const next = usableAccount() ?? state.accounts[0]
|
|
911
|
+
await switchTo(next, `active account ${account.id} removed`)
|
|
912
|
+
}
|
|
913
|
+
state.updatedAt = Date.now()
|
|
914
|
+
saveState()
|
|
915
|
+
return { ok: true }
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
if (action === 'activate') {
|
|
919
|
+
const account = findAccount(String(body.id ?? ''))
|
|
920
|
+
if (account === undefined) throw new Error(`没有账户 ${body.id}`)
|
|
921
|
+
account.enabled = true
|
|
922
|
+
account.exhaustedUntil = 0
|
|
923
|
+
account.exhaustReason = undefined
|
|
924
|
+
const mirrored = await switchTo(account, 'selected in Settings')
|
|
925
|
+
if (!mirrored) throw new Error(`账户 ${account.id} 没有可用的密钥`)
|
|
926
|
+
return { ok: true }
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (action === 'move') {
|
|
930
|
+
const account = findAccount(String(body.id ?? ''))
|
|
931
|
+
if (account === undefined) throw new Error(`没有账户 ${body.id}`)
|
|
932
|
+
const index = state.accounts.indexOf(account)
|
|
933
|
+
const direction = body.direction === 'up' ? -1 : 1
|
|
934
|
+
const target = index + direction
|
|
935
|
+
if (target < 0 || target >= state.accounts.length) return { ok: true, moved: false }
|
|
936
|
+
state.accounts.splice(index, 1)
|
|
937
|
+
state.accounts.splice(target, 0, account)
|
|
938
|
+
state.updatedAt = Date.now()
|
|
939
|
+
saveState()
|
|
940
|
+
return { ok: true, moved: true }
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
if (action === 'probe') {
|
|
944
|
+
// A per-account probe lets the settings dialog refresh one card without
|
|
945
|
+
// spending a request on every other account's key.
|
|
946
|
+
const wanted = body?.id
|
|
947
|
+
const only = typeof wanted === 'string' && wanted.length > 0
|
|
948
|
+
? state.accounts.find((entry) => entry.id === wanted)
|
|
949
|
+
: undefined
|
|
950
|
+
if (wanted !== undefined && only === undefined) throw new Error(`没有账户 ${wanted}`)
|
|
951
|
+
await probeAll({ force: true, ...(only === undefined ? {} : { only }) })
|
|
952
|
+
return { ok: true }
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
if (action === 'reset') {
|
|
956
|
+
for (const account of state.accounts) {
|
|
957
|
+
account.exhaustedUntil = 0
|
|
958
|
+
account.exhaustReason = undefined
|
|
959
|
+
}
|
|
960
|
+
resume('reset in Settings')
|
|
961
|
+
schedule()
|
|
962
|
+
return { ok: true }
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
throw new Error(`未知操作:${action}`)
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
async function handleApi(request, response) {
|
|
969
|
+
try {
|
|
970
|
+
if (request.method === 'POST') {
|
|
971
|
+
const text = await readBody(request)
|
|
972
|
+
let body
|
|
973
|
+
try {
|
|
974
|
+
body = text.length === 0 ? {} : JSON.parse(text)
|
|
975
|
+
} catch {
|
|
976
|
+
throw new Error('请求体不是合法 JSON')
|
|
977
|
+
}
|
|
978
|
+
await applyAction(body)
|
|
979
|
+
}
|
|
980
|
+
sendJson(response, 200, await snapshot())
|
|
981
|
+
} catch (error) {
|
|
982
|
+
sendJson(response, 400, { error: describeError(error) })
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
let disposeApi
|
|
987
|
+
if (typeof ctx.inject === 'function') {
|
|
988
|
+
ctx.inject(['webServer'], (scope) => {
|
|
989
|
+
try {
|
|
990
|
+
disposeApi = scope.webServer.register({ kind: 'exact', path: `${cfg.path}.json`, handler: handleApi })
|
|
991
|
+
scope.effect(() => () => {
|
|
992
|
+
if (disposeApi !== undefined) disposeApi()
|
|
993
|
+
}, 'opencode-quota-router: unregister the Settings API')
|
|
994
|
+
} catch (error) {
|
|
995
|
+
log.warn(`could not serve the Settings API at ${cfg.path}.json: ${describeError(error)}`)
|
|
996
|
+
}
|
|
997
|
+
})
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// ---- Boot ---------------------------------------------------------------
|
|
1001
|
+
|
|
1002
|
+
const quarantined = Object.keys(state.quarantine)
|
|
1003
|
+
log.info(`watching ${cfg.routes.length} route(s); mode ${state.mode}; ${state.accounts.length} account(s); quarantined ${quarantined.length === 0 ? 'none' : quarantined.join(', ')}`)
|
|
1004
|
+
|
|
1005
|
+
schedule()
|
|
1006
|
+
bootTimer = setTimeout(() => {
|
|
1007
|
+
bootTimer = undefined
|
|
1008
|
+
void (async () => {
|
|
1009
|
+
if (disposed) return
|
|
1010
|
+
await seedAccounts()
|
|
1011
|
+
await mirrorActive(true)
|
|
1012
|
+
await runProbe()
|
|
1013
|
+
})().catch((error) => log.warn(`startup probe failed: ${describeError(error)}`))
|
|
1014
|
+
}, 1500)
|
|
1015
|
+
bootTimer.unref?.()
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function safeLog(ctx, level, message) {
|
|
1019
|
+
try {
|
|
1020
|
+
ctx.logger[level](`opencode-quota-router: ${message}`)
|
|
1021
|
+
} catch {
|
|
1022
|
+
/* logging must never break routing */
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function describeError(error) {
|
|
1027
|
+
if (error instanceof Error) return error.message
|
|
1028
|
+
return String(error)
|
|
1029
|
+
}
|