w-dispatch-ai 1.0.38 → 1.0.40

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 (55) hide show
  1. package/README.md +12 -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 +2 -2
  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 +8 -8
  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/src/dispatchCodex.mjs +1 -1
  52. package/src/providers.mjs +49 -13
  53. package/test/tools/fakeServerForApiTest.mjs +7 -0
  54. package/test/unit-dispatchAiFallback.test.mjs +245 -2
  55. package/test/unit-providers.test.mjs +37 -0
@@ -183,7 +183,7 @@ export default runRolePipeline
183
183
  <br class="clear">
184
184
 
185
185
  <footer>
186
- Documentation generated by <a href="https://github.com/jsdoc3/jsdoc">JSDoc 4.0.5</a> on Thu Sep 24 2026 15:32:02 GMT+0800 (台北標準時間) using the <a href="https://github.com/clenemt/docdash">docdash</a> theme.
186
+ Documentation generated by <a href="https://github.com/jsdoc3/jsdoc">JSDoc 4.0.5</a> on Wed Sep 30 2026 11:57:15 GMT+0800 (台北標準時間) using the <a href="https://github.com/clenemt/docdash">docdash</a> theme.
187
187
  </footer>
188
188
 
189
189
  <script>prettyPrint();</script>
@@ -165,7 +165,7 @@ export default salvageTruncatedArray
165
165
  <br class="clear">
166
166
 
167
167
  <footer>
168
- Documentation generated by <a href="https://github.com/jsdoc3/jsdoc">JSDoc 4.0.5</a> on Thu Sep 24 2026 15:32:02 GMT+0800 (台北標準時間) using the <a href="https://github.com/clenemt/docdash">docdash</a> theme.
168
+ Documentation generated by <a href="https://github.com/jsdoc3/jsdoc">JSDoc 4.0.5</a> on Wed Sep 30 2026 11:57:15 GMT+0800 (台北標準時間) using the <a href="https://github.com/clenemt/docdash">docdash</a> theme.
169
169
  </footer>
170
170
 
171
171
  <script>prettyPrint();</script>
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.40",
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)與完整歷程
@@ -54,7 +54,7 @@ let OWN_KEYS = ['exe', 'model', 'sandbox', 'extraArgs', 'input']
54
54
  * @param {String} prompt 輸入提示詞字串,一律以stdin傳入子進程
55
55
  * @param {Object} [opt={}] 輸入設定物件,預設{}
56
56
  * @param {String} [opt.exe='codex'] 輸入codex執行檔名稱或絕對路徑字串,給予名稱時由execCli自系統PATH解析,預設'codex'
57
- * @param {String} [opt.model=''] 輸入模型ID字串,例如'gpt-5.6-luna'、'gpt-6-sol',預設''代表不帶`-m`旗標
57
+ * @param {String} [opt.model=''] 輸入模型ID字串,例如'gpt-5.6-luna'、'gpt-6.1-sol',預設''代表不帶`-m`旗標(此時由CLI依config.toml之model或官方推薦預設決定)
58
58
  * @param {String} [opt.sandbox='workspace-write'] 輸入沙箱模式字串,例如'read-only'、'workspace-write'、'danger-full-access',預設'workspace-write'
59
59
  * @param {Array} [opt.extraArgs=[]] 輸入額外命令列旗標字串陣列,例如['--config', 'model_reasoning_effort="max"'],將接於固定旗標之後,預設[]
60
60
  * @param {Number} [opt.timeoutMs=300000] 輸入逾時毫秒正整數,逾時將強制關閉子進程及其子孫程序,全套件統一預設300000
package/src/providers.mjs CHANGED
@@ -194,6 +194,26 @@ let OC_READONLY = { edit: 'deny', bash: 'ask' }
194
194
  let CLAUDE_READONLY = ['--tools', 'Read,Glob,Grep', '--strict-mcp-config']
195
195
 
196
196
 
