w-dispatch-ai 1.0.36 → 1.0.38

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 (64) hide show
  1. package/README.md +26 -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/describeNonJsonBody.mjs.html +122 -0
  11. package/docs/dfTimeoutMs.mjs.html +2 -2
  12. package/docs/dispatchAi.mjs.html +2 -2
  13. package/docs/dispatchAiFallback.mjs.html +42 -8
  14. package/docs/dispatchAiWkf.mjs.html +2 -2
  15. package/docs/dispatchAntigravity.mjs.html +2 -2
  16. package/docs/dispatchApiOpenaiCompat.mjs.html +124 -31
  17. package/docs/dispatchApiOpenaiResponses.mjs.html +113 -27
  18. package/docs/dispatchApiTypesafeSystemone.mjs.html +42 -17
  19. package/docs/dispatchClaude.mjs.html +2 -2
  20. package/docs/dispatchCodex.mjs.html +2 -2
  21. package/docs/dispatchOpencode.mjs.html +2 -2
  22. package/docs/getCliArgs.mjs.html +2 -2
  23. package/docs/getErrorResult.mjs.html +2 -2
  24. package/docs/getErrorType.mjs.html +9 -5
  25. package/docs/global.html +3159 -1806
  26. package/docs/index.html +2 -2
  27. package/docs/quota_dfQuotaTimeoutMs.mjs.html +2 -2
  28. package/docs/quota_fetchQuotaJson.mjs.html +2 -2
  29. package/docs/quota_fromCodexUsageHttp.mjs.html +2 -2
  30. package/docs/quota_getQuotaAntigravity.mjs.html +2 -2
  31. package/docs/quota_getQuotaClaude.mjs.html +2 -2
  32. package/docs/quota_getQuotaCodex.mjs.html +2 -2
  33. package/docs/quota_readJsonOrNull.mjs.html +2 -2
  34. package/docs/quota_toQuotaLabel.mjs.html +2 -2
  35. package/docs/quota_toQuotaResult.mjs.html +2 -2
  36. package/docs/quota_toQuotaScopedLabel.mjs.html +2 -2
  37. package/docs/quota_toQuotaWindow.mjs.html +2 -2
  38. package/docs/readEnvFile.mjs.html +2 -2
  39. package/docs/resolveProviders.mjs.html +2 -2
  40. package/docs/wkf_callAiWithFallback.mjs.html +64 -20
  41. package/docs/wkf_createFileStore.mjs.html +2 -2
  42. package/docs/wkf_createUsageCounter.mjs.html +2 -2
  43. package/docs/wkf_extractJsonLoose.mjs.html +2 -2
  44. package/docs/wkf_noSideEffectPrefix.mjs.html +2 -2
  45. package/docs/wkf_runFanout.mjs.html +4 -2
  46. package/docs/wkf_runFanoutPipeline.mjs.html +2 -2
  47. package/docs/wkf_runRolePipeline.mjs.html +2 -2
  48. package/docs/wkf_salvageTruncatedArray.mjs.html +13 -2
  49. package/package.json +1 -1
  50. package/src/checkTruncation.mjs +127 -0
  51. package/src/describeNonJsonBody.mjs +50 -0
  52. package/src/dispatchAiFallback.mjs +40 -6
  53. package/src/dispatchApiOpenaiCompat.mjs +122 -29
  54. package/src/dispatchApiOpenaiResponses.mjs +111 -25
  55. package/src/dispatchApiTypesafeSystemone.mjs +40 -15
  56. package/src/getErrorType.mjs +7 -3
  57. package/src/providers.mjs +1 -1
  58. package/src/wkf/callAiWithFallback.mjs +62 -18
  59. package/src/wkf/runFanout.mjs +2 -0
  60. package/src/wkf/salvageTruncatedArray.mjs +11 -0
  61. package/test/tools/fakeServerForApiTest.mjs +87 -2
  62. package/test/unit-dispatchApiOpenaiCompat.test.mjs +207 -1
  63. package/test/unit-dispatchApiOpenaiResponses.test.mjs +74 -5
  64. package/test/unit-dispatchApiTypesafeSystemone.test.mjs +25 -1
@@ -29,7 +29,7 @@
29
29
  <nav >
30
30
 
31
31
 
