w-dispatch-ai 1.0.35 → 1.0.37

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 (63) hide show
  1. package/README.md +13 -7
  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 +2 -2
  5. package/docs/adapters.mjs.html +2 -2
  6. package/docs/budgetFor.mjs.html +2 -2
  7. package/docs/buildValidator.mjs.html +2 -2
  8. package/docs/castPintOr.mjs.html +2 -2
  9. package/docs/checkTruncation.mjs.html +199 -0
  10. package/docs/dfTimeoutMs.mjs.html +2 -2
  11. package/docs/dispatchAi.mjs.html +2 -2
  12. package/docs/dispatchAiFallback.mjs.html +33 -8
  13. package/docs/dispatchAiWkf.mjs.html +2 -2
  14. package/docs/dispatchAntigravity.mjs.html +2 -2
  15. package/docs/dispatchApiOpenaiCompat.mjs.html +98 -30
  16. package/docs/dispatchApiOpenaiResponses.mjs.html +102 -25
  17. package/docs/dispatchApiTypesafeSystemone.mjs.html +23 -15
  18. package/docs/dispatchClaude.mjs.html +2 -2
  19. package/docs/dispatchCodex.mjs.html +2 -2
  20. package/docs/dispatchOpencode.mjs.html +2 -2
  21. package/docs/getCliArgs.mjs.html +2 -2
  22. package/docs/getErrorResult.mjs.html +2 -2
  23. package/docs/getErrorType.mjs.html +6 -4
  24. package/docs/global.html +1237 -117
  25. package/docs/index.html +2 -2
  26. package/docs/quota_dfQuotaTimeoutMs.mjs.html +2 -2
  27. package/docs/quota_fetchQuotaJson.mjs.html +2 -2
  28. package/docs/quota_fromCodexUsageHttp.mjs.html +2 -2
  29. package/docs/quota_getQuotaAntigravity.mjs.html +2 -2
  30. package/docs/quota_getQuotaClaude.mjs.html +2 -2
  31. package/docs/quota_getQuotaCodex.mjs.html +2 -2
  32. package/docs/quota_readJsonOrNull.mjs.html +2 -2
  33. package/docs/quota_toQuotaLabel.mjs.html +2 -2
  34. package/docs/quota_toQuotaResult.mjs.html +2 -2
  35. package/docs/quota_toQuotaScopedLabel.mjs.html +2 -2
  36. package/docs/quota_toQuotaWindow.mjs.html +2 -2
  37. package/docs/readEnvFile.mjs.html +2 -2
  38. package/docs/resolveProviders.mjs.html +3 -3
  39. package/docs/wkf_callAiWithFallback.mjs.html +63 -20
  40. package/docs/wkf_createFileStore.mjs.html +2 -2
  41. package/docs/wkf_createUsageCounter.mjs.html +2 -2
  42. package/docs/wkf_extractJsonLoose.mjs.html +2 -2
  43. package/docs/wkf_noSideEffectPrefix.mjs.html +2 -2
  44. package/docs/wkf_runFanout.mjs.html +4 -2
  45. package/docs/wkf_runFanoutPipeline.mjs.html +2 -2
  46. package/docs/wkf_runRolePipeline.mjs.html +2 -2
  47. package/docs/wkf_salvageTruncatedArray.mjs.html +11 -2
  48. package/package.json +1 -1
  49. package/src/checkTruncation.mjs +127 -0
  50. package/src/dispatchAiFallback.mjs +31 -6
  51. package/src/dispatchApiOpenaiCompat.mjs +96 -28
  52. package/src/dispatchApiOpenaiResponses.mjs +100 -23
  53. package/src/dispatchApiTypesafeSystemone.mjs +21 -13
  54. package/src/getErrorType.mjs +4 -2
  55. package/src/providers.mjs +41 -8
  56. package/src/resolveProviders.mjs +1 -1
  57. package/src/wkf/callAiWithFallback.mjs +61 -18
  58. package/src/wkf/runFanout.mjs +2 -0
  59. package/src/wkf/salvageTruncatedArray.mjs +9 -0
  60. package/test/tools/fakeServerForApiTest.mjs +79 -2
  61. package/test/unit-dispatchApiOpenaiCompat.test.mjs +169 -1
  62. package/test/unit-dispatchApiOpenaiResponses.test.mjs +63 -5
  63. package/test/unit-dispatchApiTypesafeSystemone.test.mjs +17 -0
package/src/providers.mjs CHANGED
@@ -24,14 +24,16 @@
24
24
  // v2頁列為Free但實測全不可用(CLI回UnknownError、REST回400 Upstream request failed: Model is unavailable):
25
25
  // deepseek-v4-flash-free / laguna-s-2.1-free / ling-3.0-tiny-free / longcat-2.0-free / north-mini-code-free
26
26
  // v1頁有而v2頁未列, 但實測可用: mimo-v2.6-flash-free / jev-1.13-free / muse-spark-1.2與1.3 / nemotron-3.5-lightning-free
27
- // 兩頁皆列且實測可用: big-pickle(CLI 7.1s) / mimo-v2.5-free / nemotron-3-ultra-free
27
+ // 兩頁皆列且實測可用: big-pickle(CLI 7.1s) / mimo-v2.5-free / nemotron-3-ultra-free /
28
+ // space-bunny-free(2026-09-24新上線: CLI 8.1s、REST 1.9s)
28
29
  // 故判準固定為: 文件只用於「端點種類與計價」, 「能不能用」一律以/zen/v1/models或`opencode models`加實測為準。
29
30
  //
30
31
  // ★ 核心事實: zen「不是」單一OpenAI相容端點, 端點依模型家族而異 ★
31
32
  // 依官方文件之端點欄(2026-09-22以v2 Console/Models頁複查, 分佈與v1一致):
32
33
  // /zen/v1/chat/completions @ai-sdk/openai-compatible ← 本套件api-openai-compat僅支援此種
33
34
  // mimo-v2.5-free / ling-3.0-flash-fin-free / nemotron-3-ultra-free /
34
- // nemotron-3.5-lightning-free / big-pickle / glm-* / kimi-* / minimax-* / deepseek-v4-*
35
+ // nemotron-3.5-lightning-free / big-pickle / space-bunny-free(2026-09-24查證) /
36
+ // glm-* / kimi-* / minimax-* / deepseek-v4-*
35
37
  // /zen/v1/responses @ai-sdk/openai (OpenAI Responses API, 非chat/completions)
