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/test.mjs ADDED
@@ -0,0 +1,370 @@
1
+ /**
2
+ * Local harness for the merged accounts plugin: one fake context per half, a
3
+ * fake credential store, a local usage endpoint that answers per API key, and a
4
+ * model catalog that advertises modalities, so account failover, per-model
5
+ * quarantine, the Settings API, the per-account probe, and the capability
6
+ * columns are all exercised without any model tokens.
7
+ */
8
+ import http from 'node:http'
9
+ import fs from 'node:fs'
10
+ import os from 'node:os'
11
+ import path from 'node:path'
12
+ import { Readable } from 'node:stream'
13
+ import assert from 'node:assert/strict'
14
+
15
+ import * as accounts from './index.js'
16
+ import { apply as applyRouter, isExhausted, maskKey, nextProbeAt, retryAfter } from './router.js'
17
+ import { apply as applyUsage, isChinaHoliday, peakMultiplier, peakSnapshot } from './usage.js'
18
+
19
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
20
+
21
+ // ---- one package, two children -------------------------------------------
22
+ {
23
+ assert.equal(accounts.name, 'opencode-router')
24
+ assert.equal(accounts.maskKey('short'), 'sho…', 'helpers are re-exported')
25
+ const mounted = []
26
+ const meta = {
27
+ plugin(spec, config) {
28
+ mounted.push({ spec, config })
29
+ },
30
+ }
31
+ accounts.apply(meta, {
32
+ usageUrl: 'http://127.0.0.1:1/usage',
33
+ usage: { path: '/u', plan: 'go' },
34
+ router: { path: '/r', probeIntervalMs: 1000 },
35
+ })
36
+ assert.equal(mounted.length, 2)
37
+ assert.deepEqual(mounted.map((entry) => entry.spec.name), ['opencode-router:usage', 'opencode-router:router'])
38
+ assert.equal(mounted[0].config.path, '/u')
39
+ assert.equal(mounted[0].config.plan, 'go')
40
+ assert.equal(mounted[1].config.path, '/r')
41
+ assert.equal(mounted[1].config.usageUrl, 'http://127.0.0.1:1/usage', 'top-level keys are shared')
42
+ assert.equal('usage' in mounted[0].config, false, 'the sub-objects are consumed')
43
+ assert.equal('router' in mounted[1].config, false)
44
+ }
45
+ console.log('composite plugin ok')
46
+
47
+ // ---- pure logic -----------------------------------------------------------
48
+ assert.equal(isExhausted({ rolling: { status: 'ok', percent: 42 } }), false)
49
+ assert.equal(isExhausted({ rolling: { status: 'ok', percent: 100 } }), true)
50
+ {
51
+ const now = Date.parse('2026-10-31T12:00:00Z')
52
+ assert.equal(nextProbeAt({ rolling: { resetsAt: new Date(now + 10 * 60_000).toISOString() } }, now, 30 * 60_000), now + 10 * 60_000 + 2000)
53
+ assert.equal(retryAfter({ rolling: { percent: 100, resetsAt: new Date(now + 60_000).toISOString() } }, now, 1000), now + 62_000)
54
+ assert.equal(maskKey('oc_sk_515adae389d8_0mTGYIu3_cGU'), 'oc_sk_5…_cGU')
55
+ }
56
+ {
57
+ const holidayPeak = Date.parse('2026-02-17T02:00:00Z') // Beijing 10:00 on 初一
58
+ const plainPeak = Date.parse('2026-02-24T02:00:00Z') // Beijing 10:00, an ordinary Tuesday
59
+ assert.equal(isChinaHoliday(holidayPeak), true)
60
+ assert.equal(isChinaHoliday(plainPeak), false)
61
+ assert.equal(peakMultiplier('deepseek-v4.1-flash', holidayPeak), 2, 'OpenCode bills the holiday as peak')
62
+ assert.equal(peakMultiplier('deepseek-v4.1-flash', holidayPeak, true), 1, 'the official API skips the holiday')
63
+ assert.equal(peakMultiplier('glm-5.3', holidayPeak), 1, 'only DeepSeek models have peak hours')
64
+ assert.equal(isChinaHoliday(Date.parse('2030-02-02T04:00:00Z')), true, '2030 除夕 is lunar 12/30')
65
+ assert.equal(isChinaHoliday(Date.parse('2030-02-01T04:00:00Z')), false)
66
+
67
+ const holiday = peakSnapshot(7.1, holidayPeak, 'go')
68
+ assert.equal(holiday.plan.leverage, 10)
69
+ assert.equal(holiday.plan.allowanceCny, 710)
70
+ assert.equal(holiday.models.every((row) => row.cheaper === 'go'), true)
71
+ const plain = peakSnapshot(7.1, plainPeak, 'go')
72
+ assert.ok(Math.abs(plain.models[0].ratio - 0.1) < 0.001, 'both peak: exactly the 10x discount')
73
+ }
74
+ console.log('pure logic ok')
75
+
76
+ // ---- shared fixtures ------------------------------------------------------
77
+ /** API keys whose plan-wide window is spent. */
78
+ const exhaustedKeys = new Set()
79
+ /** How many usage requests each key produced, so a targeted probe is visible. */
80
+ const probes = new Map()
81
+ const server = http.createServer((request, response) => {
82
+ if (request.url === '/models') {
83
+ response.writeHead(200, { 'content-type': 'application/json' })
84
+ response.end(JSON.stringify({
85
+ data: [
86
+ {
87
+ id: 'deepseek-v4.1-flash',
88
+ name: 'DeepSeek V4.1 Flash',
89
+ modalities: { input: ['text', 'image/jpeg', 'image/png'] },
90
+ capabilities: { tools: { supported: true }, reasoning: { supported: true, effort: ['low', 'high'] } },
91
+ limit: { context: 1_000_000, output: 384_000 },
92
+ cost: { text: { input: { price: 0.15 }, output: { price: 0.6 } } },
93
+ },
94
+ {
95
+ id: 'beam-free',
96
+ name: 'Beam Free',
97
+ modalities: { input: ['text', 'video/mp4'] },
98
+ capabilities: { tools: { supported: false }, reasoning: { supported: false } },
99
+ limit: { context: 1_048_576, output: 131_072 },
100
+ cost: { text: { input: { price: 0 }, output: { price: 0 } } },
101
+ },
102
+ ],
103
+ }))
104
+ return
105
+ }
106
+ if (request.url !== '/usage') return response.writeHead(404).end()
107
+ const key = String(request.headers.authorization ?? '').replace(/^Bearer /, '')
108
+ probes.set(key, (probes.get(key) ?? 0) + 1)
109
+ const exhausted = exhaustedKeys.has(key)
110
+ response.writeHead(200, { 'content-type': 'application/json' })
111
+ response.end(JSON.stringify({
112
+ usage: {
113
+ rolling: { status: exhausted ? 'limited' : 'ok', percent: exhausted ? 100 : key === 'oc_sk_a' ? 100 : 12.5, resetsAt: new Date(Date.now() + 3_600_000).toISOString() },
114
+ weekly: { status: 'ok', percent: 4, resetsAt: new Date(Date.now() + 86_400_000).toISOString() },
115
+ monthly: { status: 'ok', percent: 9, resetsAt: new Date(Date.now() + 2_592_000_000).toISOString() },
116
+ },
117
+ }))
118
+ })
119
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
120
+ const base = `http://127.0.0.1:${server.address().port}`
121
+ const usageUrl = `${base}/usage`
122
+
123
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), 'accounts-'))
124
+ const sessionsRoot = path.join(workdir, 'sessions')
125
+ fs.mkdirSync(sessionsRoot, { recursive: true })
126
+ const statePath = path.join(workdir, 'opencode-quota-router.json')
127
+
128
+ /** The fake credential store: the router writes here, the provider reads here. */
129
+ const store = new Map([['OPENCODE_API_KEY', 'oc_sk_test']])
130
+ const credentials = {
131
+ resolve: async (ref) => (store.has(ref) ? { value: store.get(ref), source: 'file' } : undefined),
132
+ describe: async (ref) => ({ configured: store.has(ref), writable: true }),
133
+ set: async (ref, value) => {
134
+ if (typeof value !== 'string' || value.length === 0) throw new Error('empty value')
135
+ store.set(ref, value)
136
+ },
137
+ unset: async (ref) => {
138
+ store.delete(ref)
139
+ },
140
+ }
141
+
142
+ const listeners = new Map()
143
+ const disposers = []
144
+ const apiRoutes = new Map()
145
+ const scopeEffect = (factory) => {
146
+ const dispose = factory()
147
+ if (typeof dispose === 'function') disposers.push(dispose)
148
+ }
149
+ const llm = {
150
+ listProviders: () => [{ id: 'opencode-go' }, { id: 'deepseek-account' }, { id: 'opencode-free' }],
151
+ resolveModelInfo: async (provider, model) => ({
152
+ reasoning: {
153
+ efforts: provider === 'opencode-go' ? [{ id: 'low' }, { id: 'high' }, { id: 'max' }] : [{ id: 'off' }, { id: 'low' }],
154
+ },
155
+ }),
156
+ }
157
+ const on = (event, listener) => {
158
+ listeners.set(event, listener)
159
+ return () => listeners.delete(event)
160
+ }
161
+
162
+ // ---- router half ----------------------------------------------------------
163
+ const routes = [
164
+ { primary: { provider: 'opencode-go', model: 'deepseek-v4.1-flash' }, fallback: { provider: 'deepseek-account', model: 'deepseek-flash' } },
165
+ { primary: { provider: 'opencode-go', model: 'deepseek-v4-pro' }, fallback: { provider: 'deepseek-account', model: 'deepseek-v4-pro' } },
166
+ ]
167
+ applyRouter({
168
+ logger: { info: () => {}, warn: () => {} },
169
+ get: (name) => (name === 'credentials' ? credentials : undefined),
170
+ on,
171
+ effect: scopeEffect,
172
+ inject: (_deps, callback) => {
173
+ callback({
174
+ webServer: {
175
+ register: ({ path: route, handler }) => {
176
+ apiRoutes.set(route, handler)
177
+ return () => apiRoutes.delete(route)
178
+ },
179
+ },
180
+ effect: scopeEffect,
181
+ })
182
+ },
183
+ llm,
184
+ }, { routes, usageUrl, statePath, probeIntervalMs: 1200, minProbeDelayMs: 1000, unconfirmedDelayMs: 1000, modelQuarantineMs: 2500 })
185
+
186
+ const ask = listeners.get('agent/request')
187
+ const fail = listeners.get('agent/request-error')
188
+ let headerModel = 'deepseek-v4-pro'
189
+ const agent = { id: 's1', session: { requestHeader: () => ({ config: { provider: 'opencode-go', model: headerModel } }) } }
190
+ const call = (config) => ask({ agent, turn: 1, step: 1, signal: new AbortController().signal }, async () => config)
191
+ const crash = (provider, failure) => fail({ agent, turn: 1, step: 1, provider, failure, signal: new AbortController().signal }, async () => undefined)
192
+ const readState = () => JSON.parse(fs.readFileSync(statePath, 'utf8'))
193
+ const flash = { provider: 'deepseek-account', model: 'deepseek-flash', reasoningEffort: 'off' }
194
+ const pro = { provider: 'deepseek-account', model: 'deepseek-v4-pro', reasoningEffort: 'low' }
195
+
196
+ /** Drive the Settings API over the fake webserver. */
197
+ async function api(method, payload) {
198
+ const handler = apiRoutes.get('/opencode-quota-router.json')
199
+ assert.ok(handler, 'the Settings API is registered')
200
+ const request = Object.assign(Readable.from(payload === undefined ? [] : [Buffer.from(JSON.stringify(payload))]), { method })
201
+ const response = {
202
+ status: 0,
203
+ writeHead(status) {
204
+ this.status = status
205
+ },
206
+ end(text) {
207
+ this.resolve({ status: this.status, body: JSON.parse(text) })
208
+ },
209
+ }
210
+ const done = new Promise((resolve) => {
211
+ response.resolve = resolve
212
+ })
213
+ handler(request, response)
214
+ return done
215
+ }
216
+
217
+ await sleep(1800)
218
+ assert.equal((await call(flash)).provider, 'opencode-go')
219
+ {
220
+ const state = readState()
221
+ assert.equal(state.version, 3)
222
+ assert.equal(state.accounts.length, 1, 'the configured key is adopted as the first account')
223
+ assert.equal(state.accounts[0].keyRef, 'OPENCODE_API_KEY_A1')
224
+ assert.equal(store.get('OPENCODE_API_KEY_A1'), 'oc_sk_test')
225
+ }
226
+ console.log('router startup ok')
227
+
228
+ // per-model quarantine keeps the plan for the other models
229
+ headerModel = 'deepseek-v4-pro'
230
+ assert.deepEqual(await crash('opencode-go', { code: 'QUOTA', status: 429, message: 'usage limit reached' }), { kind: 'retry' })
231
+ {
232
+ assert.equal((await call(flash)).provider, 'opencode-go', 'Flash keeps the plan while Pro is quarantined')
233
+ const routed = await call(pro)
234
+ assert.equal(routed.provider, 'deepseek-account')
235
+ assert.equal(readState().mode, 'primary')
236
+ }
237
+ await sleep(3200)
238
+ assert.equal((await call(pro)).provider, 'opencode-go')
239
+ console.log('per-model quarantine ok')
240
+
241
+ // A spent account hands over to the backup
242
+ {
243
+ const added = await api('POST', { action: 'add', label: '账户 B', key: 'oc_sk_backup' })
244
+ assert.equal(added.status, 200)
245
+ assert.equal(added.body.accounts[1].keyRef, 'OPENCODE_API_KEY_A2')
246
+ assert.equal(store.get('OPENCODE_API_KEY_A2'), 'oc_sk_backup')
247
+ assert.equal(JSON.stringify(added.body).includes('oc_sk_backup'), false, 'the snapshot never carries a plaintext key')
248
+ }
249
+ exhaustedKeys.add('oc_sk_test')
250
+ headerModel = 'deepseek-v4.1-flash'
251
+ assert.deepEqual(await crash('opencode-go', { code: 'QUOTA', status: 429, message: 'usage limit reached' }), { kind: 'retry' })
252
+ {
253
+ const state = readState()
254
+ assert.equal(state.active, 'a2', 'the spent account hands over to the backup')
255
+ assert.equal(state.mode, 'primary', 'an account failover is not a plan-wide fallback')
256
+ assert.equal(store.get('OPENCODE_API_KEY'), 'oc_sk_backup', 'the active key is mirrored into the provider reference')
257
+ assert.equal((await call(flash)).provider, 'opencode-go')
258
+ }
259
+ console.log('account failover ok')
260
+
261
+ // A target probe only touches the named account
262
+ {
263
+ probes.clear()
264
+ const probed = await api('POST', { action: 'probe', id: 'a1' })
265
+ assert.equal(probed.status, 200)
266
+ assert.equal(probes.get('oc_sk_test'), 1, 'the idle account was probed')
267
+ assert.equal(probes.get('oc_sk_backup') ?? 0, 0, 'the other account was left alone')
268
+ const all = await api('POST', { action: 'probe' })
269
+ assert.equal(all.status, 200)
270
+ assert.equal(probes.get('oc_sk_test'), 2)
271
+ assert.equal(probes.get('oc_sk_backup'), 1)
272
+ const unknown = await api('POST', { action: 'probe', id: 'zzz' })
273
+ assert.equal(unknown.status, 400, 'a probe for an unknown account is rejected')
274
+ assert.match(unknown.body.error, /zzz/)
275
+ }
276
+ console.log('targeted probe ok')
277
+
278
+ // Every account spent is a plan-wide fallback, and Settings can reset it
279
+ exhaustedKeys.add('oc_sk_backup')
280
+ headerModel = 'deepseek-v4.1-flash'
281
+ await crash('opencode-go', { code: 'QUOTA', status: 429, message: 'usage limit reached' })
282
+ {
283
+ assert.equal(readState().mode, 'fallback', 'no usable account left: every primary model falls back')
284
+ assert.equal((await call(flash)).provider, 'deepseek-account')
285
+ const reset = await api('POST', { action: 'reset' })
286
+ assert.equal(reset.body.mode, 'primary')
287
+ assert.equal((await call(flash)).provider, 'opencode-go')
288
+ }
289
+ console.log('plan-wide fallback ok')
290
+
291
+ exhaustedKeys.clear()
292
+ assert.equal(await crash('deepseek-account', { code: 'QUOTA', message: 'no' }), undefined, 'other providers are never claimed')
293
+ assert.equal(await crash('opencode-go', { code: 'RATE_LIMIT', status: 429, message: 'slow down' }), undefined, 'a plain rate limit stays with the provider')
294
+ console.log('delegation ok')
295
+
296
+ // ---- usage half -----------------------------------------------------------
297
+ let handler
298
+ applyUsage({
299
+ logger: { info: () => {}, warn: () => {} },
300
+ get: (name) => (name === 'credentials' ? credentials : undefined),
301
+ on,
302
+ effect: scopeEffect,
303
+ webServer: {
304
+ register: ({ path: route, handler: registered }) => {
305
+ if (route === '/opencode-usage.json') handler = registered
306
+ return () => {}
307
+ },
308
+ },
309
+ }, {
310
+ plan: 'go',
311
+ path: '/opencode-usage',
312
+ usageUrl,
313
+ modelsUrl: `${base}/models`,
314
+ sessionsRoot,
315
+ accountsStatePath: statePath,
316
+ refreshSeconds: 3600,
317
+ })
318
+
319
+ const request = async (query = '') => {
320
+ const response = {
321
+ status: 0,
322
+ writeHead(status) {
323
+ this.status = status
324
+ },
325
+ end(text) {
326
+ this.resolve(JSON.parse(text))
327
+ },
328
+ }
329
+ const done = new Promise((resolve) => {
330
+ response.resolve = resolve
331
+ })
332
+ handler({ url: `/opencode-usage.json${query}` }, response)
333
+ return done
334
+ }
335
+
336
+ await sleep(300)
337
+ {
338
+ const data = await request('?refresh=1')
339
+ assert.equal(data.accounts.length, 2, 'every account in the pool is reported')
340
+ assert.equal(data.activeAccount, 'a2')
341
+ assert.equal(JSON.stringify(data).includes('oc_sk_'), false, 'no plaintext key leaves the host')
342
+ assert.equal(data.accounts[0].usage.rolling.percent, 12.5)
343
+
344
+ const flashRow = data.models.find((row) => row.model === 'deepseek-v4.1-flash')
345
+ assert.deepEqual(flashRow.inputs, ['text', 'image'], 'modalities become capability tags')
346
+ assert.equal(flashRow.tools, true)
347
+ assert.equal(flashRow.reasoning, true)
348
+ assert.equal(flashRow.context, 1_000_000)
349
+ assert.equal(flashRow.maxOutput, 384_000)
350
+
351
+ const freeRow = data.models.find((row) => row.model === 'beam-free')
352
+ assert.equal(freeRow.free, true, 'a zero-price catalog entry is free')
353
+ assert.equal(freeRow.remaining, undefined, 'free models have no remaining quota')
354
+ assert.equal(freeRow.inputs.includes('video'), true)
355
+ assert.equal(data.peak.plan.leverage, 10)
356
+ }
357
+ console.log('usage view ok')
358
+
359
+ // A missing pool file degrades to the single configured reference
360
+ {
361
+ fs.rmSync(statePath)
362
+ const data = await request('?refresh=1')
363
+ assert.equal(data.accounts.length, 1)
364
+ assert.equal(data.accounts[0].keyRef, 'OPENCODE_API_KEY')
365
+ }
366
+ console.log('single-account fallback ok')
367
+
368
+ for (const dispose of disposers) await dispose()
369
+ server.close()
370
+ console.log('all accounts checks passed')