32
- <h2><a href="index.html">Home</a></h2><h3>Global</h3><ul><li><a href="global.html#NO_SIDE_EFFECT">NO_SIDE_EFFECT</a></li><li><a href="global.html#WDispatchAi">WDispatchAi</a></li><li><a href="global.html#adapters">adapters</a></li><li><a href="global.html#agyPrint">agyPrint</a></li><li><a href="global.html#attachErrorType">attachErrorType</a></li><li><a href="global.html#budgetFor">budgetFor</a></li><li><a href="global.html#buildChain">buildChain</a></li><li><a href="global.html#buildValidator">buildValidator</a></li><li><a href="global.html#callAiWithFallback">callAiWithFallback</a></li><li><a href="global.html#callOnce">callOnce</a></li><li><a href="global.html#castPintOr">castPintOr</a></li><li><a href="global.html#collectAdditional">collectAdditional</a></li><li><a href="global.html#createFileStore">createFileStore</a></li><li><a href="global.html#createUsageCounter">createUsageCounter</a></li><li><a href="global.html#decodeJwtPayload">decodeJwtPayload</a></li><li><a href="global.html#defaultIntegratePrompt">defaultIntegratePrompt</a></li><li><a href="global.html#dfQuotaTimeoutMs">dfQuotaTimeoutMs</a></li><li><a href="global.html#dfTimeoutMs">dfTimeoutMs</a></li><li><a href="global.html#directWindow">directWindow</a></li><li><a href="global.html#dispatchAi">dispatchAi</a></li><li><a href="global.html#dispatchAiFallback">dispatchAiFallback</a></li><li><a href="global.html#dispatchAiWkf">dispatchAiWkf</a></li><li><a href="global.html#dispatchAntigravity">dispatchAntigravity</a></li><li><a href="global.html#dispatchApiOpenaiCompat">dispatchApiOpenaiCompat</a></li><li><a href="global.html#dispatchApiOpenaiResponses">dispatchApiOpenaiResponses</a></li><li><a href="global.html#dispatchApiTypesafeSystemone">dispatchApiTypesafeSystemone</a></li><li><a href="global.html#dispatchClaude">dispatchClaude</a></li><li><a href="global.html#dispatchCodex">dispatchCodex</a></li><li><a href="global.html#dispatchOpencode">dispatchOpencode</a></li><li><a href="global.html#extractJsonLoose">extractJsonLoose</a></li><li><a href="global.html#extractOutputText">extractOutputText</a></li><li><a href="global.html#fetchQuotaJson">fetchQuotaJson</a></li><li><a href="global.html#firstStr">firstStr</a></li><li><a href="global.html#fromBucket">fromBucket</a></li><li><a href="global.html#fromCodexUsageHttp">fromCodexUsageHttp</a></li><li><a href="global.html#fromLimitItem">fromLimitItem</a></li><li><a href="global.html#fromRpcWindow">fromRpcWindow</a></li><li><a href="global.html#fromTopField">fromTopField</a></li><li><a href="global.html#fromWindow">fromWindow</a></li><li><a href="global.html#getCliArgs">getCliArgs</a></li><li><a href="global.html#getErrorResult">getErrorResult</a></li><li><a href="global.html#getErrorType">getErrorType</a></li><li><a href="global.html#getQuotaAntigravity">getQuotaAntigravity</a></li><li><a href="global.html#getQuotaClaude">getQuotaClaude</a></li><li><a href="global.html#getQuotaCodex">getQuotaCodex</a></li><li><a href="global.html#initState">initState</a></li><li><a href="global.html#isKeyIndependentFail">isKeyIndependentFail</a></li><li><a href="global.html#pickWindow">pickWindow</a></li><li><a href="global.html#readBodyCapped">readBodyCapped</a></li><li><a href="global.html#readEnvFile">readEnvFile</a></li><li><a href="global.html#readJsonOrNull">readJsonOrNull</a></li><li><a href="global.html#redactText">redactText</a></li><li><a href="global.html#reorderByCooling">reorderByCooling</a></li><li><a href="global.html#resolveProviders">resolveProviders</a></li><li><a href="global.html#runFanout">runFanout</a></li><li><a href="global.html#runFanoutPipeline">runFanoutPipeline</a></li><li><a href="global.html#runRolePipeline">runRolePipeline</a></li><li><a href="global.html#salvageTruncatedArray">salvageTruncatedArray</a></li><li><a href="global.html#toQuotaLabel">toQuotaLabel</a></li><li><a href="global.html#toQuotaResult">toQuotaResult</a></li><li><a href="global.html#toQuotaScopedLabel">toQuotaScopedLabel</a></li><li><a href="global.html#toQuotaWindow">toQuotaWindow</a></li><li><a href="global.html#viaAppServer">viaAppServer</a></li><li><a href="global.html#viaHttp">viaHttp</a></li><li><a href="global.html#windowToSeconds">windowToSeconds</a></li></ul>
32
+ <h2><a href="index.html">Home</a></h2><h3>Global</h3><ul><li><a href="global.html#NO_SIDE_EFFECT">NO_SIDE_EFFECT</a></li><li><a href="global.html#WDispatchAi">WDispatchAi</a></li><li><a href="global.html#adapters">adapters</a></li><li><a href="global.html#agyPrint">agyPrint</a></li><li><a href="global.html#attachErrorType">attachErrorType</a></li><li><a href="global.html#budgetFor">budgetFor</a></li><li><a href="global.html#buildChain">buildChain</a></li><li><a href="global.html#buildValidator">buildValidator</a></li><li><a href="global.html#callAiWithFallback">callAiWithFallback</a></li><li><a href="global.html#callOnce">callOnce</a></li><li><a href="global.html#castPintOr">castPintOr</a></li><li><a href="global.html#collectAdditional">collectAdditional</a></li><li><a href="global.html#createFileStore">createFileStore</a></li><li><a href="global.html#createUsageCounter">createUsageCounter</a></li><li><a href="global.html#decodeJwtPayload">decodeJwtPayload</a></li><li><a href="global.html#defaultIntegratePrompt">defaultIntegratePrompt</a></li><li><a href="global.html#describeNonJsonBody">describeNonJsonBody</a></li><li><a href="global.html#dfQuotaTimeoutMs">dfQuotaTimeoutMs</a></li><li><a href="global.html#dfTimeoutMs">dfTimeoutMs</a></li><li><a href="global.html#directWindow">directWindow</a></li><li><a href="global.html#dispatchAi">dispatchAi</a></li><li><a href="global.html#dispatchAiFallback">dispatchAiFallback</a></li><li><a href="global.html#dispatchAiWkf">dispatchAiWkf</a></li><li><a href="global.html#dispatchAntigravity">dispatchAntigravity</a></li><li><a href="global.html#dispatchApiOpenaiCompat">dispatchApiOpenaiCompat</a></li><li><a href="global.html#dispatchApiOpenaiResponses">dispatchApiOpenaiResponses</a></li><li><a href="global.html#dispatchApiTypesafeSystemone">dispatchApiTypesafeSystemone</a></li><li><a href="global.html#dispatchClaude">dispatchClaude</a></li><li><a href="global.html#dispatchCodex">dispatchCodex</a></li><li><a href="global.html#dispatchOpencode">dispatchOpencode</a></li><li><a href="global.html#extractJsonLoose">extractJsonLoose</a></li><li><a href="global.html#extractOutputText">extractOutputText</a></li><li><a href="global.html#fetchQuotaJson">fetchQuotaJson</a></li><li><a href="global.html#finOf">finOf</a></li><li><a href="global.html#firstStr">firstStr</a></li><li><a href="global.html#fromBucket">fromBucket</a></li><li><a href="global.html#fromCodexUsageHttp">fromCodexUsageHttp</a></li><li><a href="global.html#fromLimitItem">fromLimitItem</a></li><li><a href="global.html#fromRpcWindow">fromRpcWindow</a></li><li><a href="global.html#fromTopField">fromTopField</a></li><li><a href="global.html#fromWindow">fromWindow</a></li><li><a href="global.html#getCliArgs">getCliArgs</a></li><li><a href="global.html#getErrorResult">getErrorResult</a></li><li><a href="global.html#getErrorType">getErrorType</a></li><li><a href="global.html#getQuotaAntigravity">getQuotaAntigravity</a></li><li><a href="global.html#getQuotaClaude">getQuotaClaude</a></li><li><a href="global.html#getQuotaCodex">getQuotaCodex</a></li><li><a href="global.html#initState">initState</a></li><li><a href="global.html#isKeyIndependentFail">isKeyIndependentFail</a></li><li><a href="global.html#judgeTruncated">judgeTruncated</a></li><li><a href="global.html#normalizeFinishReason">normalizeFinishReason</a></li><li><a href="global.html#pickWindow">pickWindow</a></li><li><a href="global.html#readBodyCapped">readBodyCapped</a></li><li><a href="global.html#readEnvFile">readEnvFile</a></li><li><a href="global.html#readJsonOrNull">readJsonOrNull</a></li><li><a href="global.html#redactText">redactText</a></li><li><a href="global.html#reorderByCooling">reorderByCooling</a></li><li><a href="global.html#resolveProviders">resolveProviders</a></li><li><a href="global.html#runFanout">runFanout</a></li><li><a href="global.html#runFanoutPipeline">runFanoutPipeline</a></li><li><a href="global.html#runRolePipeline">runRolePipeline</a></li><li><a href="global.html#safeValidate">safeValidate</a></li><li><a href="global.html#salvageTruncatedArray">salvageTruncatedArray</a></li><li><a href="global.html#toQuotaLabel">toQuotaLabel</a></li><li><a href="global.html#toQuotaResult">toQuotaResult</a></li><li><a href="global.html#toQuotaScopedLabel">toQuotaScopedLabel</a></li><li><a href="global.html#toQuotaWindow">toQuotaWindow</a></li><li><a href="global.html#viaAppServer">viaAppServer</a></li><li><a href="global.html#viaHttp">viaHttp</a></li><li><a href="global.html#windowToSeconds">windowToSeconds</a></li></ul>
33
33
 
34
34
  </nav>
35
35
 
@@ -57,6 +57,8 @@ import buildValidator from './buildValidator.mjs'
57
57
  import strTruncate from 'wsemi/src/strTruncate.mjs'
58
58
  import getErrorResult from './getErrorResult.mjs'
59
59
  import dfTimeoutMs from './dfTimeoutMs.mjs'
60
+ import { TRUNCATION_REASONS, normalizeFinishReason, safeValidate, judgeTruncated } from './checkTruncation.mjs'
61
+ import describeNonJsonBody from './describeNonJsonBody.mjs'
60
62
 
61
63
 
62
64
  // dispatchApiOpenaiCompat.mjs — 以fetch直呼OpenAI相容API(chat/completions)
@@ -86,17 +88,39 @@ import dfTimeoutMs from './dfTimeoutMs.mjs'
86
88
  // 故呼叫端若於body帶入tools, 本函數一律以TOOL_CALLS_UNSUPPORTED回報失敗而不假裝成功
87
89
  // (實測Agnes於tool_calls時content為"\n\n"而非null, 不特別處理會靜默回傳空白內容)。
88
90
  //