36
38
  // muse-spark-1.2-contributor-free / muse-spark-1.3-contributor-free / GPT系 / Grok系
37
39
  // /zen/v1/messages @ai-sdk/anthropic Claude系 / Qwen系(qwen3.5~3.7-plus/max)
@@ -77,9 +79,11 @@
77
79
  // 屬刻意之反濫用政策; 付費模型不受限) ★
78
80
  // 症狀: 403 FreeTierError「OpenCode's free tier can only be used from within OpenCode」。
79
81
  // 判定依據(2026-09-18本機矩陣實測, opencode 1.18.31):
80
- // - REST直呼(任何金鑰): /chat/completions與/responses之免費模型一律403 → 該類zen:條目自此為政策性失效。
81
- // 例外(2026-09-22實測): /systemone之jev-1.13-free不受此閘門, 帶金鑰或匿名皆200(0.6~1.2s)——
82
- // 閘門只套在對話型免費模型, 故zen:jev-1.13-free之REST條目可用(見該條)。
82
+ // - REST直呼(任何金鑰): /chat/completions與/responses之免費模型(當日逐一實測者)一律403 → 該類zen:條目自此為政策性失效。
83
+ // 例外(2026-09-22實測): /systemone之jev-1.13-free不受此閘門, 帶金鑰或匿名皆200(0.6~1.2s),
84
+ // 故zen:jev-1.13-free之REST條目可用(見該條)。
85
+ // 例外(2026-09-24實測): /chat/completions之space-bunny-free亦不受此閘門, 匿名與帶金鑰皆200(1.9~2.0s)——
86
+ // 即閘門係逐模型套用, 並非「對話型免費模型一律擋」; 例外可能隨時收回, 新模型能否走REST一律以實測為準。
83
87
  // - opencode CLI匿名或帶金鑰: 通過; 但注入的設定若把bash工具deny掉(permission.bash:'deny'、
84
88
  // 舊式tools.bash:false、agent覆寫deny)即被判非opencode而403; 只deny edit、或改為ask則通過。
85
89
  // 即閘門以「請求之工具清單是否含bash」為指紋之一(另一指紋為User-Agent之版本字串, 非正式版build被拒)。
@@ -92,8 +96,8 @@
92
96
  // (實測: muse-spark 1.2/1.3之REST 500而CLI皆秒級成功)。CLI版模型id為
93
97
  // `opencode/<model-id>`, 可用 `opencode models` 列出; 兩者屬不同供應商條目(見id命名)。
94
98
  //
95
- // 【zen免費模型清單為「更新日快照」, 不保證即為當前狀態】zen:系收錄截至2026-08-21
96
- // 經GET https://opencode.ai/zen/v1/models 查得之免費模型(*-free), 不做好用篩選——
99
+ // 【zen免費模型清單為「更新日快照」, 不保證即為當前狀態】zen:系收錄起於2026-08-21
100
+ // 經GET https://opencode.ai/zen/v1/models 查得之免費模型(*-free), 其後之增刪見本段漂移紀錄(最近一次2026-09-24), 不做好用篩選——
97
101
  // 新模型會上線、舊模型可能下架或限流, 且各模型能力/速度/輸出習慣差異極大
98
102
  // (各條目註解記錄已測特性), 由呼叫端自行評估選用; 要查當前清單, 以OPENCODE_KEYS
99
103
  // 打上述/models端點即可。暫時打不通的條目(限流/額度/上游中斷)依本套件哲學保留不移除:
@@ -134,6 +138,9 @@
134
138
  // 且維護者已明示為刻意政策而非故障, 故不適用「暫時失敗保留」之哲學。同模型之CLI版(oc:opencode/*)
135
139
  // 不受影響且已收錄, 能力覆蓋相同。日後政策放寬時, 依檔頭收錄檢核重新收錄即可(端點種類見端點表)。
136
140
  // 副作用: 移除後預設清單已無api-openai-responses之條目(該kind仍受支援, 僅無預設條目)。
141
+ // 2026-09-24之漂移: 新增space-bunny-free之兩條目(oc:與zen:, 使用者指示)。官方v1/v2文件〈Pricing〉全Free,
142
+ // 說明為限時免費之stealth推理模型(供應商零保留、不以資料訓練), 端點/chat/completions; CLI清單與/zen/v1/models皆有。
143
+ // 與其他對話型免費模型不同, 其REST未被免費層閘門擋下(匿名與帶金鑰皆200), 故兩路皆收; 實測數據見兩條目註解。
137
144
  //
138
145
  // 【使用方式】
139
146
  // import providers from 'w-dispatch-ai/src/providers.mjs'
@@ -278,6 +285,20 @@ let providers = [
278
285
  //2026-09-22新增(官方文件與CLI清單皆有, 端點/chat/completions): 匿名CLI實測7.0s成功;
279
286
  //REST不收zen:版——同日實測403 FreeTierError(對話型免費模型之閘門, 見檔頭)
280
287
  },
