w-dispatch-ai 1.0.0 → 1.0.1

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.
@@ -0,0 +1,422 @@
1
+ import assert from 'assert'
2
+ import dispatchAiFallback from '../src/dispatchAiFallback.mjs'
3
+ import createFakeCli from './tools/fakeCliForTest.mjs'
4
+
5
+
6
+ //assertKey, 由假CLI回聲之OPENCODE_AUTH_CONTENT解出本次注入之金鑰
7
+ let getInjectedKey = (stdout) => {
8
+ let o = JSON.parse(stdout)
9
+ let auth = o.env.OPENCODE_AUTH_CONTENT
10
+ if (auth === '') {
11
+ return ''
12
+ }
13
+ let j = JSON.parse(auth)
14
+ return Object.values(j)[0].key
15
+ }
16
+
17
+
18
+ describe('dispatchAiFallback', function() {
19
+
20
+ let fake = null
21
+
22
+ before(function() {
23
+ fake = createFakeCli('fake-fallback')
24
+ })
25
+
26
+ after(function() {
27
+ if (fake) {
28
+ fake.clean()
29
+ }
30
+ })
31
+
32
+ it('prompt非有效字串時回傳錯誤結果物件且tried為空', async function() {
33
+ let t = await dispatchAiFallback('', { providers: [{ kind: 'claude' }] })
34
+ let r = [t.ok, t.error, t.tried]
35
+ let rr = [false, 'prompt must be a non-empty string', []]
36
+ assert.strict.deepEqual(r, rr)
37
+ })
38
+
39
+ it('providers非有效陣列時回傳錯誤結果物件', async function() {
40
+ let r = []
41
+ for (let providers of [null, undefined, [], {}, 'abc', [null, 'x', 123]]) {
42
+ let t = await dispatchAiFallback('abc', { providers })
43
+ r.push([t.ok, t.error])
44
+ }
45
+ let rr = [
46
+ [false, 'providers must be a non-empty array'],
47
+ [false, 'providers must be a non-empty array'],
48
+ [false, 'providers must be a non-empty array'],
49
+ [false, 'providers must be a non-empty array'],
50
+ [false, 'providers must be a non-empty array'],
51
+ [false, 'providers must be a non-empty array'],
52
+ ]
53
+ assert.strict.deepEqual(r, rr)
54
+ })
55
+
56
+ it('某把金鑰失敗自動換組內下一把並成功', async function() {
57
+ let t = await dispatchAiFallback('abc', {
58
+ providers: [
59
+ {
60
+ id: 'g-rotate',
61
+ kind: 'opencode',
62
+ exe: fake.exe,
63
+ provider: 'opencode',
64
+ keys: ['sk-bad-aaa', 'sk-good-bbb'],
65
+ extraArgs: ['--fake-fail-key=sk-bad'],
66
+ },
67
+ ],
68
+ })
69
+ let r = [
70
+ t.ok,
71
+ t.providerId,
72
+ t.keyIndex,
73
+ getInjectedKey(t.stdout),
74
+ t.tried.map((x) => [x.keyId, x.outcome]),
75
+ ]
76
+ let rr = [
77
+ true,
78
+ 'g-rotate',
79
+ 1,
80
+ 'sk-good-bbb',
81
+ [['g-rotate#0', 'next-key'], ['g-rotate#1', 'ok']],
82
+ ]
83
+ assert.strict.deepEqual(r, rr)
84
+ })
85
+
86
+ it('成功亦推進游標, 多把金鑰跨次呼叫輪替均攤', async function() {
87
+ let providers = [{
88
+ id: 'g-cursor',
89
+ kind: 'opencode',
90
+ exe: fake.exe,
91
+ provider: 'opencode',
92
+ keys: ['sk-k0', 'sk-k1', 'sk-k2'],
93
+ }]
94
+ let r = []
95
+ for (let i = 0; i < 4; i++) {
96
+ let t = await dispatchAiFallback('abc', { providers })
97
+ r.push(getInjectedKey(t.stdout))
98
+ }
99
+ let rr = ['sk-k0', 'sk-k1', 'sk-k2', 'sk-k0']
100
+ assert.strict.deepEqual(r, rr)
101
+ })
102
+
103
+ it('store注入時游標經store持久化, 各群組游標互不干擾', async function() {
104
+ let stored = { cursors: {} }
105
+ let store = {
106
+ get: () => stored,
107
+ set: (s) => {
108
+ stored = s
109
+ },
110
+ }
111
+ let providers = [{
112
+ id: 'g-store-a',
113
+ kind: 'opencode',
114
+ exe: fake.exe,
115
+ provider: 'opencode',
116
+ keys: ['sk-a0', 'sk-a1'],
117
+ }]
118
+ let t1 = await dispatchAiFallback('abc', { providers, store })
119
+ let t2 = await dispatchAiFallback('abc', { providers, store })
120
+ let r = [
121
+ getInjectedKey(t1.stdout),
122
+ getInjectedKey(t2.stdout),
123
+ stored.cursors['g-store-a'],
124
+ stored.cursors['g-store-b'],
125
+ ]
126
+ let rr = ['sk-a0', 'sk-a1', 0, undefined]
127
+ assert.strict.deepEqual(r, rr)
128
+ })
129
+
130
+ it('整組金鑰用盡時遞補下一組', async function() {
131
+ let t = await dispatchAiFallback('abc', {
132
+ providers: [
133
+ {
134
+ id: 'g-dead',
135
+ kind: 'opencode',
136
+ exe: fake.exe,
137
+ provider: 'opencode',
138
+ keys: ['sk-bad-1', 'sk-bad-2'],
139
+ extraArgs: ['--fake-fail-key=sk-bad'],
140
+ },
141
+ { id: 'g-alive', kind: 'claude', exe: fake.exe },
142
+ ],
143
+ })
144
+ let o = JSON.parse(t.stdout)
145
+ let r = [
146
+ t.ok,
147
+ t.providerId,
148
+ t.keyIndex,
149
+ o.args[0], //claude轉接器之固定首旗標
150
+ t.tried.map((x) => [x.providerId, x.outcome]),
151
+ ]
152
+ let rr = [
153
+ true,
154
+ 'g-alive',
155
+ null,
156
+ '-p',
157
+ [['g-dead', 'next-key'], ['g-dead', 'next-key'], ['g-alive', 'ok']],
158
+ ]
159
+ assert.strict.deepEqual(r, rr)
160
+ })
161
+
162
+ it('逾時為與金鑰無關之失敗, 一把即整組跳過不逐把空耗', async function() {
163
+ let t = await dispatchAiFallback('abc', {
164
+ providers: [
165
+ {
166
+ id: 'g-hang',
167
+ kind: 'opencode',
168
+ exe: fake.exe,
169
+ provider: 'opencode',
170
+ keys: ['sk-h0', 'sk-h1', 'sk-h2'],
171
+ extraArgs: ['--fake-sleep=10000'],
172
+ timeoutMs: 1000,
173
+ },
174
+ { id: 'g-next', kind: 'claude', exe: fake.exe },
175
+ ],
176
+ })
177
+ let r = [
178
+ t.ok,
179
+ t.providerId,
180
+ t.tried.length, //hang組僅1筆而非3筆
181
+ t.tried[0].outcome,
182
+ t.tried[0].error.indexOf('TIMEOUT') === 0,
183
+ ]
184
+ let rr = [true, 'g-next', 2, 'skip-group', true]
185
+ assert.strict.deepEqual(r, rr)
186
+ })
187
+
188
+ it('參數錯誤(code=2)整組跳過', async function() {
189
+ let t = await dispatchAiFallback('abc', {
190
+ providers: [
191
+ {
192
+ id: 'g-arg',
193
+ kind: 'opencode',
194
+ exe: fake.exe,
195
+ provider: 'opencode',
196
+ keys: ['sk-x0', 'sk-x1'],
197
+ extraArgs: ['--fake-exit=2'],
198
+ },
199
+ { id: 'g-ok', kind: 'codex', exe: fake.exe },
200
+ ],
201
+ })
202
+ let r = [t.ok, t.providerId, t.tried.map((x) => x.outcome)]
203
+ let rr = [true, 'g-ok', ['skip-group', 'ok']]
204
+ assert.strict.deepEqual(r, rr)
205
+ })
206
+
207
+ it('輸出未過驗證整組跳過, 條目validate覆寫共用預設', async function() {
208
+ let t = await dispatchAiFallback('abc', {
209
+ providers: [
210
+ {
211
+ id: 'g-badout',
212
+ kind: 'opencode',
213
+ exe: fake.exe,
214
+ provider: 'opencode',
215
+ keys: ['sk-v0', 'sk-v1'],
216
+ validate: 'min:100000',
217
+ },
218
+ { id: 'g-anyout', kind: 'claude', exe: fake.exe },
219
+ ],
220
+ })
221
+ let r = [
222
+ t.ok,
223
+ t.providerId,
224
+ t.tried.map((x) => [x.outcome, x.error || null]),
225
+ ]
226
+ let rr = [
227
+ true,
228
+ 'g-anyout',
229
+ [['skip-group', 'OUTPUT_VALIDATION_FAILED'], ['ok', null]],
230
+ ]
231
+ assert.strict.deepEqual(r, rr)
232
+ })
233
+
234
+ it('執行檔不存在(ENOENT)整組跳過', async function() {
235
+ let t = await dispatchAiFallback('abc', {
236
+ providers: [
237
+ { id: 'g-noexe', kind: 'claude', exe: 'no-such-cli-for-test', keys: ['sk-n0', 'sk-n1'] },
238
+ { id: 'g-hasexe', kind: 'claude', exe: fake.exe },
239
+ ],
240
+ })
241
+ let r = [t.ok, t.providerId, t.tried.map((x) => x.outcome)]
242
+ let rr = [true, 'g-hasexe', ['skip-group', 'ok']]
243
+ assert.strict.deepEqual(r, rr)
244
+ })
245
+
246
+ it('kind無效之條目整組跳過, 不中斷後續遞補', async function() {
247
+ let t = await dispatchAiFallback('abc', {
248
+ providers: [
249
+ { id: 'g-unknown', kind: 'gemini' },
250
+ { id: 'g-known', kind: 'claude', exe: fake.exe },
251
+ ],
252
+ })
253
+ let r = [
254
+ t.ok,
255
+ t.providerId,
256
+ t.tried[0].outcome,
257
+ t.tried[0].error.indexOf('unknown ai kind') === 0,
258
+ ]
259
+ let rr = [true, 'g-known', 'skip-group', true]
260
+ assert.strict.deepEqual(r, rr)
261
+ })
262
+
263
+ it('全數供應商失敗時回傳最後一筆失敗結果與完整歷程', async function() {
264
+ let t = await dispatchAiFallback('abc', {
265
+ providers: [
266
+ { id: 'g-f1', kind: 'claude', exe: fake.exe, extraArgs: ['--fake-exit=1'] },
267
+ { id: 'g-f2', kind: 'codex', exe: fake.exe, extraArgs: ['--fake-exit=1'] },
268
+ ],
269
+ })
270
+ let r = [
271
+ t.ok,
272
+ t.code,
273
+ t.error,
274
+ t.providerId,
275
+ t.tried.map((x) => [x.providerId, x.outcome]),
276
+ ]
277
+ let rr = [
278
+ false,
279
+ 1,
280
+ 'Exit code 1',
281
+ 'g-f2',
282
+ [['g-f1', 'next-key'], ['g-f2', 'next-key']],
283
+ ]
284
+ assert.strict.deepEqual(r, rr)
285
+ })
286
+
287
+ it('預算不足一次最低嘗試時停止並回報budget exhausted', async function() {
288
+ let t = await dispatchAiFallback('abc', {
289
+ providers: [{ id: 'g-nobudget', kind: 'claude', exe: fake.exe }],
290
+ budgetMs: 50,
291
+ minAttemptMs: 100,
292
+ })
293
+ let r = [t.ok, t.error, t.tried.map((x) => x.outcome)]
294
+ let rr = [false, 'budget exhausted', ['budget-out']]
295
+ assert.strict.deepEqual(r, rr)
296
+ })
297
+
298
+ it('剩餘預算會壓進attempt之timeoutMs', async function() {
299
+ let t = await dispatchAiFallback('abc', {
300
+ providers: [{
301
+ id: 'g-clamp',
302
+ kind: 'claude',
303
+ exe: fake.exe,
304
+ extraArgs: ['--fake-sleep=10000'], //timeoutMs未給, 預設120000, 應被預算4000封頂
305
+ }],
306
+ budgetMs: 4000,
307
+ minAttemptMs: 100,
308
+ })
309
+ let r = [
310
+ t.ok,
311
+ t.tried[0].outcome,
312
+ t.error.indexOf('TIMEOUT') === 0,
313
+ t.durationMs < 9000, //遠小於fake-sleep之10000, 證明timeout被封頂
314
+ ]
315
+ let rr = [false, 'skip-group', true, true]
316
+ assert.strict.deepEqual(r, rr)
317
+ })
318
+
319
+ it('onEvent依序回報事件, 回調拋出例外不影響主流程', async function() {
320
+ let evs = []
321
+ let t = await dispatchAiFallback('abc', {
322
+ providers: [{
323
+ id: 'g-ev',
324
+ kind: 'opencode',
325
+ exe: fake.exe,
326
+ provider: 'opencode',
327
+ keys: ['sk-bad-e0', 'sk-good-e1'],
328
+ extraArgs: ['--fake-fail-key=sk-bad'],
329
+ }],
330
+ onEvent: (ev) => {
331
+ evs.push([ev.type, ev.providerId, ev.keyIndex])
332
+ throw new Error('callback error should not break the loop')
333
+ },
334
+ })
335
+ let r = [t.ok, evs]
336
+ let rr = [
337
+ true,
338
+ [
339
+ ['try', 'g-ev', 0],
340
+ ['next-key', 'g-ev', 0],
341
+ ['try', 'g-ev', 1],
342
+ ['ok', 'g-ev', 1],
343
+ ],
344
+ ]
345
+ assert.strict.deepEqual(r, rr)
346
+ })
347
+
348
+ it('條目即opt, config與provider等鍵原樣透傳對應轉接器', async function() {
349
+ let config = { provider: { 'agnes-ai': { npm: '@ai-sdk/openai-compatible' } } }
350
+ let t = await dispatchAiFallback('abc', {
351
+ providers: [{
352
+ id: 'g-agnes',
353
+ kind: 'opencode',
354
+ exe: fake.exe,
355
+ model: 'agnes-ai/agnes-2.0-flash',
356
+ provider: 'agnes-ai',
357
+ keys: ['sk-agnes-0'],
358
+ config,
359
+ }],
360
+ })
361
+ let o = JSON.parse(t.stdout)
362
+ let r = [
363
+ t.ok,
364
+ JSON.parse(o.env.OPENCODE_CONFIG_CONTENT),
365
+ JSON.parse(o.env.OPENCODE_AUTH_CONTENT),
366
+ o.args.includes('agnes-ai/agnes-2.0-flash'),
367
+ ]
368
+ let rr = [
369
+ true,
370
+ config,
371
+ { 'agnes-ai': { type: 'api', key: 'sk-agnes-0' } },
372
+ true,
373
+ ]
374
+ assert.strict.deepEqual(r, rr)
375
+ })
376
+
377
+ it('四層情境: 組內輪替與跨組遞補複合', async function() {
378
+ //deepseek兩把全敗(額度型) → agnes第1把敗換第2把成功
379
+ let t = await dispatchAiFallback('abc', {
380
+ providers: [
381
+ {
382
+ id: 'g-ds',
383
+ kind: 'opencode',
384
+ exe: fake.exe,
385
+ provider: 'opencode',
386
+ keys: ['sk-dsbad-0', 'sk-dsbad-1'],
387
+ extraArgs: ['--fake-fail-key=sk-dsbad'],
388
+ },
389
+ {
390
+ id: 'g-ag',
391
+ kind: 'opencode',
392
+ exe: fake.exe,
393
+ provider: 'agnes-ai',
394
+ keys: ['sk-agbad-0', 'sk-aggood-1'],
395
+ extraArgs: ['--fake-fail-key=sk-agbad'],
396
+ },
397
+ { id: 'g-cl', kind: 'claude', exe: fake.exe },
398
+ ],
399
+ })
400
+ let r = [
401
+ t.ok,
402
+ t.providerId,
403
+ t.keyIndex,
404
+ getInjectedKey(t.stdout),
405
+ t.tried.map((x) => [x.keyId, x.outcome]),
406
+ ]
407
+ let rr = [
408
+ true,
409
+ 'g-ag',
410
+ 1,
411
+ 'sk-aggood-1',
412
+ [
413
+ ['g-ds#0', 'next-key'],
414
+ ['g-ds#1', 'next-key'],
415
+ ['g-ag#0', 'next-key'],
416
+ ['g-ag#1', 'ok'],
417
+ ],
418
+ ]
419
+ assert.strict.deepEqual(r, rr)
420
+ })
421
+
422
+ })