w-dispatch-ai 1.0.38 → 1.0.39

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.
Files changed (52) hide show
  1. package/README.md +6 -3
  2. package/dist/w-dispatch-ai.umd.js +2 -2
  3. package/dist/w-dispatch-ai.umd.js.map +1 -1
  4. package/docs/WDispatchAi.mjs.html +1 -1
  5. package/docs/adapters.mjs.html +1 -1
  6. package/docs/budgetFor.mjs.html +1 -1
  7. package/docs/buildValidator.mjs.html +1 -1
  8. package/docs/castPintOr.mjs.html +1 -1
  9. package/docs/checkTruncation.mjs.html +1 -1
  10. package/docs/describeNonJsonBody.mjs.html +1 -1
  11. package/docs/dfTimeoutMs.mjs.html +1 -1
  12. package/docs/dispatchAi.mjs.html +1 -1
  13. package/docs/dispatchAiFallback.mjs.html +25 -2
  14. package/docs/dispatchAiWkf.mjs.html +1 -1
  15. package/docs/dispatchAntigravity.mjs.html +1 -1
  16. package/docs/dispatchApiOpenaiCompat.mjs.html +1 -1
  17. package/docs/dispatchApiOpenaiResponses.mjs.html +1 -1
  18. package/docs/dispatchApiTypesafeSystemone.mjs.html +1 -1
  19. package/docs/dispatchClaude.mjs.html +1 -1
  20. package/docs/dispatchCodex.mjs.html +1 -1
  21. package/docs/dispatchOpencode.mjs.html +1 -1
  22. package/docs/getCliArgs.mjs.html +1 -1
  23. package/docs/getErrorResult.mjs.html +1 -1
  24. package/docs/getErrorType.mjs.html +1 -1
  25. package/docs/global.html +7 -7
  26. package/docs/index.html +1 -1
  27. package/docs/quota_dfQuotaTimeoutMs.mjs.html +1 -1
  28. package/docs/quota_fetchQuotaJson.mjs.html +1 -1
  29. package/docs/quota_fromCodexUsageHttp.mjs.html +1 -1
  30. package/docs/quota_getQuotaAntigravity.mjs.html +1 -1
  31. package/docs/quota_getQuotaClaude.mjs.html +1 -1
  32. package/docs/quota_getQuotaCodex.mjs.html +1 -1
  33. package/docs/quota_readJsonOrNull.mjs.html +1 -1
  34. package/docs/quota_toQuotaLabel.mjs.html +1 -1
  35. package/docs/quota_toQuotaResult.mjs.html +1 -1
  36. package/docs/quota_toQuotaScopedLabel.mjs.html +1 -1
  37. package/docs/quota_toQuotaWindow.mjs.html +1 -1
  38. package/docs/readEnvFile.mjs.html +1 -1
  39. package/docs/resolveProviders.mjs.html +1 -1
  40. package/docs/wkf_callAiWithFallback.mjs.html +1 -1
  41. package/docs/wkf_createFileStore.mjs.html +1 -1
  42. package/docs/wkf_createUsageCounter.mjs.html +1 -1
  43. package/docs/wkf_extractJsonLoose.mjs.html +1 -1
  44. package/docs/wkf_noSideEffectPrefix.mjs.html +1 -1
  45. package/docs/wkf_runFanout.mjs.html +1 -1
  46. package/docs/wkf_runFanoutPipeline.mjs.html +1 -1
  47. package/docs/wkf_runRolePipeline.mjs.html +1 -1
  48. package/docs/wkf_salvageTruncatedArray.mjs.html +1 -1
  49. package/package.json +1 -1
  50. package/src/dispatchAiFallback.mjs +24 -1
  51. package/test/tools/fakeServerForApiTest.mjs +7 -0
  52. package/test/unit-dispatchAiFallback.test.mjs +245 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "w-dispatch-ai",
3
- "version": "1.0.38",
3
+ "version": "1.0.39",
4
4
  "main": "dist/w-dispatch-ai.umd.js",