89
- // 【重試語意對齊execCli】4xx(429除外)為客戶端錯誤不可重試而立即中止;
91
+ // 【預設帶Accept-Encoding: identity(2026-09-24起, 三個REST轉接器同步)】Node內建fetch(undici)只在回應
92
+ // 帶Content-Encoding時才自動解壓; 伺服器若壓縮了本體卻漏標此標頭, fetch原樣交出壓縮位元組,
93
+ // JSON.parse失敗而回INVALID_RESPONSE(使用端回報Zen之space-bunny-free即此症: 手動brotli解壓得完整答案,
94
+ // 改帶identity即得正常JSON)。本機以假伺服器重現該機制。重現條件(安裝方tai-kns-trade, 2026-09-24 11:5x實測):
95
+ // 長回應(chat/completions約35秒)時Node fetch所見回應標頭為0個、本體9,224 bytes為brotli位元組, 改帶identity
96
+ // 則本體為22,124 bytes之JSON; 同環境同時段他端點標頭正常(npm registry 13個、Zen /models 8個含content-encoding=br)
97
+ // 且未設代理。本機同日對Zen取樣7次(含65KB長回應)皆正確標示br、未重現——推測漏標為有條件出現(長回應),
98
+ // 條件未定。identity請伺服器勿壓縮, 從源頭消除「壓縮處理不一致」一類失敗(不論成因在伺服器、代理或執行環境),
99
+ // 代價僅傳輸量變大(實測Zen回應約5~8KB→20~65KB)。呼叫端可以opt.headers之'Accept-Encoding'覆寫。
100
+ //
101
+ // 【HTTP 200但本體非JSON另報(安裝方建議C2)】JSON.parse失敗時不再與「缺choices[0].message.content」同一句,
102
+ // 改回INVALID_RESPONSE: body is not JSON(附原始位元組數、前16位元組hex、content-encoding、content-type,
103
+ // 規則單一來源見describeNonJsonBody.mjs), 遞補層據此整組跳過; 「JSON缺content」維持換金鑰。
104
+ //
105
+ // 【截斷(finish_reason為length或content_filter)預設失敗(2026-09-24起, 規則單一來源見checkTruncation.mjs)】
106
+ // 舊版不看finish_reason, 實測Zen之space-bunny-free於max_tokens:600時推理即耗盡而回content:""、
107
+ // 本轉接器卻回ok:true(靜默成功)。現於null檢查與validate之前裁定: 預設回INCOMPLETE_RESPONSE
108
+ // (errorType incomplete, 結果帶truncated:true), 不論validate為何——「validate接受」不代表內容完整。
109
+ // 呼叫端明示acceptTruncated:true才放行length之截斷(交validate裁決, 通過者仍標truncated:true);
110
+ // content_filter與可見輸出為空者一律失敗。finish_reason為stop/null/未知值者照舊(不可誤殺)。
111
+ //
112
+ // 【重試語意對齊execCli】4xx(429除外)為客戶端錯誤不可重試而立即中止; 截斷亦不重試(同一請求必然再截斷);
90
113
  // 429/5xx/網路錯誤/逾時依maxRetries線性退避重試(間隔retryDelayMs*次數, 上限15000ms)。
91
114
  //
92
115
  // 【結果結構對齊execCli】{ ok, stdout, stderr, code, error, durationMs, attempts, usage },
93
116
  // usage為原始回應之token用量原樣透傳(無則null; 驗證失敗等已耗token之失敗亦帶出),
94
117
  // CLI類轉接器無可靠來源故無此欄——呼叫端可據此把「真實用量」與「只能估算」分開處理。
118
+ // 取得並解析回應後之結果另帶finishReason(正規化終止原因, 缺值為'')與truncated(是否截斷)。
95
119
  // 失敗結果另帶機器可讀之errorType(timeout/fetch/http/tool-unsupported/invalid-response/
96
- // validation/params, 一覽見getErrorType.mjs檔頭), error字串保留不動, 兩者並存。
120
+ // incomplete/validation/params, 一覽見getErrorType.mjs檔頭), error字串保留不動, 兩者並存。
97
121
  // stdout為回覆內容、code為HTTP狀態碼(網路錯誤與逾時為null)、逾時error以TIMEOUT開頭、
98
- // 驗證失敗error為OUTPUT_VALIDATION_FAILED——故dispatchAiFallback之失敗分流
99
- // (TIMEOUT/驗證失敗跳組, 其餘換金鑰)對本轉接器同樣成立, 無須任何修改。
122
+ // 驗證失敗error為OUTPUT_VALIDATION_FAILED(validate拋錯亦同, 拋錯訊息置stderr)——
123
+ // dispatchAiFallback據此分流: 逾時/驗證失敗/截斷/工具不支援/本體非JSON整組跳過, 其餘換金鑰。
100
124
 
101
125
 
102
126
  //預設值
@@ -119,9 +143,10 @@ let optTruncate = {
119
143
  * @param {Object} body 輸入請求本體物件
120
144
  * @param {Number} timeoutMs 輸入逾時毫秒
121
145
  * @param {Function|null} validator 輸入驗證函式
146
+ * @param {Boolean} acceptTruncated 輸入是否明示接受截斷內容
122
147
  * @returns {Promise} 回傳Promise,resolve回傳結果物件
123
148
  */
124
- async function callOnce(url, headers, body, timeoutMs, validator) {
149
+ async function callOnce(url, headers, body, timeoutMs, validator, acceptTruncated) {
125
150
 
126
151
  let t0 = Date.now()
127
152
 
@@ -144,7 +169,9 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
144
169
  controller.abort()
145
170
  }, timeoutMs)
146
171
 
172
+ //本體先取原始位元組再以UTF-8解碼(等同res.text()), 本體非JSON時才有原始位元組可供診斷(見describeNonJsonBody.mjs)
147
173
  let res = null
174
+ let raw = new Uint8Array(0)
148
175
  let txt = ''
149
176
  try {
150
177
  res = await fetch(url, {
@@ -153,7 +180,8 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
153
180
  body: JSON.stringify(body),
154
181
  signal: controller.signal,
155
182
  })
156
- txt = await res.text()
183
+ raw = new Uint8Array(await res.arrayBuffer())
184
+ txt = new TextDecoder('utf-8').decode(raw)
157
185
  }
158
186
  catch (err) {
159
187
  clearTimeout(timer)
@@ -181,20 +209,39 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
181
209
 
182
210
  //取出choices[0]與usage(token用量, 原樣透傳; 失敗回應亦可能已耗token, 一併帶出)
183
211
  let content = null
184
- let finishReason = ''
212
+ let finishReasonRaw = null
185
213
  let toolCalls = null
186
214
  let usage = null
215
+ let parsed = true
187
216
  try {
188
217
  let j = JSON.parse(txt)
189
218
  content = get(j, 'choices.0.message.content', null)
190
- finishReason = get(j, 'choices.0.finish_reason', '')
219
+ finishReasonRaw = get(j, 'choices.0.finish_reason', null)
191
220
  toolCalls = get(j, 'choices.0.message.tool_calls', null)
192
221
  usage = get(j, 'usage', null)
193
222
  if (!isobj(usage)) {
194
223
  usage = null
195
224
  }
196
225
  }
197
- catch {}
226
+ catch {
227
+ parsed = false
228
+ }
229
+
230
+ //本體非JSON, 與「JSON缺content」分開回報並附原始位元組資訊; 遞補層據前綴整組跳過(見describeNonJsonBody.mjs)
231
+ if (!parsed) {
232
+ return mkResult({
233
+ stderr: strTruncate(txt, 500, optTruncate),
234
+ code: res.status,
235
+ error: describeNonJsonBody(raw, res.headers),
236
+ errorType: 'invalid-response',
237
+ finishReason: '',
238
+ truncated: false,
239
+ })
240
+ }
241
+
242
+ //fin, 自此起之每個結果皆外顯正規化終止原因與是否截斷(規則見checkTruncation.mjs)
243
+ let finishReason = normalizeFinishReason(finishReasonRaw)
244
+ let fin = { finishReason, truncated: TRUNCATION_REASONS.includes(finishReason) }
198
245
 
199
246
  //tool_calls, 本轉接器不支援工具迴圈(見檔頭), 明確回報而不假裝成功
200
247
  //(Agnes於tool_calls時content為"\n\n"非null, 不攔截會靜默回傳空白內容)
@@ -205,31 +252,67 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
205
252
  error: 'TOOL_CALLS_UNSUPPORTED: use a cli kind (opencode/claude/codex/antigravity) when tools are needed',
206
253
  errorType: 'tool-unsupported',
207
254
  usage,
255
+ ...fin,
208
256
  })