288
+ {
289
+ id: 'oc:opencode/space-bunny-free',
290
+ model: 'opencode/space-bunny-free',
291
+ kind: 'opencode',
292
+ provider: 'opencode',
293
+ useStoredAuth: false,
294
+ config: {
295
+ permission: OC_READONLY,
296
+ },
297
+ //2026-09-24新增(官方v1/v2文件、CLI清單與/zen/v1/models皆有; 〈Pricing〉全Free, 限時免費之stealth推理模型,
298
+ //context 1M、可輸入圖片與影片)。匿名CLI實測(opencode 1.18.32): 簡答8.1s、推理題34.9s答對、
299
+ //read工具讀相對路徑13.2s正確、要求寫檔被OC_READONLY擋下未落地。官方〈Privacy〉載明其供應商零保留
300
+ //且不以資料訓練(與big-pickle及各*-free模型之「可能用於改進模型」不同)。同模型另有zen:REST版(見該條)
301
+ },
281
302
  {
282
303
  id: 'agy:gemini-3.8-flash-high',
283
304
  model: 'gemini-3.8-flash-high', //2026-09-03自3.7升版(agy 1.1.25): 實測12.5s vs 3.7之62.2s, 讀檔14.6s vs 57.6s
@@ -348,7 +369,7 @@ let providers = [
348
369
  //付費版jev-1.13未收: 同日以第1把金鑰實測403(Upstream request failed: Model access is disabled),
349
370
  //屬該金鑰工作區未開此模型; 要收須先於Zen後台開啟並確認計費($0.042/1M輸入)。
350
371
  },
351
- //zen:系為2026-08-21快照(檔頭聲明), 各條註記當日以「請只回覆兩個字:完成」實測之結果
372
+ //REST文字生成條目(api-openai-compat): agnes:/poolside:走各家官方端點; zen:走OpenCode Zen轉售(端點表與免費層閘門見檔頭)
352
373
  {
353
374
  id: 'agnes:agnes-3.0-flash',
354
375
  model: 'agnes-3.0-flash',
@@ -368,6 +389,18 @@ let providers = [
368
389
  chat_template_kwargs: { enable_thinking: false },
369
390
  },
370
391
  },
392
+ {
393
+ id: 'zen:space-bunny-free',
394
+ model: 'space-bunny-free',
395
+ kind: 'api-openai-compat',
396
+ envVar: 'OPENCODE_KEYS',
397
+ baseURL: 'https://opencode.ai/zen/v1',
398
+ body: { max_tokens: 32768 },
399
+ //2026-09-24新增: 對話型免費模型中少數REST可通者(免費層閘門未套用, 見檔頭; 例外可能隨時收回)——
400
+ //匿名1.9s、第1把金鑰2.0s皆200。max_tokens刻意不用zen條目慣例之8192: 此為推理模型, 推理token計入max_tokens,
401
+ //同日實測列10縣市一題即用5222(推理4849+正文373), 8192易截斷(轉接器遇截斷預設判失敗換家, 見checkTruncation.mjs);
402
+ //長文(README前8000字)摘要成JSON一題用3385(推理3045), 三題finish_reason皆stop, 故取32768留約6倍餘裕。
403
+ },
371
404
 
372
405
  ]
373
406
 
@@ -57,7 +57,7 @@ import strFindSimilar from 'wsemi/src/strFindSimilar.mjs'
57
57
  * //全取: envVar展開為keys, 缺環境變數者列入skipped
58
58
  * let { providers, table, skipped } = resolveProviders(providersAll, { env })
59
59
  * console.log(providers.length, skipped)
60
- * // => 15 []
60
+ * // => 17 []
61
61
  *
62
62
  * //pick打錯字時, missing附拼寫提示hints(最接近之可用id)
63
63
  * let rm = resolveProviders(providersAll, { env, pick: ['poolside/laguna-s-2.1'] })
@@ -4,8 +4,11 @@ import isearr from 'wsemi/src/isearr.mjs'
4
4
  import isestr from 'wsemi/src/isestr.mjs'
5
5
  import isfun from 'wsemi/src/isfun.mjs'
6
6
  import isobj from 'wsemi/src/isobj.mjs'
7
+ import isbol from 'wsemi/src/isbol.mjs'
7
8
  import dispatchAiFallback from '../dispatchAiFallback.mjs'
8
9
  import dfTimeoutMs from '../dfTimeoutMs.mjs'
10
+ import buildValidator from '../buildValidator.mjs'
11
+ import { safeValidate } from '../checkTruncation.mjs'
9
12
  import extractJsonLoose from './extractJsonLoose.mjs'
10
13
  import NO_SIDE_EFFECT from './noSideEffectPrefix.mjs'
11
14
 
@@ -19,9 +22,18 @@ import NO_SIDE_EFFECT from './noSideEffectPrefix.mjs'
19
22
  // 否則fallback名稱打錯字只會讓遞補鏈無感知地短一截, 事後無從察覺。
20
23
  //
21
24
  // 【JSON驗證接進遞補層】parse+check包成dispatchAiFallback的validate:
22
- // 回覆非法(空回、截斷、缺欄位)時為OUTPUT_VALIDATION_FAILED, 遞補層視為
25
+ // 回覆非法(空回、缺欄位)時為OUTPUT_VALIDATION_FAILED, 遞補層視為
23
26
  // 與金鑰無關之失敗而「整組跳過換下一家」(不換組內金鑰——同模型換金鑰仍是
24
27
  // 同樣的產出習慣); 端點不穩而偶發空回的模型, 以maxRetries調高令同鍵重試。
28
+ // parse或check拋錯視同非法(不reject); 條目自帶validate時與本層validate取交集(兩者皆過才算過)——
29
+ // 遞補層之「條目覆寫共用預設」會讓條目validate取代本層validate, 曾致非法回覆通過轉接器、
30
+ // 本層重解析失敗而回ok:false且error為空(不再遞補), 2026-09-24由複審指出後修正。
31
+ //
32
+ // 【截斷(2026-09-24起)】REST文字類轉接器於validate之前判定截斷, 預設回INCOMPLETE_RESPONSE(errorType
33
+ // incomplete, 遞補層整組跳過且不重試), 不再經驗證失敗路徑。本層之acceptTruncated預設為「有自訂parse且非
34
+ // rawText」: README明文之策略②(組成自訂parse注入搶救截斷前段, 見salvageTruncatedArray.mjs)本身即同意訊號,
35
+ // 既有使用者不必改任何東西; 預設parse(extractJsonLoose)與rawText遇截斷一律判失敗換家。可顯式覆寫。
36
+ // CLI類轉接器拿不到終止訊號, 截斷不可判(已知限制)。
25
37
  //
26
38
  // 【防寫檔前綴】agentic CLI之cwd不是隔離邊界(可用絕對路徑寫到cwd外),
27
39
  // 故預設在prompt前掛「禁止建檔」約束(實測有效); 不需要時傳promptPrefix:''關閉。