5
5
  "dependencies": {
6
6
  "wsemi": "^1.9.3"
@@ -54,6 +54,14 @@ import { BODY_NOT_JSON } from './describeNonJsonBody.mjs'
54
54
  // 中止後每個後續呼叫進門即回ABORTED, 整條工作流自然快速收束, 不需逐層實作。
55
55
  // 不中止進行中之嘗試(不殺子進程/不斷開請求), 此為已知設計取捨(避免侵入execCli層)。
56
56
  //
57
+ // 【組盡事件(group-exhausted)】逐次事件(try/next-key/skip-group等)不帶呼叫識別, 而同一onEvent常被並行呼叫共用
58
+ // (如runFanout之各席位)。呼叫端要判斷「某條目在這次呼叫裡整組試完仍無成交」(如健康層據以降序)時,
59
+ // 若以金鑰數與逐把失敗次數重建, 並行呼叫跨越一次成交就會多計或少計——游標只在成交時推進,
60
+ // 在途呼叫與新呼叫的起點不同(2026-09-24下游w-knowledge-extract實測重現), 且重建本身依賴本層之
61
+ // 游標推進時機、每把至多一次、金鑰濾法三項內部性質。故由本層於本組未成交而試完時直接發出,
62
+ // 每次呼叫每組恰一次; 成交、預算用盡、中止(本組未試完)皆不發——後兩者屬呼叫端的時間或意願, 非該組故障。
63
+ // 組邊界只發事件, 不寫入tried(tried為逐次嘗試歷程, 其長度即嘗試次數)。
64
+ //
57
65
  // 【meta保留鍵】「剔除自用鍵後原樣轉傳」令條目即調校點, 但呼叫端放進條目/opt的任何
58
66
  // 自有欄位都會被靜默轉傳——保留meta一鍵保證永不轉傳, 呼叫端要掛分類/標籤/註記
59
67
  // 一律放meta, 與轉傳機制永久絕緣(工作流各層之規格物件同此約定)。
@@ -276,7 +284,7 @@ function isKeyIndependentFail(r) {
276
284
  * @param {Function} [opt.coolDetect=null] 輸入冷卻觸發判定函數(r)=>Boolean,收完整失敗結果物件(含stdout、stderr、code、error),回傳true即視同冷卻觸發(內建429/TIMEOUT觸發不受影響)——CLI類限流埋在stderr且各家字樣不同,簽章表由觀察到字樣的呼叫端維護,如(r)=>/FreeUsageLimitError/i.test(r.stderr||'');僅cooldownMs>0時有效,回調拋出例外視同false,預設null
277
285
  * @param {Function} [opt.shouldStop=null] 輸入中止判定函數()=>Boolean,於每次嘗試之間檢查,回傳true即停止遞補回報ABORTED(不中止進行中之嘗試)——供呼叫端於成果已無人接收時(如客戶端斷線)止損;經工作流層原樣轉傳,中止後各後續呼叫進門即回ABORTED令整條工作流快速收束;回調拋出例外視同false,預設null
278
286
  * @param {*} [opt.meta=undefined] 輸入呼叫端自有資訊,保留鍵保證永不轉傳各轉接器,預設undefined
279
- * @param {Function} [opt.onEvent=null] 輸入事件回調函數(ev)=>{},ev.type可為'try'、'ok'、'next-key'、'skip-group'、'budget-out'、'aborted'、'cooled'(冷卻觸發,帶error與cooldownMs,僅cooldownMs>0時出現);失敗事件(next-key/skip-group)另帶errorType、stdout(被拒回覆)與stderr(錯誤輸出)供診斷,後兩者於失敗路徑已由轉接器截斷;回調拋出例外不影響主流程,預設null
287
+ * @param {Function} [opt.onEvent=null] 輸入事件回調函數(ev)=>{},ev.type可為'try'、'ok'、'next-key'、'skip-group'、'budget-out'、'aborted'、'cooled'(冷卻觸發,帶error與cooldownMs,僅cooldownMs>0時出現)、'group-exhausted'(本組未成交而試完,每次呼叫每組恰一次,位於本組最後一個next-key或skip-group之後、下一組首個try之前;帶keys(有效金鑰數,0代表登入態之單一虛擬金鑰)、attempted(本組實際嘗試數)、by('all-keys'每把皆換鑰失敗,或'skip-group'以與金鑰無關之失敗收尾)、errorTypes(本組各次嘗試之errorType依序)與error(本組最後一次錯誤);成交、預算用盡、中止之組不發,亦不寫入tried);失敗事件(next-key/skip-group)另帶errorType、stdout(被拒回覆)與stderr(錯誤輸出)供診斷,後兩者於失敗路徑已由轉接器截斷;回調拋出例外不影響主流程,預設null
280
288
  * @param {Number} [opt.timeoutMs=300000] 輸入各attempt共用之逾時毫秒正整數,條目可覆寫,全套件統一預設300000
281
289
  * @param {String|Function} [opt.validate=undefined] 輸入各attempt共用之stdout驗證規則,條目可覆寫,預設undefined
282
290
  * @param {Boolean} [opt.acceptTruncated=false] 輸入是否接受REST文字類轉接器回報之截斷內容布林值(原樣轉傳轉接器),1.0.37起截斷於validate之前判失敗,validate內含搶救策略者須給true,預設false
@@ -428,6 +436,7 @@ async function dispatchAiFallback(prompt, opt = {}) {
428
436
  //組內逐把嘗試, 每把至多一次, 全敗即組盡遞補下一組
429
437
  let nAttempts = (nk > 0) ? nk : 1
430
438
  let skipGroup = false
439
+ let groupStart = tried.length //本組於tried之起點, 供組盡事件取本組各次嘗試
431
440
  for (let a = 0; a < nAttempts && !skipGroup; a++) {
432
441
 
433
442
  //中止檢查(嘗試邊界): 成果已無人接收時止損, 不中止進行中之嘗試(見檔頭【中止】)
@@ -519,6 +528,20 @@ async function dispatchAiFallback(prompt, opt = {}) {
519
528
  }
520
529
 
521
530
  }
531
+
532
+ //組盡: 走到這裡即本組未成交且已試完(成交、預算用盡、中止皆於迴圈內回傳), 每次呼叫每組恰發一次(見檔頭【組盡事件】)
533
+ let tg = tried.slice(groupStart)
534
+ emit({
535
+ type: 'group-exhausted',
536
+ providerId: id,
537
+ keyIndex: null,
538
+ keyId: id,
539
+ keys: nk, //有效金鑰數(同上方濾法), 0代表登入態之單一虛擬金鑰
540
+ attempted: tg.length, //本組實際送出之嘗試數
541
+ by: skipGroup ? 'skip-group' : 'all-keys', //以與金鑰無關之失敗收尾, 或每把皆換鑰失敗
542
+ errorTypes: tg.map((t) => t.errorType), //本組各次嘗試之errorType, 依嘗試順序
543
+ error: get(lastResult, 'error', ''), //本組最後一次嘗試之錯誤
544
+ })
522
545
  }
523
546
 
524
547
  //全數失敗, 回傳最後一筆失敗結果(含其errorType)與完整歷程
@@ -25,6 +25,7 @@ import zlib from 'zlib'
25
25
  // Content-Encoding(模擬2026-09-24使用端回報之伺服器: 壓縮卻漏標, Node fetch因而不解壓)
26
26
  // TRUNC_ROUTES — 200, 依表回指定之finish_reason與content(截斷處理之規格測試用, 見下方常數)
27
27
  // garbage-200 — 200, Content-Type為application/json但本體為8個非JSON位元組(模擬壓縮或損壞本體)
28
+ // slow-401 — 延遲300ms後回401(與金鑰有關之失敗但耗時, 供組內預算用盡之情境)
28
29
  // 其他 — 404
29
30
  // 【responses之行為路由(依body.model)】
30
31
  // echo — 200, message之output_text為JSON字串{ auth, body }
@@ -367,6 +368,12 @@ async function fakeServerForApiTest() {
367
368
  else if (model === 'br-noheader') {
368
369
  sendUndeclaredBr({ choices: [{ finish_reason: 'stop', message: { role: 'assistant', content: '完成' } }] })
369
370
  }
371
+ else if (model === 'slow-401') {
372
+ setTimeout(() => {
373
+ res.writeHead(401, { 'Content-Type': 'application/json' })
374
+ res.end(JSON.stringify({ type: 'error', error: { type: 'AuthError', message: 'Invalid API key.' } }))
375
+ }, 300)
376
+ }
370
377
  else if (TRUNC_ROUTES[model] !== undefined) {
371
378
  let t = TRUNC_ROUTES[model]
372
379
  res.writeHead(200, { 'Content-Type': 'application/json' })
@@ -1,6 +1,7 @@
1
1
  import assert from 'assert'
2
2
  import dispatchAiFallback from '../src/dispatchAiFallback.mjs'
3
3
  import createFakeCli from './tools/fakeCliForTest.mjs'
4
+ import fakeServerForApiTest from './tools/fakeServerForApiTest.mjs'
4
5
 
5
6
 
6
7
  //assertKey, 由假CLI回聲之OPENCODE_AUTH_CONTENT解出本次注入之金鑰
@@ -18,15 +19,20 @@ let getInjectedKey = (stdout) => {
18
19
  describe('dispatchAiFallback', function() {
19
20
 
20
21
  let fake = null
22
+ let svr = null
21
23
 
22
- before(function() {
24
+ before(async function() {
23
25
  fake = createFakeCli('fake-fallback')
26
+ svr = await fakeServerForApiTest() //組邊界事件之情境需REST假伺服器(401/截斷/延遲401)
24
27
  })
25
28
 
26
- after(function() {
29
+ after(async function() {
27
30
  if (fake) {
28
31
  fake.clean()
29
32
  }
33
+ if (svr) {
34
+ await svr.close()
35
+ }
30
36
  })
31
37
 
32
38
  it('prompt非有效字串時回傳錯誤結果物件且tried為空', async function() {
@@ -676,4 +682,241 @@ describe('dispatchAiFallback', function() {
676
682
  assert.strict.deepEqual(r, rr)
677
683
  })
678
684
 
685
+ it('group-exhausted: 組內每把皆換鑰失敗, 於最後一個next-key之後恰發一次(by為all-keys), tried不增項', async function() {
686
+ let evs = []
687
+ let t = await dispatchAiFallback('abc', {
688
+ providers: [{ id: 'ge-all', kind: 'api-openai-compat', baseURL: svr.url, model: 'echo', keys: ['sk-bad-a', 'sk-bad-b'] }],
689
+ onEvent: (ev) => evs.push(ev),
690
+ })
691
+ let ge = evs.filter((x) => x.type === 'group-exhausted')
692
+ let r = [
693
+ t.ok,
694
+ evs.map((x) => [x.type, x.keyId]),
695
+ ge.map((x) => [x.providerId, x.keyIndex, x.keyId, x.keys, x.attempted, x.by, x.errorTypes]),
696
+ ge[0].error !== '' && ge[0].error === t.tried[1].error, //本組最後一次嘗試之錯誤
697
+ t.tried.map((x) => x.outcome), //組邊界只發事件, 不寫入逐次嘗試歷程
698
+ ]
699
+ let rr = [
700
+ false,
701
+ [['try', 'ge-all#0'], ['next-key', 'ge-all#0'], ['try', 'ge-all#1'], ['next-key', 'ge-all#1'], ['group-exhausted', 'ge-all']],
702
+ [['ge-all', null, 'ge-all', 2, 2, 'all-keys', ['http', 'http']]],
703
+ true,
704
+ ['next-key', 'next-key'],
705
+ ]
706
+ assert.strict.deepEqual(r, rr)
707
+ })
708
+
709
+ it('group-exhausted: 換鑰後遇與金鑰無關之失敗(截斷)整組跳過, by為skip-group', async function() {
710
+ let evs = []
711
+ let t = await dispatchAiFallback('abc', {
712
+ providers: [{ id: 'ge-skip2', kind: 'api-openai-compat', baseURL: svr.url, model: 'trunc-text', keys: ['sk-bad-1', 'sk-good-2'] }],
713
+ onEvent: (ev) => evs.push(ev),
714
+ })
715
+ let ge = evs.filter((x) => x.type === 'group-exhausted')
716
+ let r = [
717
+ t.ok,
718
+ evs.map((x) => [x.type, x.keyId]),
719
+ ge.map((x) => [x.keys, x.attempted, x.by, x.errorTypes]),
720
+ ]
721
+ let rr = [
722
+ false,
723
+ [['try', 'ge-skip2#0'], ['next-key', 'ge-skip2#0'], ['try', 'ge-skip2#1'], ['skip-group', 'ge-skip2#1'], ['group-exhausted', 'ge-skip2']],
724
+ [[2, 2, 'skip-group', ['http', 'incomplete']]],
725
+ ]
726
+ assert.strict.deepEqual(r, rr)
727
+ })
728
+
729
+ it('group-exhausted: 首把即整組跳過時attempted為1, keys為濾除無效值後之有效金鑰數', async function() {
730
+ let evs = []
731
+ await dispatchAiFallback('abc', {
732
+ providers: [{ id: 'ge-skip1', kind: 'api-openai-compat', baseURL: svr.url, model: 'trunc-text', keys: ['sk-good-1', '', null, 'sk-good-2'] }],
733
+ onEvent: (ev) => evs.push(ev),
734
+ })
735
+ let ge = evs.filter((x) => x.type === 'group-exhausted')
736
+ let r = [
737
+ evs.map((x) => [x.type, x.keyId]),
738
+ ge.map((x) => [x.keys, x.attempted, x.by, x.errorTypes]),
739
+ ]
740
+ let rr = [
741
+ [['try', 'ge-skip1#0'], ['skip-group', 'ge-skip1#0'], ['group-exhausted', 'ge-skip1']],
742
+ [[2, 1, 'skip-group', ['incomplete']]],
743
+ ]
744
+ assert.strict.deepEqual(r, rr)
745
+ })
746
+
747
+ it('group-exhausted: 組內有任一把成交則不發(含換鑰後成交)', async function() {
748
+ let evs = []
749
+ let t = await dispatchAiFallback('abc', {
750
+ providers: [{ id: 'ge-ok', kind: 'api-openai-compat', baseURL: svr.url, model: 'echo', keys: ['sk-bad-1', 'sk-good-2'] }],
751
+ onEvent: (ev) => evs.push([ev.type, ev.keyId]),
752
+ })
753
+ let r = [t.ok, evs]
754
+ let rr = [true, [['try', 'ge-ok#0'], ['next-key', 'ge-ok#0'], ['try', 'ge-ok#1'], ['ok', 'ge-ok#1']]]
755
+ assert.strict.deepEqual(r, rr)
756
+ })
757
+
758
+ it('group-exhausted: 組內預算用盡或中止(未試完)者不發, 之前已試完之組照發', async function() {
759
+ //預算用盡: 第1把延遲300ms後401, 輪到第2把時剩餘預算不足minAttemptMs → budget-out, 本組未試完
760
+ let evs1 = []
761
+ let t1 = await dispatchAiFallback('abc', {
762
+ providers: [{ id: 'ge-bud', kind: 'api-openai-compat', baseURL: svr.url, model: 'slow-401', keys: ['sk-x-1', 'sk-x-2'] }],
763
+ budgetMs: 1000,
764
+ minAttemptMs: 800,
765
+ onEvent: (ev) => evs1.push([ev.type, ev.keyId]),
766
+ })
767
+ //中止: 輪到第2把時shouldStop轉true → aborted, 本組未試完
768
+ let n2 = 0
769
+ let evs2 = []
770
+ let t2 = await dispatchAiFallback('abc', {
771
+ providers: [{ id: 'ge-abt', kind: 'api-openai-compat', baseURL: svr.url, model: 'echo', keys: ['sk-bad-1', 'sk-bad-2'] }],
772
+ shouldStop: () => n2++ >= 1,
773
+ onEvent: (ev) => evs2.push([ev.type, ev.keyId]),
774
+ })
775
+ //跨組: 第1組已試完(照發), 第2組首把之前中止(不發)
776
+ let n3 = 0
777
+ let evs3 = []
778
+ let t3 = await dispatchAiFallback('abc', {
779
+ providers: [
780
+ { id: 'ge-abt-a', kind: 'api-openai-compat', baseURL: svr.url, model: 'echo', keys: ['sk-bad-1'] },
781
+ { id: 'ge-abt-b', kind: 'claude', exe: fake.exe },
782
+ ],
783
+ shouldStop: () => n3++ >= 1,
784
+ onEvent: (ev) => evs3.push([ev.type, ev.keyId]),
785
+ })
786
+ //跨組(預算): 第1組已試完(照發), 第2組首把之前剩餘預算不足 → budget-out(不發)
787
+ let evs4 = []
788
+ let t4 = await dispatchAiFallback('abc', {
789
+ providers: [
790
+ { id: 'ge-bud-a', kind: 'api-openai-compat', baseURL: svr.url, model: 'slow-401', keys: ['sk-q-1'] },
791
+ { id: 'ge-bud-b', kind: 'claude', exe: fake.exe },
792
+ ],
793
+ budgetMs: 1000,
794
+ minAttemptMs: 800,
795
+ onEvent: (ev) => evs4.push([ev.type, ev.keyId]),
796
+ })
797
+ let r = [[t1.errorType, evs1], [t2.errorType, evs2], [t3.errorType, evs3], [t4.errorType, evs4]]
798
+ let rr = [
799
+ ['budget', [['try', 'ge-bud#0'], ['next-key', 'ge-bud#0'], ['budget-out', 'ge-bud#1']]],
800
+ ['aborted', [['try', 'ge-abt#0'], ['next-key', 'ge-abt#0'], ['aborted', 'ge-abt']]],
801
+ ['aborted', [['try', 'ge-abt-a#0'], ['next-key', 'ge-abt-a#0'], ['group-exhausted', 'ge-abt-a'], ['aborted', 'ge-abt-b']]],
802
+ ['budget', [['try', 'ge-bud-a#0'], ['next-key', 'ge-bud-a#0'], ['group-exhausted', 'ge-bud-a'], ['budget-out', 'ge-bud-b']]],
803
+ ]
804
+ assert.strict.deepEqual(r, rr)
805
+ })
806
+
807
+ it('group-exhausted: 無keys或單把金鑰之條目失敗亦恰發一次(keys為0或1, attempted為1), 回調拋出例外不影響遞補', async function() {
808
+ let evs = []
809
+ let t = await dispatchAiFallback('abc', {
810
+ providers: [
811
+ { id: 'ge-nokey-a', kind: 'claude', exe: fake.exe, extraArgs: ['--fake-exit=1'] }, //換鑰型失敗(exec)
812
+ { id: 'ge-nokey-b', kind: 'claude', exe: fake.exe, validate: 'min:100000' }, //與金鑰無關之失敗(validation)
813
+ { id: 'ge-onekey', kind: 'api-openai-compat', baseURL: svr.url, model: 'echo', keys: ['sk-bad-1'] }, //單把金鑰換鑰型失敗(http)
814
+ { id: 'ge-badkind', kind: 'no-such-kind', keys: ['k-1', 'k-2'] }, //kind無效, 首把即整組跳過(params)
815
+ { id: 'ge-nokey-ok', kind: 'claude', exe: fake.exe },
816
+ ],
817
+ onEvent: (ev) => {
818
+ evs.push(ev)
819
+ throw new Error('callback error should not break the loop')
820
+ },
821
+ })
822
+ let ge = evs.filter((x) => x.type === 'group-exhausted')
823
+ let r = [
824
+ t.ok,
825
+ t.providerId,
826
+ ge.map((x) => [x.providerId, x.keyIndex, x.keyId, x.keys, x.attempted, x.by, x.errorTypes]),
827
+ ]
828
+ let rr = [
829
+ true,
830
+ 'ge-nokey-ok',
831
+ [
832
+ ['ge-nokey-a', null, 'ge-nokey-a', 0, 1, 'all-keys', ['exec']],
833
+ ['ge-nokey-b', null, 'ge-nokey-b', 0, 1, 'skip-group', ['validation']],
834
+ ['ge-onekey', null, 'ge-onekey', 1, 1, 'all-keys', ['http']],
835
+ ['ge-badkind', null, 'ge-badkind', 2, 1, 'skip-group', ['params']],
836
+ ],
837
+ ]
838
+ assert.strict.deepEqual(r, rr)
839
+ })
840
+
841
+ it('group-exhausted: 多組鏈中每個試完之組各發一次, 皆位於下一組首個try之前(與cooled並存時殿後)', async function() {
842
+ let stored = { cursors: {}, cooling: {} }
843
+ let store = {
844
+ get: () => stored,
845
+ set: (s) => {
846
+ stored = s
847
+ }
848
+ }
849
+ let evs = []
850
+ let t = await dispatchAiFallback('abc', {
851
+ providers: [
852
+ { id: 'ge-m1', kind: 'api-openai-compat', baseURL: svr.url, model: 'echo', keys: ['sk-bad-1', 'sk-bad-2'] },
853
+ { id: 'ge-m2', kind: 'claude', exe: fake.exe, extraArgs: ['--fake-sleep=9000'], timeoutMs: 500 }, //逾時 → cooled與skip-group
854
+ { id: 'ge-m3', kind: 'claude', exe: fake.exe },
855
+ ],
856
+ store,
857
+ cooldownMs: 300000,
858
+ onEvent: (ev) => evs.push([ev.type, ev.keyId]),
859
+ })
860
+ let r = [t.ok, t.providerId, evs]
861
+ let rr = [
862
+ true,
863
+ 'ge-m3',
864
+ [
865
+ ['try', 'ge-m1#0'], ['next-key', 'ge-m1#0'], ['try', 'ge-m1#1'], ['next-key', 'ge-m1#1'], ['group-exhausted', 'ge-m1'],
866
+ ['try', 'ge-m2'], ['cooled', 'ge-m2'], ['skip-group', 'ge-m2'], ['group-exhausted', 'ge-m2'],
867
+ ['try', 'ge-m3'], ['ok', 'ge-m3'],
868
+ ],
869
+ ]
870
+ assert.strict.deepEqual(r, rr)
871
+ })
872
+
873
+ it('group-exhausted: 並行呼叫共用onEvent與store且跨越一次成交時, 事件數恰等於試完之呼叫數', async function() {
874
+ //以金鑰數與逐把失敗次數重建「整組全敗」時, 並行跨越成交會多計或少計(游標只在成交時推進, 各呼叫起點不同);
875
+ //本事件由各呼叫自身之組迴圈發出, 不受並行交錯影響(對應安裝方報告之多計/少計情境)
876
+ let stored = { cursors: {}, cooling: {} }
877
+ let store = {
878
+ get: () => stored,
879
+ set: (s) => {
880
+ stored = s
881
+ }
882
+ }
883
+ let count = (evs) => evs.filter((x) => x === 'group-exhausted').length
884
+ //情境一(多計): X與Y同時自游標0起跑, X首把立即成交(游標推進至1), Y兩把皆於成交之後401; 其後Z自新游標1起跑兩把皆敗
885
+ let base1 = { id: 'ge-par1', kind: 'api-openai-compat', baseURL: svr.url }
886
+ let evs1 = []
887
+ let on1 = (ev) => evs1.push(ev.type)
888
+ let [tx, ty] = await Promise.all([
889
+ dispatchAiFallback('abc', { providers: [{ ...base1, model: 'echo', keys: ['sk-x-0', 'sk-x-1'] }], store, onEvent: on1 }),
890
+ dispatchAiFallback('abc', { providers: [{ ...base1, model: 'slow-401', keys: ['sk-y-0', 'sk-y-1'] }], store, onEvent: on1 }),
891
+ ])
892
+ let tz = await dispatchAiFallback('abc', { providers: [{ ...base1, model: 'slow-401', keys: ['sk-z-0', 'sk-z-1'] }], store, onEvent: on1 })
893
+ //情境二(少計): W首把立即401(成交之前), V首把成交, W次把延遲300ms後401(成交之後)
894
+ let base2 = { id: 'ge-par2', kind: 'api-openai-compat', baseURL: svr.url }
895
+ let evs2 = []
896
+ let on2 = (ev) => evs2.push(ev.type)
897
+ let [tw, tv] = await Promise.all([
898
+ dispatchAiFallback('abc', { providers: [{ ...base2, model: 'slow-401', keys: ['sk-bad-w-0', 'sk-w-1'] }], store, onEvent: on2 }),
899
+ dispatchAiFallback('abc', { providers: [{ ...base2, model: 'echo', keys: ['sk-v-0', 'sk-v-1'] }], store, onEvent: on2 }),
900
+ ])
901
+ let r = [
902
+ [tx.ok, ty.ok, tz.ok],
903
+ ty.tried.map((x) => x.keyId),
904
+ tz.tried.map((x) => x.keyId),
905
+ count(evs1),
906
+ [tw.ok, tv.ok],
907
+ tw.tried.map((x) => x.keyId),
908
+ count(evs2),
909
+ ]
910
+ let rr = [
911
+ [true, false, false],
912
+ ['ge-par1#0', 'ge-par1#1'],
913
+ ['ge-par1#1', 'ge-par1#0'],
914
+ 2, //Y與Z各一
915
+ [false, true],
916
+ ['ge-par2#0', 'ge-par2#1'],
917
+ 1, //W一
918
+ ]
919
+ assert.strict.deepEqual(r, rr)
920
+ })
921
+
679
922
  })