209
257
  }
210
258
 
211
- if (content === null || content === undefined) {
259
+ //content字串化(少數閘道回array形態); null/undefined一律視為無內容
260
+ if (content === undefined) {
261
+ content = null
262
+ }
263
+ if (content !== null &amp;&amp; typeof content !== 'string') {
264
+ content = JSON.stringify(content)
265
+ }
266
+
267
+ //截斷, 於null檢查與validate之前裁定: 預設失敗, 明示acceptTruncated才交validate放行(見檔頭)
268
+ if (fin.truncated) {
269
+ let d = judgeTruncated({
270
+ finishReason,
271
+ content,
272
+ acceptTruncated,
273
+ validator,
274
+ reasoningTokens: get(usage, 'completion_tokens_details.reasoning_tokens', null),
275
+ label: `finish_reason=${finishReason}`,
276
+ })
277
+ if (!d.accept) {
278
+ return mkResult({
279
+ stdout: strTruncate(content || '', 500, optTruncate),
280
+ stderr: strTruncate((d.threw ? `validate threw: ${d.threw}\n` : '') + txt, 500, optTruncate),
281
+ code: res.status,
282
+ error: d.error,
283
+ errorType: 'incomplete',
284
+ usage,
285
+ ...fin,
286
+ })
287
+ }
288
+ return mkResult({ ok: true, stdout: content, code: res.status, usage, ...fin })
289
+ }
290
+
291
+ if (content === null) {
212
292
  return mkResult({
213
293
  stderr: strTruncate(txt, 500, optTruncate),
214
294
  code: res.status,
215
295
  error: 'INVALID_RESPONSE: missing choices[0].message.content',
216
296
  errorType: 'invalid-response',
217
297
  usage,
298
+ ...fin,
218
299
  })
219
300
  }
220
- if (typeof content !== 'string') {
221
- content = JSON.stringify(content) //少數閘道回array形態
222
- }
223
301
 
224
- //validator, error與execCli一致令dispatchAiFallback可統一分流
225
- if (validator &amp;&amp; !validator(content)) {
226
- return mkResult({
227
- stdout: strTruncate(content, 500, optTruncate),
228
- code: res.status,
229
- error: 'OUTPUT_VALIDATION_FAILED',
230
- errorType: 'validation',
231
- usage,
232
- })
302
+ //validator, error與execCli一致令dispatchAiFallback可統一分流; 拋錯視同拒絕(不reject), 訊息置stderr
303
+ if (validator) {
304
+ let v = safeValidate(validator, content)
305
+ if (!v.pass) {
306
+ return mkResult({
307
+ stdout: strTruncate(content, 500, optTruncate),
308
+ stderr: v.threw ? `validate threw: ${v.threw}` : '',
309
+ code: res.status,
310
+ error: 'OUTPUT_VALIDATION_FAILED',
311
+ errorType: 'validation',
312
+ usage,
313
+ ...fin,
314
+ })
315
+ }
233
316
  }
234
317
 
235
318
  return mkResult({
@@ -237,6 +320,7 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
237
320
  stdout: content,
238
321
  code: res.status,
239
322
  usage,
323
+ ...fin,
240
324
  })
241
325
  }
242
326
 
@@ -263,12 +347,13 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
263
347
  * @param {String} [opt.key=''] 輸入API key字串,以Bearer置於Authorization標頭,預設''代表不帶認證標頭
264
348
  * @param {String} [opt.system=''] 輸入system提示詞字串,將以system角色置於messages首位,預設''代表不帶
265
349
  * @param {Object} [opt.body={}] 輸入額外請求本體物件(如temperature、max_tokens、response_format),將併入預設body(同名鍵以此為準),預設{}。注意本轉接器不支援工具,帶入tools而模型回tool_calls時一律以TOOL_CALLS_UNSUPPORTED回報失敗,需要工具請改用CLI類kind
266
- * @param {Object} [opt.headers={}] 輸入額外請求標頭物件,預設{}
350
+ * @param {Object} [opt.headers={}] 輸入額外請求標頭物件,同名鍵覆寫預設標頭;預設標頭含'Accept-Encoding: identity'(防伺服器壓縮卻漏標Content-Encoding,見檔頭),要改回允許壓縮可給{'Accept-Encoding':'gzip, deflate, br'},預設{}
267
351
  * @param {Number} [opt.timeoutMs=300000] 輸入逾時毫秒正整數,逾時將中止請求(含回應串流讀取),全套件統一預設300000
268
- * @param {String|Function} [opt.validate=undefined] 輸入回覆內容驗證規則字串或自訂驗證函數,規則字串支援'nonempty'、'json'、'min:100',多規則可用逗號串接,預設undefined代表不驗證
269
- * @param {Number} [opt.maxRetries=0] 輸入失敗後最大重試次數非負整數,4xx(429除外)不重試,預設0
352
+ * @param {String|Function} [opt.validate=undefined] 輸入回覆內容驗證規則字串或自訂驗證函數,規則字串支援'nonempty'、'json'、'min:100',多規則可用逗號串接,自訂函數拋錯視同驗證失敗,預設undefined代表不驗證
353
+ * @param {Boolean} [opt.acceptTruncated=false] 輸入是否接受被截斷(finish_reason為length)之內容布林值,true代表交validate裁決(無validate則直接接受)且結果標truncated:true;content_filter與可見輸出為空者一律失敗,預設false代表截斷一律失敗(errorType incomplete)
354
+ * @param {Number} [opt.maxRetries=0] 輸入失敗後最大重試次數非負整數,4xx(429除外)與截斷不重試,預設0
270
355
  * @param {Number} [opt.retryDelayMs=5000] 輸入重試間隔毫秒正整數,實際間隔為retryDelayMs乘以重試次數且上限15000ms,預設5000
271
- * @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否成功布林值)、stdout(回覆內容字串)、stderr(失敗時之原始回應本體)、code(HTTP狀態碼,網路錯誤與逾時為null)、error(錯誤訊息字串,成功時為空字串)、errorType(僅失敗時,機器可讀錯誤類別字串,一覽見getErrorType.mjs檔頭)、durationMs(耗時毫秒)、attempts(實際嘗試次數)、usage(原始回應之token用量物件原樣透傳,無則null),本函數不會reject
356
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否成功布林值)、stdout(回覆內容字串)、stderr(失敗時之原始回應本體)、code(HTTP狀態碼,網路錯誤與逾時為null)、error(錯誤訊息字串,成功時為空字串)、errorType(僅失敗時,機器可讀錯誤類別字串,一覽見getErrorType.mjs檔頭)、durationMs(耗時毫秒)、attempts(實際嘗試次數)、usage(原始回應之token用量物件原樣透傳,無則null)、finishReason(已取得回應時之正規化終止原因字串,缺值為'')、truncated(已取得回應時是否被截斷布林值),本函數不會reject
272
357
  * @example
273
358
  * //need network, no cli required
274
359
  *