@@ -90,16 +102,17 @@ function buildChain(providers, spec) {
90
102
  * @param {Function} [opt.check=null] 輸入結果檢核函數(json)=>Boolean,預設null代表只要能解析出JSON即通過
91
103
  * @param {Function} [opt.parse=extractJsonLoose] 輸入回覆解析函數(stdout)=>Object|null,預設寬鬆JSON抽取
92
104
  * @param {Boolean} [opt.rawText=false] 輸入是否以純文字模式運作布林值,true代表不解析JSON(json欄位為修剪後文字、check收文字),預設false
105
+ * @param {Boolean} [opt.acceptTruncated] 輸入是否接受REST文字類轉接器回報之截斷內容(交parse+check裁決)布林值,預設為「有給自訂parse且非rawText」(自訂parse即搶救策略之同意訊號,見salvageTruncatedArray.mjs),其餘情況截斷一律判失敗換家
93
106
  * @param {String} [opt.promptPrefix=防寫檔約束] 輸入prompt前綴字串,預設為防寫檔約束(見wkf/noSideEffectPrefix.mjs),傳''關閉
94
107
  * @param {Number} [opt.timeoutMs=300000] 輸入單次嘗試逾時毫秒正整數,全套件統一預設300000
95
108
  * @param {Number} [opt.budgetMs=null] 輸入整條遞補鏈之時間預算毫秒正整數,預設null代表不限
96
109
  * @param {Number} [opt.minAttemptMs=20000] 輸入搭配budgetMs之開工門檻毫秒正整數,剩餘預算低於此值即不再開工,預設20000
97
- * @param {Number} [opt.maxRetries=0] 輸入同家重試次數非負整數,預設0(韌性交給遞補;端點不穩偶發空回之模型可調高令同鍵重試)
110
+ * @param {Number} [opt.maxRetries=0] 輸入同家重試次數非負整數,預設0(韌性交給遞補;端點不穩偶發空回之模型可調高令同鍵重試;截斷不重試)
98
111
  * @param {String} [opt.cwd=process.cwd()] 輸入子進程工作目錄字串,預設process.cwd()
99
112
  * @param {Object} [opt.store=null] 輸入游標持久化物件{get,set},預設null代表用行程內記憶體
100
113
  * @param {*} [opt.meta=undefined] 輸入呼叫端自有資訊(分類、標籤、註記),保留鍵保證永不轉傳下層,預設undefined
101
114
  * @param {Function} [opt.onEvent=null] 輸入遞補層事件回調函數,預設null。除上列外之其餘鍵(retryDelayMs、maxBuffer、shouldStop、coolDetect、cooldownMs等)亦一律原樣轉傳dispatchAiFallback
102
- * @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否取得可用結果布林值)、json(解析後物件,rawText模式下為文字)、providerId(實際使用之名稱)、keyIndex、keyId、ms(總耗時毫秒)、tried(遞補嘗試歷程陣列)、usage(api類之token用量原樣透傳,CLI類為null)、error(錯誤訊息字串)、errorType(僅失敗時,機器可讀錯誤類別字串,一覽見getErrorType.mjs檔頭),本函數不會reject
115
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否取得可用結果布林值)、json(解析後物件,rawText模式下為文字)、providerId(實際使用之名稱)、keyIndex、keyId、ms(總耗時毫秒)、tried(遞補嘗試歷程陣列)、usage(api類之token用量原樣透傳,CLI類為null)、finishReason(REST文字類之正規化終止原因字串,CLI類為'')、truncated(是否為截斷內容布林值,接受搶救時ok亦可能為true)、error(錯誤訊息字串)、errorType(僅失敗時,機器可讀錯誤類別字串,一覽見getErrorType.mjs檔頭),本函數不會reject
103
116
  * @example
104
117
  * //need cli in system PATH
105
118
  *