197
+ //各條目之思考強度(單一來源, 2026-09-30使用者指示一律high): 各家參數不同, 條目引用下列常數而不手寫。
198
+ // claude --effort(low/medium/high/xhigh/max): 未給時-p實測為medium(Claude Code 2.1.285, session紀錄之effort欄)。
199
+ // codex --config model_reasoning_effort: 未給時沿用執行機之~/.codex/config.toml(本機為high), 再無則為模型預設
200
+ // (app-server model/list之defaultReasoningEffort, gpt-6.1-sol為low)——明給令條目不隨安裝機之設定而變;
201
+ // 同日實測--config覆寫優先於config.toml(給low時session記為low)。
202
+ // opencode --variant: 僅部分模型有檔位, 以`opencode models <provider> --verbose`之variants查(同日opencode 1.18.33:
203
+ // muse-spark-1.2/1.3與space-bunny有high; big-pickle、mimo無檔位; agnes-ai、poolside為自訂provider亦無),
204
+ // 無檔位之條目不帶。同日實測space-bunny之variant low/high推理token為18/71, opencode.db之訊息記錄variant。
205
+ // REST body.reasoning_effort: 僅zen:space-bunny-free帶——opencode模型目錄宣告其high檔即{reasoningEffort:'high'}
206
+ // (@ai-sdk/openai-compatible轉為此欄), 與oc:版同參數。agnes接受此欄但同日兩題各兩輪推理token無一致差異,
207
+ // 視為未生效故不帶(其預設即會思考, usage可見推理token)。poolside之思考為開關(chat_template_kwargs.enable_thinking)
208
+ // 而非檔位, 同日由關改開(見該條); jev(systemone)為決策模型無此概念。
209
+ // agy 以模型slug之檔位表示(gemini-3.8-flash-high), 不另帶--effort(與slug檔位不一致時agy報conflicts)。
210
+ // 注意: 條目覆寫extraArgs時須一併帶上本常數(與防寫常數), 否則回退各CLI之預設強度。
211
+ let EFFORT = 'high'
212
+ let CLAUDE_EFFORT = ['--effort', EFFORT]
213
+ let CODEX_EFFORT = ['--config', `model_reasoning_effort="${EFFORT}"`]
214
+ let OC_EFFORT = ['--variant', EFFORT]
215
+
216
+
197
217
  let providers = [
198
218
 
199
219
  //cli版
@@ -246,7 +266,10 @@ let providers = [
246
266
  config: {
247
267
  permission: OC_READONLY,
248
268
  },
249
- //2026-09-03實測8.1s(opencode CLI 1.18.27); 同模型另有zen:REST版, 額度池與故障域各自獨立
269
+ extraArgs: OC_EFFORT,
270
+ //2026-09-03實測8.1s(opencode CLI 1.18.27); 同模型另有zen:REST版, 額度池與故障域各自獨立。
271
+ //2026-09-30(opencode 1.18.33)連3次2.0s即失敗: 伺服器回UnknownError「Unexpected server error」, 帶不帶--variant皆同,
272
+ //同時1.3正常——屬上游暫時故障, 依套件哲學保留不移除(恢復之偵測即下次再打)
250
273
  },
251
274
  {
252
275
  id: 'oc:opencode/muse-spark-1.3-contributor-free',
@@ -257,6 +280,7 @@ let providers = [
257
280
  config: {
258
281
  permission: OC_READONLY,
259
282
  },
283
+ extraArgs: OC_EFFORT,
260
284
  //2026-09-03實測6.1s(opencode CLI 1.18.27)。註: 同日同模型走zen REST回HTTP 500,
261
285
  //僅CLI路徑可用——同模型不同路徑屬不同供應商之實例(故未增zen:對應條目)
262
286
  },
@@ -294,6 +318,7 @@ let providers = [
294
318
  config: {
295
319
  permission: OC_READONLY,
296
320
  },
321
+ extraArgs: OC_EFFORT,
297
322
  //2026-09-24新增(官方v1/v2文件、CLI清單與/zen/v1/models皆有; 〈Pricing〉全Free, 限時免費之stealth推理模型,
298
323
  //context 1M、可輸入圖片與影片)。匿名CLI實測(opencode 1.18.32): 簡答8.1s、推理題34.9s答對、
299
324
  //read工具讀相對路徑13.2s正確、要求寫檔被OC_READONLY擋下未落地。官方〈Privacy〉載明其供應商零保留
@@ -312,16 +337,18 @@ let providers = [
312
337
  },
313
338
  //claude/codex各兩條: 在前者為預設(全取遞補時先試), 在後者為較強之新模型(2026-09-23使用者指示)
314
339
  {
315
- id: 'claude:sonnet',
340
+ id: 'claude:sonnet', //當前為5.5
316
341
  model: 'sonnet',
317
342
  kind: 'claude',
318
- extraArgs: CLAUDE_READONLY,
343
+ extraArgs: [...CLAUDE_READONLY, ...CLAUDE_EFFORT],
344
+ //官方2026-09-28發布Sonnet 5.5; 2026-09-30於Claude Code 2.1.285以modelUsage實測別名已指向claude-sonnet-5-5。
345
+ //用別名則新版Sonnet發布即自動切換(與opus-5.5寫全名之取捨見該條)
319
346
  },
320
347
  {
321
348
  id: 'claude:opus-5.5',
322
349
  model: 'claude-opus-5-5',
323
350
  kind: 'claude',
324
- extraArgs: CLAUDE_READONLY,
351
+ extraArgs: [...CLAUDE_READONLY, ...CLAUDE_EFFORT],
325
352
  //2026-09-23新增Opus 5.5(官方2026-09-22發布, API id claude-opus-5-5): Claude Code 2.1.280實測4.9~5.1s,
326
353
  //以--output-format json之modelUsage確認實際服務模型為claude-opus-5-5。
327
354
  //刻意寫全名而非別名'opus': 別名當下雖同樣指向5.5(同日實測), 但日後新版Opus發布時會無聲切換,
@@ -332,16 +359,20 @@ let providers = [
332
359
  model: 'gpt-5.6-luna',
333
360
  kind: 'codex',
334
361
  sandbox: 'read-only',
362
+ extraArgs: CODEX_EFFORT,
335
363
  },
336
364
  {
337
- id: 'codex:gpt-6-sol',
338
- model: 'gpt-6-sol',
365
+ id: 'codex:gpt-6.1-sol',
366
+ model: 'gpt-6.1-sol',
339
367
  kind: 'codex',
340
368
  sandbox: 'read-only',
341
- //2026-09-23新增GPT-6 Sol(官方定位複雜程式與agentic工作): Codex CLI 0.156.1(穩定版)實測6.3~6.9s,
342
- //該帳號之app-server model/list已列gpt-6-sol/gpt-6-luna/gpt-6-astra; 金絲雀實測唯讀沙箱擋下寫檔。
343
- //openai/codex issue #47420稱「僅alpha版可用」係0.154.0使用者之回報, 0.156.1已不成立;
344
- //若本機為較舊之codex而清單無此模型, 先執行`codex update`。推理強度沿用使用者config(實測為high)
369
+ extraArgs: CODEX_EFFORT,
370
+ //2026-09-30以GPT-6.1 Sol取代GPT-6 Sol(使用者指示; 前者2026-09-23收錄): 官方2026-09-29發布, Codex對Plus/Pro等開放;
371
+ //Codex CLI 0.159.2之app-server model/list已列且為該帳號預設模型(isDefault), 實測8.6s, session紀錄確認服務模型為gpt-6.1-sol;
372
+ //金絲雀實測唯讀沙箱擋下寫檔(回報沙箱唯讀、檔案未落地)。
373
+ //寫全名而非別名: Codex無「最新版sol」之別名——官方Models文件未載別名, model/list各筆亦無別名欄位且upgrade為null;
374
+ //不給model則改用執行機config.toml之model(本機實測不帶-m時跑gpt-5.6-sol)或官方推薦預設(不限sol系), 故新版發布時手動換。
375
+ //若本機為較舊之codex而清單無此模型, 先執行`codex update`
345
376
  },
346
377
 
347
378
  //api版
@@ -385,9 +416,13 @@ let providers = [
385
416
  envVar: 'POOLSIDE_KEYS',
386
417
  baseURL: 'https://inference.poolside.ai/v1',
387
418
  body: {
388
- max_tokens: 8192,
389
- chat_template_kwargs: { enable_thinking: false },
419
+ max_tokens: 32768,
420
+ chat_template_kwargs: { enable_thinking: true },
390
421
  },
422
+ //思考為開關而非檔位: 2026-09-30依「思考強度一律high」由false改true(原false無紀錄說明)。同日實測(第1把金鑰):
423
+ //不帶此欄即為開啟(推理829、27.6s); false時推理0、3.8s但一題正整數邊長矩形題答錯(12,3,6), true時答對(3,3,3、20.7s)。
424
+ //reasoning_effort於false時HTTP 400、true時接受但推理token無一致差異, 故不帶。開思考後推理token計入max_tokens,
425
+ //依README「推理模型請放寬body.max_tokens」由8192改32768(同日實測接受, 列10縣市題用538)
391
426
  },
392
427
  {
393
428
  id: 'zen:space-bunny-free',
@@ -395,11 +430,12 @@ let providers = [
395
430
  kind: 'api-openai-compat',
396
431
  envVar: 'OPENCODE_KEYS',
397
432
  baseURL: 'https://opencode.ai/zen/v1',
398
- body: { max_tokens: 32768 },
433
+ body: { max_tokens: 32768, reasoning_effort: EFFORT },
399
434
  //2026-09-24新增: 對話型免費模型中少數REST可通者(免費層閘門未套用, 見檔頭; 例外可能隨時收回)——
400
435
  //匿名1.9s、第1把金鑰2.0s皆200。max_tokens刻意不用zen條目慣例之8192: 此為推理模型, 推理token計入max_tokens,
401
436
  //同日實測列10縣市一題即用5222(推理4849+正文373), 8192易截斷(轉接器遇截斷預設判失敗換家, 見checkTruncation.mjs);
402
437
  //長文(README前8000字)摘要成JSON一題用3385(推理3045), 三題finish_reason皆stop, 故取32768留約6倍餘裕。
438
+ //2026-09-30加reasoning_effort(思考強度見EFFORT常數): 同日帶high列10縣市題用277(推理89)、6.6s、finish_reason為stop
403
439
  },
404
440
 
405
441
  ]
@@ -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
  })
@@ -109,4 +109,41 @@ describe('providers', function() {
109
109
  assert.strict.deepEqual(r, rr)
110
110
  })
111
111
 
112
+ it('思考強度一律high: claude、codex、antigravity條目必明給; opencode之--variant與REST之reasoning_effort帶則須為high, REST不得關閉思考', function() {
113
+ //未明給時claude回退-p預設(2026-09-30實測medium)、codex回退執行機之config.toml, 結果隨安裝機而異;
114
+ //opencode之variant與REST之reasoning_effort僅部分模型有(見providers.mjs之EFFORT常數), 故只檢「帶則為high」
115
+ let flagValues = (a, flag) => {
116
+ let arr = Array.isArray(a) ? a : []
117
+ let vs = []
118
+ for (let i = 0; i < arr.length - 1; i++) {
119
+ if (arr[i] === flag) {
120
+ vs.push(arr[i + 1])
121
+ }
122
+ }
123
+ return vs
124
+ }
125
+ let isHigh = (p) => {
126
+ if (p.kind === 'claude') {
127
+ let vs = flagValues(p.extraArgs, '--effort')
128
+ return vs.length === 1 && vs[0] === 'high'
129
+ }
130
+ if (p.kind === 'codex') {
131
+ let vs = flagValues(p.extraArgs, '--config').filter((s) => /^model_reasoning_effort\s*=/.test(s))
132
+ return vs.length === 1 && vs[0] === 'model_reasoning_effort="high"'
133
+ }
134
+ if (p.kind === 'antigravity') {
135
+ return /-high$/.test(p.model) || p.effort === 'high' //檔位以slug或effort表示
136
+ }
137
+ if (p.kind === 'opencode') {
138
+ return flagValues(p.extraArgs, '--variant').every((v) => v === 'high')
139
+ }
140
+ let body = p.body || {}
141
+ let thinkingOff = !!body.chat_template_kwargs && body.chat_template_kwargs.enable_thinking === false
142
+ return (body.reasoning_effort === undefined || body.reasoning_effort === 'high') && !thinkingOff
143
+ }
144
+ let r = providers.map((p) => [p.id, isHigh(p)])
145
+ let rr = providers.map((p) => [p.id, true])
146
+ assert.strict.deepEqual(r, rr)
147
+ })
148
+
112
149
  })