@@ -362,6 +447,9 @@ async function dispatchApiOpenaiCompat(prompt, opt = {}) {
362
447
  //validator
363
448
  let validator = buildValidator(get(opt, 'validate', null))
364
449
 
450
+ //acceptTruncated, 僅明示true才放行截斷內容(見檔頭)
451
+ let acceptTruncated = get(opt, 'acceptTruncated', null) === true
452
+
365
453
  //url, baseURL尾端斜線正規化後接上端點
366
454
  let url = baseURL.replace(/\/+$/, '') + '/chat/completions'
367
455
 
@@ -375,8 +463,8 @@ async function dispatchApiOpenaiCompat(prompt, opt = {}) {
375
463
  //body, 額外鍵以bodyExtra為準(可覆寫temperature等, 覆寫messages屬進階用法)
376
464
  let body = { model, messages, ...bodyExtra }
377
465
 
378
- //headers
379
- let headers = { 'Content-Type': 'application/json', ...headersExtra }
466
+ //headers, Accept-Encoding預設identity(防伺服器壓縮卻漏標Content-Encoding, 見檔頭), headersExtra同名鍵可覆寫
467
+ let headers = { 'Content-Type': 'application/json', 'Accept-Encoding': 'identity', ...headersExtra }
380
468
  if (isestr(key)) {
381
469
  headers['Authorization'] = `Bearer ${key}`
382
470
  }
@@ -391,7 +479,7 @@ async function dispatchApiOpenaiCompat(prompt, opt = {}) {
391
479
  await delay(Math.min(retryDelayMs * attempt, MAX_RETRY_DELAY_MS))
392
480
  }
393
481
 
394
- lastResult = await callOnce(url, headers, body, timeoutMs, validator)
482
+ lastResult = await callOnce(url, headers, body, timeoutMs, validator, acceptTruncated)
395
483
  totalAttempts = attempt + 1
396
484
 
397
485
  if (lastResult.ok) {
@@ -399,6 +487,11 @@ async function dispatchApiOpenaiCompat(prompt, opt = {}) {
399
487
  return lastResult
400
488
  }
401
489
 
490
+ //不可重試: 截斷為決定性(同一請求必然再截斷, 重試只會再燒一次輸出上限)
491
+ if (lastResult.truncated === true) {
492
+ break
493
+ }
494
+
402
495
  //不可重試: 4xx(429除外)為客戶端錯誤, 重試無意義
403
496
  let c = lastResult.code
404
497
  if (isnum(c) &amp;&amp; c >= 400 &amp;&amp; c &lt; 500 &amp;&amp; c !== 429) {
@@ -428,7 +521,7 @@ export default dispatchApiOpenaiCompat
428
521
  <br class="clear">
429
522
 
430
523
  <footer>
431
- Documentation generated by <a href="https://github.com/jsdoc3/jsdoc">JSDoc 4.0.5</a> on Thu Sep 24 2026 08:47:53 GMT+0800 (台北標準時間) using the <a href="https://github.com/clenemt/docdash">docdash</a> theme.
524
+ 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.
432
525
  </footer>
433
526
 
434
527
  <script>prettyPrint();</script>
@@ -29,7 +29,7 @@
29
29
  <nav >
30
30
 
31
31
 
32
- <h2><a href="index.html">Home</a></h2><h3>Global</h3><ul><li><a href="global.html#NO_SIDE_EFFECT">NO_SIDE_EFFECT</a></li><li><a href="global.html#WDispatchAi">WDispatchAi</a></li><li><a href="global.html#adapters">adapters</a></li><li><a href="global.html#agyPrint">agyPrint</a></li><li><a href="global.html#attachErrorType">attachErrorType</a></li><li><a href="global.html#budgetFor">budgetFor</a></li><li><a href="global.html#buildChain">buildChain</a></li><li><a href="global.html#buildValidator">buildValidator</a></li><li><a href="global.html#callAiWithFallback">callAiWithFallback</a></li><li><a href="global.html#callOnce">callOnce</a></li><li><a href="global.html#castPintOr">castPintOr</a></li><li><a href="global.html#collectAdditional">collectAdditional</a></li><li><a href="global.html#createFileStore">createFileStore</a></li><li><a href="global.html#createUsageCounter">createUsageCounter</a></li><li><a href="global.html#decodeJwtPayload">decodeJwtPayload</a></li><li><a href="global.html#defaultIntegratePrompt">defaultIntegratePrompt</a></li><li><a href="global.html#dfQuotaTimeoutMs">dfQuotaTimeoutMs</a></li><li><a href="global.html#dfTimeoutMs">dfTimeoutMs</a></li><li><a href="global.html#directWindow">directWindow</a></li><li><a href="global.html#dispatchAi">dispatchAi</a></li><li><a href="global.html#dispatchAiFallback">dispatchAiFallback</a></li><li><a href="global.html#dispatchAiWkf">dispatchAiWkf</a></li><li><a href="global.html#dispatchAntigravity">dispatchAntigravity</a></li><li><a href="global.html#dispatchApiOpenaiCompat">dispatchApiOpenaiCompat</a></li><li><a href="global.html#dispatchApiOpenaiResponses">dispatchApiOpenaiResponses</a></li><li><a href="global.html#dispatchApiTypesafeSystemone">dispatchApiTypesafeSystemone</a></li><li><a href="global.html#dispatchClaude">dispatchClaude</a></li><li><a href="global.html#dispatchCodex">dispatchCodex</a></li><li><a href="global.html#dispatchOpencode">dispatchOpencode</a></li><li><a href="global.html#extractJsonLoose">extractJsonLoose</a></li><li><a href="global.html#extractOutputText">extractOutputText</a></li><li><a href="global.html#fetchQuotaJson">fetchQuotaJson</a></li><li><a href="global.html#firstStr">firstStr</a></li><li><a href="global.html#fromBucket">fromBucket</a></li><li><a href="global.html#fromCodexUsageHttp">fromCodexUsageHttp</a></li><li><a href="global.html#fromLimitItem">fromLimitItem</a></li><li><a href="global.html#fromRpcWindow">fromRpcWindow</a></li><li><a href="global.html#fromTopField">fromTopField</a></li><li><a href="global.html#fromWindow">fromWindow</a></li><li><a href="global.html#getCliArgs">getCliArgs</a></li><li><a href="global.html#getErrorResult">getErrorResult</a></li><li><a href="global.html#getErrorType">getErrorType</a></li><li><a href="global.html#getQuotaAntigravity">getQuotaAntigravity</a></li><li><a href="global.html#getQuotaClaude">getQuotaClaude</a></li><li><a href="global.html#getQuotaCodex">getQuotaCodex</a></li><li><a href="global.html#initState">initState</a></li><li><a href="global.html#isKeyIndependentFail">isKeyIndependentFail</a></li><li><a href="global.html#pickWindow">pickWindow</a></li><li><a href="global.html#readBodyCapped">readBodyCapped</a></li><li><a href="global.html#readEnvFile">readEnvFile</a></li><li><a href="global.html#readJsonOrNull">readJsonOrNull</a></li><li><a href="global.html#redactText">redactText</a></li><li><a href="global.html#reorderByCooling">reorderByCooling</a></li><li><a href="global.html#resolveProviders">resolveProviders</a></li><li><a href="global.html#runFanout">runFanout</a></li><li><a href="global.html#runFanoutPipeline">runFanoutPipeline</a></li><li><a href="global.html#runRolePipeline">runRolePipeline</a></li><li><a href="global.html#salvageTruncatedArray">salvageTruncatedArray</a></li><li><a href="global.html#toQuotaLabel">toQuotaLabel</a></li><li><a href="global.html#toQuotaResult">toQuotaResult</a></li><li><a href="global.html#toQuotaScopedLabel">toQuotaScopedLabel</a></li><li><a href="global.html#toQuotaWindow">toQuotaWindow</a></li><li><a href="global.html#viaAppServer">viaAppServer</a></li><li><a href="global.html#viaHttp">viaHttp</a></li><li><a href="global.html#windowToSeconds">windowToSeconds</a></li></ul>
32
+ <h2><a href="index.html">Home</a></h2><h3>Global</h3><ul><li><a href="global.html#NO_SIDE_EFFECT">NO_SIDE_EFFECT</a></li><li><a href="global.html#WDispatchAi">WDispatchAi</a></li><li><a href="global.html#adapters">adapters</a></li><li><a href="global.html#agyPrint">agyPrint</a></li><li><a href="global.html#attachErrorType">attachErrorType</a></li><li><a href="global.html#budgetFor">budgetFor</a></li><li><a href="global.html#buildChain">buildChain</a></li><li><a href="global.html#buildValidator">buildValidator</a></li><li><a href="global.html#callAiWithFallback">callAiWithFallback</a></li><li><a href="global.html#callOnce">callOnce</a></li><li><a href="global.html#castPintOr">castPintOr</a></li><li><a href="global.html#collectAdditional">collectAdditional</a></li><li><a href="global.html#createFileStore">createFileStore</a></li><li><a href="global.html#createUsageCounter">createUsageCounter</a></li><li><a href="global.html#decodeJwtPayload">decodeJwtPayload</a></li><li><a href="global.html#defaultIntegratePrompt">defaultIntegratePrompt</a></li><li><a href="global.html#describeNonJsonBody">describeNonJsonBody</a></li><li><a href="global.html#dfQuotaTimeoutMs">dfQuotaTimeoutMs</a></li><li><a href="global.html#dfTimeoutMs">dfTimeoutMs</a></li><li><a href="global.html#directWindow">directWindow</a></li><li><a href="global.html#dispatchAi">dispatchAi</a></li><li><a href="global.html#dispatchAiFallback">dispatchAiFallback</a></li><li><a href="global.html#dispatchAiWkf">dispatchAiWkf</a></li><li><a href="global.html#dispatchAntigravity">dispatchAntigravity</a></li><li><a href="global.html#dispatchApiOpenaiCompat">dispatchApiOpenaiCompat</a></li><li><a href="global.html#dispatchApiOpenaiResponses">dispatchApiOpenaiResponses</a></li><li><a href="global.html#dispatchApiTypesafeSystemone">dispatchApiTypesafeSystemone</a></li><li><a href="global.html#dispatchClaude">dispatchClaude</a></li><li><a href="global.html#dispatchCodex">dispatchCodex</a></li><li><a href="global.html#dispatchOpencode">dispatchOpencode</a></li><li><a href="global.html#extractJsonLoose">extractJsonLoose</a></li><li><a href="global.html#extractOutputText">extractOutputText</a></li><li><a href="global.html#fetchQuotaJson">fetchQuotaJson</a></li><li><a href="global.html#finOf">finOf</a></li><li><a href="global.html#firstStr">firstStr</a></li><li><a href="global.html#fromBucket">fromBucket</a></li><li><a href="global.html#fromCodexUsageHttp">fromCodexUsageHttp</a></li><li><a href="global.html#fromLimitItem">fromLimitItem</a></li><li><a href="global.html#fromRpcWindow">fromRpcWindow</a></li><li><a href="global.html#fromTopField">fromTopField</a></li><li><a href="global.html#fromWindow">fromWindow</a></li><li><a href="global.html#getCliArgs">getCliArgs</a></li><li><a href="global.html#getErrorResult">getErrorResult</a></li><li><a href="global.html#getErrorType">getErrorType</a></li><li><a href="global.html#getQuotaAntigravity">getQuotaAntigravity</a></li><li><a href="global.html#getQuotaClaude">getQuotaClaude</a></li><li><a href="global.html#getQuotaCodex">getQuotaCodex</a></li><li><a href="global.html#initState">initState</a></li><li><a href="global.html#isKeyIndependentFail">isKeyIndependentFail</a></li><li><a href="global.html#judgeTruncated">judgeTruncated</a></li><li><a href="global.html#normalizeFinishReason">normalizeFinishReason</a></li><li><a href="global.html#pickWindow">pickWindow</a></li><li><a href="global.html#readBodyCapped">readBodyCapped</a></li><li><a href="global.html#readEnvFile">readEnvFile</a></li><li><a href="global.html#readJsonOrNull">readJsonOrNull</a></li><li><a href="global.html#redactText">redactText</a></li><li><a href="global.html#reorderByCooling">reorderByCooling</a></li><li><a href="global.html#resolveProviders">resolveProviders</a></li><li><a href="global.html#runFanout">runFanout</a></li><li><a href="global.html#runFanoutPipeline">runFanoutPipeline</a></li><li><a href="global.html#runRolePipeline">runRolePipeline</a></li><li><a href="global.html#safeValidate">safeValidate</a></li><li><a href="global.html#salvageTruncatedArray">salvageTruncatedArray</a></li><li><a href="global.html#toQuotaLabel">toQuotaLabel</a></li><li><a href="global.html#toQuotaResult">toQuotaResult</a></li><li><a href="global.html#toQuotaScopedLabel">toQuotaScopedLabel</a></li><li><a href="global.html#toQuotaWindow">toQuotaWindow</a></li><li><a href="global.html#viaAppServer">viaAppServer</a></li><li><a href="global.html#viaHttp">viaHttp</a></li><li><a href="global.html#windowToSeconds">windowToSeconds</a></li></ul>
33
33
 
34
34
  </nav>
35
35
 
@@ -58,6 +58,8 @@ import buildValidator from './buildValidator.mjs'
58
58
  import strTruncate from 'wsemi/src/strTruncate.mjs'
59
59
  import getErrorResult from './getErrorResult.mjs'
60
60
  import dfTimeoutMs from './dfTimeoutMs.mjs'
61
+ import { normalizeFinishReason, safeValidate, judgeTruncated } from './checkTruncation.mjs'
62
+ import describeNonJsonBody from './describeNonJsonBody.mjs'
61
63
 
62
64
 
63
65
  // dispatchApiOpenaiResponses.mjs — 以fetch直呼OpenAI Responses API(/responses)
@@ -80,16 +82,25 @@ import dfTimeoutMs from './dfTimeoutMs.mjs'
80
82
  // (chat/completions為prompt_tokens/completion_tokens/total_tokens)。
81
83
  // 本套件usage一律原樣透傳不做正規化, 跨kind加總時呼叫端須自行對應欄位名。
82
84
  //
83
- // 【status不為completed一律視為失敗, 不回半截內容】incomplete(如max_output_tokens
85
+ // 【status不為completed預設失敗, 不回半截內容】incomplete(如max_output_tokens
84
86
  // 耗盡)之output常為空陣列或截斷內容, 當成功回傳會讓截斷結果流入下游而無人察覺;
85
87
  // 故以INCOMPLETE_RESPONSE回報(errorType為incomplete), 呼叫端據此調高max_output_tokens
86
88
  // 或換家。實測: max_output_tokens為16時status為incomplete、incomplete_details為
87
89
  // {reason:'max_output_tokens'}、output為空陣列。
90
+ // 2026-09-24起與dispatchApiOpenaiCompat同一截斷規則(單一來源checkTruncation.mjs): 僅status為incomplete
91
+ // 視為截斷(結果帶truncated:true, finishReason將max_output_tokens正規化為length), 呼叫端明示
92
+ // acceptTruncated:true才放行length之截斷(交validate裁決); failed/缺status/處理中等屬非截斷之失敗,
93
+ // truncated:false, 遞補層照舊換金鑰(服務回錯)。可見輸出為空之截斷訊息附reasoning_tokens。
88
94
  //
89
95
  // 【不支援工具, 與dispatchApiOpenaiCompat同一決策】output含function_call型元素時
90
96
  // 以TOOL_CALLS_UNSUPPORTED回報而不假裝成功; 理由(工具迴圈須自建harness、tool_call
91
97
  // 有會話束縛無法外傳上層agent)詳見dispatchApiOpenaiCompat.mjs與adapters.mjs檔頭。
92
98
  //
99
+ // 【預設帶Accept-Encoding: identity(2026-09-24起)】防伺服器壓縮卻漏標Content-Encoding而令fetch不解壓、
100
+ // JSON.parse失敗; 與dispatchApiOpenaiCompat同一決策與依據(見該檔檔頭), opt.headers可覆寫。
101
+ // HTTP 200但本體非JSON時另報INVALID_RESPONSE: body is not JSON(附原始位元組資訊), 與「缺output陣列」分開
102
+ // (同dispatchApiOpenaiCompat, 見describeNonJsonBody.mjs)。
103
+ //
93
104
  // 【錯誤碼實測(Zen)】壞金鑰401(AuthError); 未知model亦回401(ModelError: Model X is not
94
105
  // supported)而非404——故不可用狀態碼區分「金鑰錯」與「模型名錯」, 須讀stderr之訊息。
95
106
  //
@@ -162,9 +173,10 @@ function extractOutputText(output) {
162
173
  * @param {Object} body 輸入請求本體物件
163
174
  * @param {Number} timeoutMs 輸入逾時毫秒
164
175
  * @param {Function|null} validator 輸入驗證函式
176
+ * @param {Boolean} acceptTruncated 輸入是否明示接受截斷內容
165
177
  * @returns {Promise} 回傳Promise,resolve回傳結果物件
166
178
  */
167
- async function callOnce(url, headers, body, timeoutMs, validator) {
179
+ async function callOnce(url, headers, body, timeoutMs, validator, acceptTruncated) {
168
180
 
169
181
  let t0 = Date.now()
170
182
 
@@ -187,7 +199,9 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
187
199
  controller.abort()
188
200
  }, timeoutMs)
189
201
 
202
+ //本體先取原始位元組再以UTF-8解碼(等同res.text()), 本體非JSON時才有原始位元組可供診斷(見describeNonJsonBody.mjs)
190
203
  let res = null
204
+ let raw = new Uint8Array(0)
191
205
  let txt = ''
192
206
  try {
193
207
  res = await fetch(url, {
@@ -196,7 +210,8 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
196
210
  body: JSON.stringify(body),
197
211
  signal: controller.signal,
198
212
  })
199
- txt = await res.text()
213
+ raw = new Uint8Array(await res.arrayBuffer())
214
+ txt = new TextDecoder('utf-8').decode(raw)
200
215
  }
201
216
  catch (err) {
202
217
  clearTimeout(timer)
@@ -244,18 +259,36 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
244
259
  catch {
245
260
  parsed = false
246
261
  }
247
- if (!parsed || !isarr(output)) {
262
+ //本體非JSON, 與「JSON缺output」分開回報並附原始位元組資訊; 遞補層據前綴整組跳過(見describeNonJsonBody.mjs)
263
+ if (!parsed) {
248
264
  return mkResult({
249
265
  stderr: strTruncate(txt, 500, optTruncate),
250
266
  code: res.status,
251
- error: 'INVALID_RESPONSE: missing output array',
267
+ error: describeNonJsonBody(raw, res.headers),
252
268
  errorType: 'invalid-response',
253
269
  usage,
270
+ finishReason: '',
271
+ truncated: false,
254
272
  })
255
273
  }
256
274
 
275
+ //fin, 自此起之每個結果皆外顯正規化終止原因與是否截斷(與chat/completions對齊, 規則見checkTruncation.mjs):
276
+ //completed→stop; incomplete→依reason(max_output_tokens→length、content_filter原值、缺→incomplete); 其餘status原值; 缺status→''
277
+ let statusN = normalizeFinishReason(status)
278
+ let reasonN = normalizeFinishReason(incompleteReason)
279
+ let finishReason = statusN
280
+ if (statusN === 'completed') {
281
+ finishReason = 'stop'
282
+ }
283
+ else if (statusN === 'incomplete') {
284
+ finishReason = (reasonN === 'max_output_tokens') ? 'length' : (reasonN || 'incomplete')
285
+ }
286
+ let fin = { finishReason, truncated: statusN === 'incomplete' }
287
+ let detail = incompleteReason || failMsg || status || 'unknown'
288
+ let isArr = isarr(output)
289
+
257
290
  //function_call, 本轉接器不支援工具迴圈(見檔頭), 明確回報而不假裝成功
258
- let hasToolCall = output.some((o) => get(o, 'type', '') === 'function_call')
291
+ let hasToolCall = isArr &amp;&amp; output.some((o) => get(o, 'type', '') === 'function_call')
259
292
  if (hasToolCall) {
260
293
  return mkResult({
261
294
  stderr: strTruncate(txt, 1000, optTruncate),
@@ -263,12 +296,48 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
263
296
  error: 'TOOL_CALLS_UNSUPPORTED: use a cli kind (opencode/claude/codex/antigravity) when tools are needed',
264
297
  errorType: 'tool-unsupported',
265
298
  usage,
299
+ ...fin,
300
+ })
301
+ }
302
+
303
+ //截斷(status為incomplete), 於output檢查與validate之前裁定: 預設失敗, 明示acceptTruncated才交validate放行(見檔頭)
304
+ if (fin.truncated) {
305
+ let partial = isArr ? extractOutputText(output) : ''
306
+ let d = judgeTruncated({
307
+ finishReason,
308
+ content: partial,
309
+ acceptTruncated,
310
+ validator,
311
+ reasoningTokens: get(usage, 'output_tokens_details.reasoning_tokens', null),
312
+ label: `status=${status} (${detail})`,
266
313
  })
314
+ if (!d.accept) {
315
+ return mkResult({
316
+ stdout: strTruncate(partial, 500, optTruncate),
317
+ stderr: strTruncate((d.threw ? `validate threw: ${d.threw}\n` : '') + txt, 500, optTruncate),
318
+ code: res.status,
319
+ error: d.error,
320
+ errorType: 'incomplete',
321
+ usage,
322
+ ...fin,
323
+ })
324
+ }
325
+ return mkResult({ ok: true, stdout: partial, code: res.status, usage, ...fin })
267
326
  }
268
327
 
269
- //status非completed一律失敗: incomplete之內容為截斷品, 當成功回傳會讓半截結果流入下游
270
- if (status !== 'completed') {
271
- let detail = incompleteReason || failMsg || status || 'unknown'
328
+ if (!isArr) {
329
+ return mkResult({
330
+ stderr: strTruncate(txt, 500, optTruncate),
331
+ code: res.status,
332
+ error: 'INVALID_RESPONSE: missing output array',
333
+ errorType: 'invalid-response',
334
+ usage,
335
+ ...fin,
336
+ })
337
+ }
338
+
339
+ //其餘非completed(failed/缺status/處理中等)屬非截斷之失敗, 不回半截內容; truncated:false故遞補層照舊換金鑰
340
+ if (statusN !== 'completed') {
272
341
  return mkResult({
273
342
  stdout: strTruncate(extractOutputText(output), 500, optTruncate),
274
343
  stderr: strTruncate(txt, 500, optTruncate),
@@ -276,6 +345,7 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
276
345
  error: `INCOMPLETE_RESPONSE: status=${status || 'missing'} (${detail})`,
277
346
  errorType: 'incomplete',
278
347
  usage,
348
+ ...fin,
279
349
  })
280
350
  }
281
351
 
@@ -288,18 +358,24 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
288
358
  error: 'INVALID_RESPONSE: no output_text in output messages',
289
359
  errorType: 'invalid-response',
290
360
  usage,
361
+ ...fin,
291
362
  })
292
363
  }
293
364
 
294
- //validator, error與execCli一致令dispatchAiFallback可統一分流
295
- if (validator &amp;&amp; !validator(content)) {
296
- return mkResult({
297
- stdout: strTruncate(content, 500, optTruncate),
298
- code: res.status,
299
- error: 'OUTPUT_VALIDATION_FAILED',
300
- errorType: 'validation',
301
- usage,
302
- })
365
+ //validator, error與execCli一致令dispatchAiFallback可統一分流; 拋錯視同拒絕(不reject), 訊息置stderr
366
+ if (validator) {
367
+ let v = safeValidate(validator, content)
368
+ if (!v.pass) {
369
+ return mkResult({
370
+ stdout: strTruncate(content, 500, optTruncate),
371
+ stderr: v.threw ? `validate threw: ${v.threw}` : '',
372
+ code: res.status,
373
+ error: 'OUTPUT_VALIDATION_FAILED',
374
+ errorType: 'validation',
375
+ usage,
376
+ ...fin,
377
+ })
378
+ }
303
379
  }
304
380
 
305
381
  return mkResult({
@@ -307,6 +383,7 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
307
383
  stdout: content,
308
384
  code: res.status,
309
385
  usage,
386
+ ...fin,
310
387
  })
311
388
  }
312
389
 
@@ -333,12 +410,13 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
333
410
  * @param {String} [opt.key=''] 輸入API key字串,以Bearer置於Authorization標頭,預設''代表不帶認證標頭
334
411
  * @param {String} [opt.system=''] 輸入system提示詞字串,將置於instructions欄位(Responses API之system管道),預設''代表不帶
335
412
  * @param {Object} [opt.body={}] 輸入額外請求本體物件(如temperature、max_output_tokens、reasoning),將併入預設body(同名鍵以此為準),預設{}。注意輸出上限欄位名為max_output_tokens而非max_tokens;本轉接器不支援工具,帶入tools而模型回function_call時一律以TOOL_CALLS_UNSUPPORTED回報失敗
336
- * @param {Object} [opt.headers={}] 輸入額外請求標頭物件,預設{}
413
+ * @param {Object} [opt.headers={}] 輸入額外請求標頭物件,同名鍵覆寫預設標頭;預設標頭含'Accept-Encoding: identity'(防伺服器壓縮卻漏標Content-Encoding,見檔頭),預設{}
337
414
  * @param {Number} [opt.timeoutMs=300000] 輸入逾時毫秒正整數,逾時將中止請求(含回應串流讀取),全套件統一預設300000
338
- * @param {String|Function} [opt.validate=undefined] 輸入回覆內容驗證規則字串或自訂驗證函數,規則字串支援'nonempty'、'json'、'min:100',多規則可用逗號串接,預設undefined代表不驗證
339
- * @param {Number} [opt.maxRetries=0] 輸入失敗後最大重試次數非負整數,4xx(429除外)不重試,預設0
415
+ * @param {String|Function} [opt.validate=undefined] 輸入回覆內容驗證規則字串或自訂驗證函數,規則字串支援'nonempty'、'json'、'min:100',多規則可用逗號串接,自訂函數拋錯視同驗證失敗,預設undefined代表不驗證
416
+ * @param {Boolean} [opt.acceptTruncated=false] 輸入是否接受被截斷(status為incomplete且reason為max_output_tokens)之內容布林值,true代表交validate裁決(無validate則直接接受)且結果標truncated:true;content_filter與可見輸出為空者一律失敗,預設false代表截斷一律失敗
417
+ * @param {Number} [opt.maxRetries=0] 輸入失敗後最大重試次數非負整數,4xx(429除外)與截斷不重試,預設0
340
418
  * @param {Number} [opt.retryDelayMs=5000] 輸入重試間隔毫秒正整數,實際間隔為retryDelayMs乘以重試次數且上限15000ms,預設5000
341
- * @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否成功布林值)、stdout(回覆內容字串)、stderr(失敗時之原始回應本體)、code(HTTP狀態碼,網路錯誤與逾時為null)、error(錯誤訊息字串,成功時為空字串)、errorType(僅失敗時,機器可讀錯誤類別字串,一覽見getErrorType.mjs檔頭)、durationMs(耗時毫秒)、attempts(實際嘗試次數)、usage(原始回應之token用量物件原樣透傳,欄位名為input_tokens/output_tokens/total_tokens,無則null),本函數不會reject
419
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否成功布林值)、stdout(回覆內容字串)、stderr(失敗時之原始回應本體)、code(HTTP狀態碼,網路錯誤與逾時為null)、error(錯誤訊息字串,成功時為空字串)、errorType(僅失敗時,機器可讀錯誤類別字串,一覽見getErrorType.mjs檔頭)、durationMs(耗時毫秒)、attempts(實際嘗試次數)、usage(原始回應之token用量物件原樣透傳,欄位名為input_tokens/output_tokens/total_tokens,無則null)、finishReason(已取得回應時之正規化終止原因字串,completed為'stop'、max_output_tokens為'length')、truncated(已取得回應時是否被截斷布林值,僅status為incomplete時為true),本函數不會reject
342
420
  * @example
343
421
  * //need network, no cli required
344
422
  *
@@ -421,6 +499,9 @@ async function dispatchApiOpenaiResponses(prompt, opt = {}) {
421
499
  //validator
422
500
  let validator = buildValidator(get(opt, 'validate', null))
423
501
 
502
+ //acceptTruncated, 僅明示true才放行截斷內容(見檔頭)
503
+ let acceptTruncated = get(opt, 'acceptTruncated', null) === true
504
+
424
505
  //url, baseURL尾端斜線正規化後接上端點
425
506
  let url = baseURL.replace(/\/+$/, '') + '/responses'
426
507
 
@@ -431,8 +512,8 @@ async function dispatchApiOpenaiResponses(prompt, opt = {}) {
431
512
  }
432
513
  body = { ...body, ...bodyExtra }
433
514
 
434
- //headers
435
- let headers = { 'Content-Type': 'application/json', ...headersExtra }
515
+ //headers, Accept-Encoding預設identity(防伺服器壓縮卻漏標Content-Encoding, 見檔頭), headersExtra同名鍵可覆寫
516
+ let headers = { 'Content-Type': 'application/json', 'Accept-Encoding': 'identity', ...headersExtra }
436
517
  if (isestr(key)) {
437
518
  headers['Authorization'] = `Bearer ${key}`
438
519
  }
@@ -447,7 +528,7 @@ async function dispatchApiOpenaiResponses(prompt, opt = {}) {
447
528
  await delay(Math.min(retryDelayMs * attempt, MAX_RETRY_DELAY_MS))
448
529
  }
449
530
 
450
- lastResult = await callOnce(url, headers, body, timeoutMs, validator)
531
+ lastResult = await callOnce(url, headers, body, timeoutMs, validator, acceptTruncated)
451
532
  totalAttempts = attempt + 1
452
533
 
453
534
  if (lastResult.ok) {
@@ -455,6 +536,11 @@ async function dispatchApiOpenaiResponses(prompt, opt = {}) {
455
536
  return lastResult
456
537
  }
457
538
 
539
+ //不可重試: 截斷為決定性(同一請求必然再截斷, 重試只會再燒一次輸出上限)
540
+ if (lastResult.truncated === true) {
541
+ break
542
+ }
543
+
458
544
  //不可重試: 4xx(429除外)為客戶端錯誤, 重試無意義
459
545
  let c = lastResult.code
460
546
  if (isnum(c) &amp;&amp; c >= 400 &amp;&amp; c &lt; 500 &amp;&amp; c !== 429) {
@@ -485,7 +571,7 @@ export { extractOutputText }
485
571
  <br class="clear">
486
572
 
487
573
  <footer>
488
- Documentation generated by <a href="https://github.com/jsdoc3/jsdoc">JSDoc 4.0.5</a> on Thu Sep 24 2026 08:47:53 GMT+0800 (台北標準時間) using the <a href="https://github.com/clenemt/docdash">docdash</a> theme.
574
+ 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.
489
575
  </footer>
490
576
 
491
577
  <script>prettyPrint();</script>