@@ -152,7 +165,8 @@ async function callAiWithFallback(prompt, opt = {}) {
152
165
 
153
166
  let rawText = get(opt, 'rawText', false) === true
154
167
  let parse = get(opt, 'parse', null)
155
- if (!isfun(parse)) {
168
+ let parseGiven = isfun(parse)
169
+ if (!parseGiven) {
156
170
  parse = extractJsonLoose
157
171
  }
158
172
  let check = get(opt, 'check', null)
@@ -160,44 +174,71 @@ async function callAiWithFallback(prompt, opt = {}) {
160
174
  check = null
161
175
  }
162
176
 
177
+ //acceptTruncated, 顯式布林值優先; 預設「有自訂parse且非rawText」——自訂parse即搶救策略之同意訊號(見檔頭【截斷】)
178
+ let acceptTruncated = get(opt, 'acceptTruncated', null)
179
+ if (!isbol(acceptTruncated)) {
180
+ acceptTruncated = parseGiven && !rawText
181
+ }
182
+
163
183
  let promptPrefix = get(opt, 'promptPrefix', null)
164
184
  if (!isestr(promptPrefix)) {
165
185
  promptPrefix = (promptPrefix === '') ? '' : NO_SIDE_EFFECT
166
186
  }
167
187
 
168
- //validate接進遞補層: 非法回覆=這一家失敗, 遞補層換下一家
188
+ //validate接進遞補層: 非法回覆=這一家失敗, 遞補層換下一家; parse/check拋錯視同非法(不reject)
169
189
  let validate = (stdout) => {
170
- if (rawText) {
171
- let s = String(stdout || '').trim()
172
- if (s === '') {
190
+ try {
191
+ if (rawText) {
192
+ let s = String(stdout || '').trim()
193
+ if (s === '') {
194
+ return false
195
+ }
196
+ return check ? check(s) === true : true
197
+ }
198
+ let j = parse(stdout)
199
+ if (j === null) {
173
200
  return false
174
201
  }
175
- return check ? check(s) === true : true
202
+ return check ? check(j) === true : true
176
203
  }
177
- let j = parse(stdout)
178
- if (j === null) {
204
+ catch {
179
205
  return false
180
206
  }
181
- return check ? check(j) === true : true
182
207
  }
183
208
 
184
- //剔除本層自用鍵後原樣轉傳(含minAttemptMs/retryDelayMs/maxBuffer等), providers與validate由本層給定
209
+ //條目自帶validate時與本層validate取交集(兩者皆過才算過), 免遞補層之條目覆寫令本層驗證被繞過(見檔頭)
210
+ chain = chain.map((e) => {
211
+ let ev = buildValidator(get(e, 'validate', null))
212
+ if (ev === null) {
213
+ return e
214
+ }
215
+ return { ...e, validate: (s) => safeValidate(ev, s).pass && validate(s) }
216
+ })
217
+
218
+ //剔除本層自用鍵後原樣轉傳(含minAttemptMs/retryDelayMs/maxBuffer等), providers、validate與acceptTruncated由本層給定
185
219
  let r = await dispatchAiFallback(promptPrefix + prompt, {
186
220
  ...omit(opt, OWN_KEYS),
187
221
  providers: chain,
188
222
  validate,
223
+ acceptTruncated,
189
224
  timeoutMs: get(opt, 'timeoutMs', null) || dfTimeoutMs, //全套件統一預設300000
190
225
  })
191
226
 
192
- //result, 已過validate故此處parse必然成功(同一解析器), 重解析僅為取出物件
227
+ //result, 已過validate故此處parse必然成功(同一解析器), 重解析僅為取出物件; 防禦性接住拋錯
193
228
  let result = null
194
229
  if (r.ok) {
195
- result = rawText ? String(r.stdout || '').trim() : parse(r.stdout)
230
+ try {
231
+ result = rawText ? String(r.stdout || '').trim() : parse(r.stdout)
232
+ }
233
+ catch {
234
+ result = null
235
+ }
196
236
  }
237
+ let ok = r.ok && result !== null
197
238
  let providerId = get(r, 'providerId', null)
198
239
  let keyIndex = get(r, 'keyIndex', null)
199
240
  return {
200
- ok: r.ok && result !== null,
241
+ ok,
201
242
  json: result, //rawText模式下此欄為文字
202
243
  providerId,
203
244
  keyIndex,
@@ -205,8 +246,10 @@ async function callAiWithFallback(prompt, opt = {}) {
205
246
  ms: Date.now() - t0,
206
247
  tried: get(r, 'tried', []),
207
248
  usage: get(r, 'usage', null), //api類轉接器之token用量原樣透傳, CLI類為null(無可靠來源)
208
- error: r.ok ? '' : get(r, 'error', 'unknown error'),
209
- ...(r.ok ? {} : { errorType: get(r, 'errorType', 'exec') }), //機器可讀錯誤類別, 僅失敗時
249
+ finishReason: get(r, 'finishReason', ''), //REST文字類之正規化終止原因, CLI類為''
250
+ truncated: get(r, 'truncated', false) === true, //接受搶救之截斷內容時ok亦可能為true
251
+ error: ok ? '' : (r.ok ? 'OUTPUT_VALIDATION_FAILED' : get(r, 'error', 'unknown error')),
252
+ ...(ok ? {} : { errorType: r.ok ? 'validation' : get(r, 'errorType', 'exec') }), //機器可讀錯誤類別, 僅失敗時
210
253
  }
211
254
  }
212
255
 
@@ -19,6 +19,8 @@ import callAiWithFallback from './callAiWithFallback.mjs'
19
19
  //
20
20
  // 【實測依據(2026-08-10評比)】整合者是本流程的單點故障——端點不穩的模型
21
21
  // (如偶發靜默空回者)當整合者時, 靠spec.fallback遞補或maxRetries調高才能保住整條鏈。
22
+ // 注意推理模型之空回常是推理耗盡輸出上限(REST類以截斷回報且不重試, 見checkTruncation.mjs),
23
+ // 該調的是max_tokens而非maxRetries。
22
24
 
23
25
 
24
26
  /**
@@ -13,6 +13,15 @@
13
13
  //
14
14
  // 【只搶救「掃到結尾仍未閉合」的截斷】陣列有正常閉合但parse失敗屬別種毛病
15
15
  // (非法字元/壞跳脫), 補`]`救不了反而可能拼出語意錯誤的結果, 一律不碰回null。
16
+ //
17
+ // 【與REST截斷判定之銜接(2026-09-24起)】REST文字類轉接器於validate之前判定截斷且預設失敗;
18
+ // 工作流callAi之acceptTruncated預設為「有自訂parse且非rawText」, 故以本工具組成自訂parse注入即同意
19
+ // 接受截斷內容(既有用法不必改), 結果之truncated:true可辨識救回者為半批; 直接呼叫轉接器須自給acceptTruncated:true。
20
+ // CLI類拿不到終止訊號, 其截斷只能靠本工具於解析時發現。
21
+ //
22
+ // 【已知限制(2026-09-24複審指出, 讀碼確認)】只記錄頂層「物件」元素之結束(`}`), 故元素非物件之陣列
23
+ // (如["a","b",…)救不回; 自第一個`[`起算, 故外包物件(如{"items":[…)會救回內層陣列(形狀改變,
24
+ // 呼叫端check須能接受), 前言含`[`(如「[注意]」)則誤判為已閉合而回null。
16
25
 
17
26
 
18
27
  /**
@@ -1,4 +1,5 @@
1
1
  import http from 'http'
2
+ import zlib from 'zlib'
2
3
 
3
4
 
4
5
  // fakeServerForApiTest.mjs — 測試用的OpenAI相容假伺服器
@@ -20,6 +21,9 @@ import http from 'http'
20
21
  // no-choices — 200但無choices(畸形回應路徑用)
21
22
  // not-json — 200但本體非JSON(畸形回應路徑用)
22
23
  // tool-calls — 200但finish_reason為tool_calls(工具不支援路徑用)
24
+ // br-noheader — 200, 請求之Accept-Encoding恰為identity才回原文; 否則回brotli壓縮本體且刻意不帶
25
+ // Content-Encoding(模擬2026-09-24使用端回報之伺服器: 壓縮卻漏標, Node fetch因而不解壓)
26
+ // TRUNC_ROUTES — 200, 依表回指定之finish_reason與content(截斷處理之規格測試用, 見下方常數)
23
27
  // 其他 — 404
24
28
  // 【responses之行為路由(依body.model)】
25
29
  // echo — 200, message之output_text為JSON字串{ auth, body }
@@ -30,19 +34,40 @@ import http from 'http'
30
34
  // reasoning-only — 200且completed但output僅reasoning無message(結構不合規)
31
35
  // tool-calls — 200但output含function_call元素
32
36
  // no-output — 200但缺output陣列
33
- // not-json/slow/err-500/flaky-429 — 同chat/completions之對應行為
37
+ // incomplete-partial — 200, status為incomplete(max_output_tokens)但已有部分文字(可搶救之截斷陣列)
38
+ // incomplete-filter — 200, status為incomplete(content_filter)且有部分文字
39
+ // no-status — 200, 缺status欄但有文字(非截斷之不合規狀態)
40
+ // not-json/slow/err-500/flaky-429/br-noheader — 同chat/completions之對應行為
34
41
  // 其他 — 401(同Zen實測: 未知model回401非404)
35
42
  // 【systemone之行為路由(POST /v1/systemone, 依body.model; 形狀取自2026-09-17 TypeSafe實測)】
36
43
  // echo / jev-latest — 200, 每題回{type:'noul', noul:0.5, echoAuth, echoBody}, 供斷言請求組成
37
44
  // missing-answer — 200但answers缺最後一題
38
45
  // no-answers — 200但無answers
39
46
  // bad-question — 422 {detail:[{type:'union_tag_invalid',...}]}
40
- // not-json/slow/err-500/flaky-429 — 同chat/completions之對應行為
47
+ // not-json/slow/err-500/flaky-429/br-noheader — 同chat/completions之對應行為
41
48
  // 其他 — 400 {detail:{error_type:'api_usage_error', message:'Unknown model: X'}}
42
49
  // 【金鑰規則】Authorization含'sk-bad'一律401(優先於model路由), 模擬無效金鑰;
43
50
  // systemone路由之401本體採TypeSafe形狀{detail:{error_type:'authentication_error'}}。
44
51
 
45
52
 
53
+ //TRUNC_ROUTES, chat/completions之截斷與終止原因情境(2026-09-24截斷處理之規格測試):
54
+ //fr為finish_reason原值(含大小寫與null), content為message.content原值(含null與純空白)
55
+ let TRUNC_ROUTES = {
56
+ 'trunc-empty': { fr: 'length', content: '', usage: { prompt_tokens: 181, completion_tokens: 600, total_tokens: 781, completion_tokens_details: { reasoning_tokens: 600 } } }, //推理耗盡(Zen實測形態)
57
+ 'trunc-null': { fr: 'length', content: null },
58
+ 'trunc-space': { fr: 'length', content: '\n\n' },
59
+ 'trunc-array': { fr: 'length', content: '[{"a":1},{"b":2},{"c":' }, //可搶救前段
60
+ 'trunc-text': { fr: 'length', content: '第一段說明,第二' },
61
+ 'trunc-tail': { fr: 'length', content: '{"a":1}\n\n說明:因為' }, //完整載荷+截尾(寬鬆解析可過)
62
+ 'trunc-upper': { fr: 'LENGTH', content: '[{"a":1},{"b":' }, //大小寫不同
63
+ 'filtered': { fr: 'content_filter', content: '部分內容' },
64
+ 'finish-stop': { fr: 'stop', content: '完成' },
65
+ 'finish-null': { fr: null, content: '完成' },
66
+ 'finish-other': { fr: 'eos', content: '完成' }, //未知值, 不可誤殺
67
+ 'plain-content': { fr: 'stop', content: 'hello world' }, //非JSON純文字
68
+ }
69
+
70
+
46
71
  /**
47
72
  * 啟動測試用OpenAI相容假伺服器
48
73
  *
@@ -73,6 +98,14 @@ async function fakeServerForApiTest() {
73
98
 
74
99
  let auth = req.headers['authorization'] || ''
75
100
 
101
+ //sendUndeclaredBr, br-noheader路由用: 客戶端明示identity才回原文, 否則回brotli本體且刻意不帶Content-Encoding
102
+ let ae = String(req.headers['accept-encoding'] || '').trim().toLowerCase()
103
+ let sendUndeclaredBr = (obj) => {
104
+ let buf = Buffer.from(JSON.stringify(obj))
105
+ res.writeHead(200, { 'Content-Type': 'application/json' })
106
+ res.end(ae === 'identity' ? buf : zlib.brotliCompressSync(buf))
107
+ }
108
+
76
109
  //body非JSON → 400
77
110
  let body = null
78
111
  try {
@@ -129,6 +162,13 @@ async function fakeServerForApiTest() {
129
162
  else if (model === 'bad-question') {
130
163
  send(422, { detail: [{ type: 'union_tag_invalid', loc: ['body', 'questions', ids[0]], msg: 'Input tag does not match any of the expected tags' }] })
131
164
  }
165
+ else if (model === 'br-noheader') {
166
+ let answers = {}
167
+ for (let id of ids) {
168
+ answers[id] = { type: 'noul', noul: 0.5 }
169
+ }
170
+ sendUndeclaredBr({ model: 'jev-1.13.0', answers, usage })
171
+ }
132
172
  else if (model === 'not-json') {
133
173
  res.writeHead(200, { 'Content-Type': 'text/plain' })
134
174
  res.end('plain text body')
@@ -217,6 +257,32 @@ async function fakeServerForApiTest() {
217
257
  else if (model === 'no-output') { //缺output陣列
218
258
  send(200, { status: 'completed', model, id: 'resp_y' })
219
259
  }
260
+ else if (model === 'incomplete-partial') { //max_output_tokens耗盡但已有部分文字
261
+ send(200, {
262
+ status: 'incomplete',
263
+ model,
264
+ error: null,
265
+ incomplete_details: { reason: 'max_output_tokens' },
266
+ output: [{ type: 'message', content: [{ type: 'output_text', text: '[{"a":1},{"b":2},{"c":' }] }],
267
+ usage: { ...usage, output_tokens_details: { reasoning_tokens: 9 } },
268
+ })
269
+ }
270
+ else if (model === 'incomplete-filter') {
271
+ send(200, { status: 'incomplete', model, error: null, incomplete_details: { reason: 'content_filter' }, output: [{ type: 'message', content: [{ type: 'output_text', text: '部分' }] }], usage })
272
+ }
273
+ else if (model === 'no-status') {
274
+ send(200, { model, error: null, incomplete_details: null, output: [{ type: 'message', content: [{ type: 'output_text', text: 'x' }] }], usage })
275
+ }
276
+ else if (model === 'br-noheader') {
277
+ sendUndeclaredBr({
278
+ status: 'completed',
279
+ model,
280
+ error: null,
281
+ incomplete_details: null,
282
+ output: [{ type: 'message', content: [{ type: 'output_text', text: '完成' }] }],
283
+ usage,
284
+ })
285
+ }
220
286
  else if (model === 'not-json') {
221
287
  res.writeHead(200, { 'Content-Type': 'text/plain' })
222
288
  res.end('plain text body')
@@ -290,6 +356,17 @@ async function fakeServerForApiTest() {
290
356
  res.writeHead(200, { 'Content-Type': 'application/json' })
291
357
  res.end(JSON.stringify({ id: 'x', object: 'chat.completion' }))
292
358
  }
359
+ else if (model === 'br-noheader') {
360
+ sendUndeclaredBr({ choices: [{ finish_reason: 'stop', message: { role: 'assistant', content: '完成' } }] })
361
+ }
362
+ else if (TRUNC_ROUTES[model] !== undefined) {
363
+ let t = TRUNC_ROUTES[model]
364
+ res.writeHead(200, { 'Content-Type': 'application/json' })
365
+ res.end(JSON.stringify({
366
+ choices: [{ finish_reason: t.fr, message: { role: 'assistant', content: t.content } }],
367
+ usage: t.usage || { prompt_tokens: 3, completion_tokens: 7, total_tokens: 10 },
368
+ }))
369
+ }
293
370
  else if (model === 'not-json') {
294
371
  res.writeHead(200, { 'Content-Type': 'text/plain' })
295
372
  res.end('plain text body')
@@ -3,6 +3,8 @@ import dispatchApiOpenaiCompat from '../src/dispatchApiOpenaiCompat.mjs'
3
3
  import dispatchAi from '../src/dispatchAi.mjs'
4
4
  import dispatchAiFallback from '../src/dispatchAiFallback.mjs'
5
5
  import dispatchAiWkf from '../src/dispatchAiWkf.mjs'
6
+ import extractJsonLoose from '../src/wkf/extractJsonLoose.mjs'
7
+ import salvageTruncatedArray from '../src/wkf/salvageTruncatedArray.mjs'
6
8
  import fakeServerForApiTest from './tools/fakeServerForApiTest.mjs'
7
9
 
8
10
 
@@ -161,6 +163,20 @@ describe('dispatchApiOpenaiCompat', function() {
161
163
  assert.strict.deepEqual(r, rr)
162
164
  })
163
165
 
166
+ it('預設帶Accept-Encoding: identity——伺服器壓縮卻漏標Content-Encoding時仍可正確解析(2026-09-24使用端回報)', async function() {
167
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'br-noheader' })
168
+ let r = [t.ok, t.code, t.stdout, t.error]
169
+ let rr = [true, 200, '完成', '']
170
+ assert.strict.deepEqual(r, rr)
171
+ })
172
+
173
+ it('呼叫端headers可覆寫Accept-Encoding: 改回允許壓縮時, 遇漏標之壓縮本體即解析失敗(invalid-response)', async function() {
174
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'br-noheader', headers: { 'Accept-Encoding': 'gzip, deflate' } })
175
+ let r = [t.ok, t.code, t.errorType, t.error]
176
+ let rr = [false, 200, 'invalid-response', 'INVALID_RESPONSE: missing choices[0].message.content']
177
+ assert.strict.deepEqual(r, rr)
178
+ })
179
+
164
180
  it('validate字串規則與自訂函數皆可用, 失敗回傳OUTPUT_VALIDATION_FAILED', async function() {
165
181
  let t1 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'empty-content', validate: 'nonempty' })
166
182
  let t2 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'echo', validate: 'nonempty,json' })
@@ -205,7 +221,159 @@ describe('dispatchApiOpenaiCompat', function() {
205
221
  ],
206
222
  })
207
223
  let r = [t.ok, t.providerId, t.tried.map((x) => [x.keyId, x.outcome])]
208
- let rr = [true, 'g-text', [['g-tool#0', 'next-key'], ['g-tool#1', 'next-key'], ['g-text#0', 'ok']]]
224
+ let rr = [true, 'g-text', [['g-tool#0', 'skip-group'], ['g-text#0', 'ok']]]
225
+ assert.strict.deepEqual(r, rr)
226
+ })
227
+
228
+ it('截斷預設失敗: finish_reason為length且可見輸出為空(空字串/null/純空白)一律incomplete, 訊息標明無可見輸出並附reasoning_tokens', async function() {
229
+ let r = []
230
+ for (let model of ['trunc-empty', 'trunc-null', 'trunc-space']) {
231
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model })
232
+ r.push([t.ok, t.errorType, t.truncated, t.finishReason, t.error.indexOf('INCOMPLETE_RESPONSE: finish_reason=length') === 0, t.error.includes('no visible output')])
233
+ }
234
+ let t0 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'trunc-empty' })
235
+ r.push(t0.error.includes('reasoning_tokens=600'))
236
+ let rr = [
237
+ [false, 'incomplete', true, 'length', true, true],
238
+ [false, 'incomplete', true, 'length', true, true],
239
+ [false, 'incomplete', true, 'length', true, true],
240
+ true,
241
+ ]
242
+ assert.strict.deepEqual(r, rr)
243
+ })
244
+
245
+ it('截斷預設失敗且與validate無關: 截斷陣列/截斷文字/完整載荷加截尾 × {無, nonempty, json, 搶救函數}皆incomplete', async function() {
246
+ let salvage = (s) => salvageTruncatedArray(s) !== null
247
+ let r = []
248
+ for (let model of ['trunc-array', 'trunc-text', 'trunc-tail']) {
249
+ for (let validate of [undefined, 'nonempty', 'json', salvage]) {
250
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model, validate })
251
+ r.push([model, t.ok, t.errorType, t.truncated])
252
+ }
253
+ }
254
+ let rr = []
255
+ for (let model of ['trunc-array', 'trunc-text', 'trunc-tail']) {
256
+ for (let i = 0; i < 4; i++) {
257
+ rr.push([model, false, 'incomplete', true])
258
+ }
259
+ }
260
+ assert.strict.deepEqual(r, rr)
261
+ })
262
+
263
+ it('acceptTruncated:true才放行: length且內容非空交validate(通過ok帶truncated、不過incomplete、無validate亦ok); 空內容與content_filter仍失敗', async function() {
264
+ let salvage = (s) => salvageTruncatedArray(s) !== null
265
+ let a1 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'trunc-array', acceptTruncated: true, validate: salvage })
266
+ let a2 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'trunc-text', acceptTruncated: true, validate: 'json' })
267
+ let a3 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'trunc-text', acceptTruncated: true })
268
+ let a4 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'trunc-empty', acceptTruncated: true })
269
+ let a5 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'filtered', acceptTruncated: true })
270
+ let r = [
271
+ [a1.ok, a1.stdout, a1.truncated, a1.finishReason],
272
+ [a2.ok, a2.errorType, a2.truncated, a2.error.includes('rejected by validate')],
273
+ [a3.ok, a3.stdout, a3.truncated],
274
+ [a4.ok, a4.errorType],
275
+ [a5.ok, a5.errorType, a5.truncated, a5.finishReason],
276
+ ]
277
+ let rr = [
278
+ [true, '[{"a":1},{"b":2},{"c":', true, 'length'],
279
+ [false, 'incomplete', true, true],
280
+ [true, '第一段說明,第二', true],
281
+ [false, 'incomplete'],
282
+ [false, 'incomplete', true, 'content_filter'],
283
+ ]
284
+ assert.strict.deepEqual(r, rr)
285
+ })
286
+
287
+ it('防誤殺: finish_reason為stop、null、未知值時照常成功且truncated為false; 截斷判定不分大小寫', async function() {
288
+ let r = []
289
+ for (let model of ['finish-stop', 'finish-null', 'finish-other']) {
290
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model })
291
+ r.push([model, t.ok, t.stdout, t.truncated, t.finishReason])
292
+ }
293
+ let tu = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'trunc-upper' })
294
+ r.push(['trunc-upper', tu.ok, tu.errorType, tu.finishReason])
295
+ let rr = [
296
+ ['finish-stop', true, '完成', false, 'stop'],
297
+ ['finish-null', true, '完成', false, ''],
298
+ ['finish-other', true, '完成', false, 'eos'],
299
+ ['trunc-upper', false, 'incomplete', 'length'],
300
+ ]
301
+ assert.strict.deepEqual(r, rr)
302
+ })
303
+
304
+ it('截斷與金鑰無關: 遞補鏈只試一把即整組跳過且tried帶truncated; 轉接器重試遇截斷不重打', async function() {
305
+ let t = await dispatchAiFallback('abc', {
306
+ providers: [
307
+ { id: 'g-trunc', kind: 'api-openai-compat', baseURL: svr.url, model: 'trunc-text', keys: ['sk-a0', 'sk-a1'] },
308
+ { id: 'g-ok', kind: 'api-openai-compat', baseURL: svr.url, model: 'echo', keys: ['sk-b0'] },
309
+ ],
310
+ })
311
+ let t2 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'trunc-text', maxRetries: 2, retryDelayMs: 10 })
312
+ let r = [t.ok, t.providerId, t.tried.map((x) => [x.keyId, x.outcome, x.truncated === true]), t2.attempts]
313
+ let rr = [true, 'g-ok', [['g-trunc#0', 'skip-group', true], ['g-ok#0', 'ok', false]], 1]
314
+ assert.strict.deepEqual(r, rr)
315
+ })
316
+
317
+ it('validate拋錯視同驗證失敗, Promise照常resolve不reject', async function() {
318
+ let boom = () => {
319
+ throw new Error('boom-validate')
320
+ }
321
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'echo', validate: boom })
322
+ let r = [t.ok, t.errorType, t.error, t.stderr.includes('boom-validate')]
323
+ let rr = [false, 'validation', 'OUTPUT_VALIDATION_FAILED', true]
324
+ assert.strict.deepEqual(r, rr)
325
+ })
326
+
327
+ it('工作流: 自訂搶救parse遇截斷即視為同意→ok且json為前段、truncated可見; 預設parse、rawText、明示acceptTruncated:false遇截斷→失敗', async function() {
328
+ let wkf = dispatchAiWkf({
329
+ providers: {
330
+ 'p-trunc': { kind: 'api-openai-compat', baseURL: svr.url, model: 'trunc-array', keys: ['sk-w1'] },
331
+ 'p-trunc-text': { kind: 'api-openai-compat', baseURL: svr.url, model: 'trunc-text', keys: ['sk-w2'] },
332
+ },
333
+ defaults: { promptPrefix: '' },
334
+ })
335
+ let parseSalvage = (s) => extractJsonLoose(s) || salvageTruncatedArray(s)
336
+ let t1 = await wkf.callAi('abc', { spec: { use: 'p-trunc' }, parse: parseSalvage })
337
+ let t2 = await wkf.callAi('abc', { spec: { use: 'p-trunc' } })
338
+ let t3 = await wkf.callAi('abc', { spec: { use: 'p-trunc-text' }, rawText: true })
339
+ let t4 = await wkf.callAi('abc', { spec: { use: 'p-trunc' }, parse: parseSalvage, acceptTruncated: false })
340
+ let r = [
341
+ [t1.ok, t1.json, t1.truncated, t1.finishReason],
342
+ [t2.ok, t2.errorType, t2.truncated, t2.tried.map((x) => x.outcome)],
343
+ [t3.ok, t3.errorType],
344
+ [t4.ok, t4.errorType],
345
+ ]
346
+ let rr = [
347
+ [true, [{ a: 1 }, { b: 2 }], true, 'length'],
348
+ [false, 'incomplete', true, ['skip-group']],
349
+ [false, 'incomplete'],
350
+ [false, 'incomplete'],
351
+ ]
352
+ assert.strict.deepEqual(r, rr)
353
+ })
354
+
355
+ it('工作流: 條目自帶validate不再繞過工作流驗證(兩者取交集), 不過即遞補下一家', async function() {
356
+ let wkf = dispatchAiWkf({
357
+ providers: {
358
+ 'p-plain': { kind: 'api-openai-compat', baseURL: svr.url, model: 'plain-content', keys: ['sk-v1'], validate: 'nonempty' },
359
+ 'p-echo': { kind: 'api-openai-compat', baseURL: svr.url, model: 'echo', keys: ['sk-v2'] },
360
+ },
361
+ defaults: { promptPrefix: '' },
362
+ })
363
+ let t = await wkf.callAi('abc', { spec: { use: 'p-plain', fallback: ['p-echo'] } })
364
+ let r = [t.ok, t.providerId, t.tried.map((x) => [x.providerId, x.outcome, x.error || null])]
365
+ let rr = [true, 'p-echo', [['p-plain', 'skip-group', 'OUTPUT_VALIDATION_FAILED'], ['p-echo', 'ok', null]]]
366
+ assert.strict.deepEqual(r, rr)
367
+ })
368
+
369
+ it('工作流: check拋錯視同驗證失敗, callAi照常resolve不reject', async function() {
370
+ let wkf = dispatchAiWkf({
371
+ providers: { 'p-e1': { kind: 'api-openai-compat', baseURL: svr.url, model: 'echo', keys: ['sk-c1'] } },
372
+ defaults: { promptPrefix: '' },
373
+ })
374
+ let t = await wkf.callAi('abc', { spec: { use: 'p-e1' }, check: (j) => j.nope.deep === 1 })
375
+ let r = [t.ok, t.errorType, t.error]
376
+ let rr = [false, 'validation', 'OUTPUT_VALIDATION_FAILED']
209
377
  assert.strict.deepEqual(r, rr)
210
378
  })
211
379