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
@@ -10,6 +10,8 @@ import buildValidator from './buildValidator.mjs'
10
10
  import strTruncate from 'wsemi/src/strTruncate.mjs'
11
11
  import getErrorResult from './getErrorResult.mjs'
12
12
  import dfTimeoutMs from './dfTimeoutMs.mjs'
13
+ import { TRUNCATION_REASONS, normalizeFinishReason, safeValidate, judgeTruncated } from './checkTruncation.mjs'
14
+ import describeNonJsonBody from './describeNonJsonBody.mjs'
13
15
 
14
16
 
15
17
  // dispatchApiOpenaiCompat.mjs — 以fetch直呼OpenAI相容API(chat/completions)
@@ -39,17 +41,39 @@ import dfTimeoutMs from './dfTimeoutMs.mjs'
39
41
  // 故呼叫端若於body帶入tools, 本函數一律以TOOL_CALLS_UNSUPPORTED回報失敗而不假裝成功
40
42
  // (實測Agnes於tool_calls時content為"\n\n"而非null, 不特別處理會靜默回傳空白內容)。
41
43
  //
42
- // 【重試語意對齊execCli】4xx(429除外)為客戶端錯誤不可重試而立即中止;
44
+ // 【預設帶Accept-Encoding: identity(2026-09-24起, 三個REST轉接器同步)】Node內建fetch(undici)只在回應
45
+ // 帶Content-Encoding時才自動解壓; 伺服器若壓縮了本體卻漏標此標頭, fetch原樣交出壓縮位元組,
46
+ // JSON.parse失敗而回INVALID_RESPONSE(使用端回報Zen之space-bunny-free即此症: 手動brotli解壓得完整答案,
47
+ // 改帶identity即得正常JSON)。本機以假伺服器重現該機制。重現條件(安裝方tai-kns-trade, 2026-09-24 11:5x實測):
48
+ // 長回應(chat/completions約35秒)時Node fetch所見回應標頭為0個、本體9,224 bytes為brotli位元組, 改帶identity
49
+ // 則本體為22,124 bytes之JSON; 同環境同時段他端點標頭正常(npm registry 13個、Zen /models 8個含content-encoding=br)
50
+ // 且未設代理。本機同日對Zen取樣7次(含65KB長回應)皆正確標示br、未重現——推測漏標為有條件出現(長回應),
51
+ // 條件未定。identity請伺服器勿壓縮, 從源頭消除「壓縮處理不一致」一類失敗(不論成因在伺服器、代理或執行環境),
52
+ // 代價僅傳輸量變大(實測Zen回應約5~8KB→20~65KB)。呼叫端可以opt.headers之'Accept-Encoding'覆寫。
53
+ //
54
+ // 【HTTP 200但本體非JSON另報(安裝方建議C2)】JSON.parse失敗時不再與「缺choices[0].message.content」同一句,
55
+ // 改回INVALID_RESPONSE: body is not JSON(附原始位元組數、前16位元組hex、content-encoding、content-type,
56
+ // 規則單一來源見describeNonJsonBody.mjs), 遞補層據此整組跳過; 「JSON缺content」維持換金鑰。
57
+ //
58
+ // 【截斷(finish_reason為length或content_filter)預設失敗(2026-09-24起, 規則單一來源見checkTruncation.mjs)】
59
+ // 舊版不看finish_reason, 實測Zen之space-bunny-free於max_tokens:600時推理即耗盡而回content:""、
60
+ // 本轉接器卻回ok:true(靜默成功)。現於null檢查與validate之前裁定: 預設回INCOMPLETE_RESPONSE
61
+ // (errorType incomplete, 結果帶truncated:true), 不論validate為何——「validate接受」不代表內容完整。
62
+ // 呼叫端明示acceptTruncated:true才放行length之截斷(交validate裁決, 通過者仍標truncated:true);
63
+ // content_filter與可見輸出為空者一律失敗。finish_reason為stop/null/未知值者照舊(不可誤殺)。
64
+ //
65
+ // 【重試語意對齊execCli】4xx(429除外)為客戶端錯誤不可重試而立即中止; 截斷亦不重試(同一請求必然再截斷);
43
66
  // 429/5xx/網路錯誤/逾時依maxRetries線性退避重試(間隔retryDelayMs*次數, 上限15000ms)。
44
67
  //
45
68
  // 【結果結構對齊execCli】{ ok, stdout, stderr, code, error, durationMs, attempts, usage },
46
69
  // usage為原始回應之token用量原樣透傳(無則null; 驗證失敗等已耗token之失敗亦帶出),
47
70
  // CLI類轉接器無可靠來源故無此欄——呼叫端可據此把「真實用量」與「只能估算」分開處理。
71
+ // 取得並解析回應後之結果另帶finishReason(正規化終止原因, 缺值為'')與truncated(是否截斷)。
48
72
  // 失敗結果另帶機器可讀之errorType(timeout/fetch/http/tool-unsupported/invalid-response/
49
- // validation/params, 一覽見getErrorType.mjs檔頭), error字串保留不動, 兩者並存。
73
+ // incomplete/validation/params, 一覽見getErrorType.mjs檔頭), error字串保留不動, 兩者並存。
50
74
  // stdout為回覆內容、code為HTTP狀態碼(網路錯誤與逾時為null)、逾時error以TIMEOUT開頭、
51
- // 驗證失敗error為OUTPUT_VALIDATION_FAILED——故dispatchAiFallback之失敗分流
52
- // (TIMEOUT/驗證失敗跳組, 其餘換金鑰)對本轉接器同樣成立, 無須任何修改。
75
+ // 驗證失敗error為OUTPUT_VALIDATION_FAILED(validate拋錯亦同, 拋錯訊息置stderr)——
76
+ // dispatchAiFallback據此分流: 逾時/驗證失敗/截斷/工具不支援/本體非JSON整組跳過, 其餘換金鑰。
53
77
 
54
78
 
55
79
  //預設值
@@ -72,9 +96,10 @@ let optTruncate = {
72
96
  * @param {Object} body 輸入請求本體物件
73
97
  * @param {Number} timeoutMs 輸入逾時毫秒
74
98
  * @param {Function|null} validator 輸入驗證函式
99
+ * @param {Boolean} acceptTruncated 輸入是否明示接受截斷內容
75
100
  * @returns {Promise} 回傳Promise,resolve回傳結果物件
76
101
  */
77
- async function callOnce(url, headers, body, timeoutMs, validator) {
102
+ async function callOnce(url, headers, body, timeoutMs, validator, acceptTruncated) {
78
103
 
79
104
  let t0 = Date.now()
80
105
 
@@ -97,7 +122,9 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
97
122
  controller.abort()
98
123
  }, timeoutMs)
99
124
 
125
+ //本體先取原始位元組再以UTF-8解碼(等同res.text()), 本體非JSON時才有原始位元組可供診斷(見describeNonJsonBody.mjs)
100
126
  let res = null
127
+ let raw = new Uint8Array(0)
101
128
  let txt = ''
102
129
  try {
103
130
  res = await fetch(url, {
@@ -106,7 +133,8 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
106
133
  body: JSON.stringify(body),
107
134
  signal: controller.signal,
108
135
  })
109
- txt = await res.text()
136
+ raw = new Uint8Array(await res.arrayBuffer())
137
+ txt = new TextDecoder('utf-8').decode(raw)
110
138
  }
111
139
  catch (err) {
112
140
  clearTimeout(timer)
@@ -134,20 +162,39 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
134
162
 
135
163
  //取出choices[0]與usage(token用量, 原樣透傳; 失敗回應亦可能已耗token, 一併帶出)
136
164
  let content = null
137
- let finishReason = ''
165
+ let finishReasonRaw = null
138
166
  let toolCalls = null
139
167
  let usage = null
168
+ let parsed = true
140
169
  try {
141
170
  let j = JSON.parse(txt)
142
171
  content = get(j, 'choices.0.message.content', null)
143
- finishReason = get(j, 'choices.0.finish_reason', '')
172
+ finishReasonRaw = get(j, 'choices.0.finish_reason', null)
144
173
  toolCalls = get(j, 'choices.0.message.tool_calls', null)
145
174
  usage = get(j, 'usage', null)
146
175
  if (!isobj(usage)) {
147
176
  usage = null
148
177
  }
149
178
  }
150
- catch {}
179
+ catch {
180
+ parsed = false
181
+ }
182
+
183
+ //本體非JSON, 與「JSON缺content」分開回報並附原始位元組資訊; 遞補層據前綴整組跳過(見describeNonJsonBody.mjs)
184
+ if (!parsed) {
185
+ return mkResult({
186
+ stderr: strTruncate(txt, 500, optTruncate),
187
+ code: res.status,
188
+ error: describeNonJsonBody(raw, res.headers),
189
+ errorType: 'invalid-response',
190
+ finishReason: '',
191
+ truncated: false,
192
+ })
193
+ }
194
+
195
+ //fin, 自此起之每個結果皆外顯正規化終止原因與是否截斷(規則見checkTruncation.mjs)
196
+ let finishReason = normalizeFinishReason(finishReasonRaw)
197
+ let fin = { finishReason, truncated: TRUNCATION_REASONS.includes(finishReason) }
151
198
 
152
199
  //tool_calls, 本轉接器不支援工具迴圈(見檔頭), 明確回報而不假裝成功
153
200
  //(Agnes於tool_calls時content為"\n\n"非null, 不攔截會靜默回傳空白內容)
@@ -158,31 +205,67 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
158
205
  error: 'TOOL_CALLS_UNSUPPORTED: use a cli kind (opencode/claude/codex/antigravity) when tools are needed',
159
206
  errorType: 'tool-unsupported',
160
207
  usage,
208
+ ...fin,
161
209
  })
162
210
  }
163
211
 
164
- if (content === null || content === undefined) {
212
+ //content字串化(少數閘道回array形態); null/undefined一律視為無內容
213
+ if (content === undefined) {
214
+ content = null
215
+ }
216
+ if (content !== null && typeof content !== 'string') {
217
+ content = JSON.stringify(content)
218
+ }
219
+
220
+ //截斷, 於null檢查與validate之前裁定: 預設失敗, 明示acceptTruncated才交validate放行(見檔頭)
221
+ if (fin.truncated) {
222
+ let d = judgeTruncated({
223
+ finishReason,
224
+ content,
225
+ acceptTruncated,
226
+ validator,
227
+ reasoningTokens: get(usage, 'completion_tokens_details.reasoning_tokens', null),
228
+ label: `finish_reason=${finishReason}`,
229
+ })
230
+ if (!d.accept) {
231
+ return mkResult({
232
+ stdout: strTruncate(content || '', 500, optTruncate),
233
+ stderr: strTruncate((d.threw ? `validate threw: ${d.threw}\n` : '') + txt, 500, optTruncate),
234
+ code: res.status,
235
+ error: d.error,
236
+ errorType: 'incomplete',
237
+ usage,
238
+ ...fin,
239
+ })
240
+ }
241
+ return mkResult({ ok: true, stdout: content, code: res.status, usage, ...fin })
242
+ }
243
+
244
+ if (content === null) {
165
245
  return mkResult({
166
246
  stderr: strTruncate(txt, 500, optTruncate),
167
247
  code: res.status,
168
248
  error: 'INVALID_RESPONSE: missing choices[0].message.content',
169
249
  errorType: 'invalid-response',
170
250
  usage,
251
+ ...fin,
171
252
  })
172
253
  }
173
- if (typeof content !== 'string') {
174
- content = JSON.stringify(content) //少數閘道回array形態
175
- }
176
254
 
177
- //validator, error與execCli一致令dispatchAiFallback可統一分流
178
- if (validator && !validator(content)) {
179
- return mkResult({
180
- stdout: strTruncate(content, 500, optTruncate),
181
- code: res.status,
182
- error: 'OUTPUT_VALIDATION_FAILED',
183
- errorType: 'validation',
184
- usage,
185
- })
255
+ //validator, error與execCli一致令dispatchAiFallback可統一分流; 拋錯視同拒絕(不reject), 訊息置stderr
256
+ if (validator) {
257
+ let v = safeValidate(validator, content)
258
+ if (!v.pass) {
259
+ return mkResult({
260
+ stdout: strTruncate(content, 500, optTruncate),
261
+ stderr: v.threw ? `validate threw: ${v.threw}` : '',
262
+ code: res.status,
263
+ error: 'OUTPUT_VALIDATION_FAILED',
264
+ errorType: 'validation',
265
+ usage,
266
+ ...fin,
267
+ })
268
+ }
186
269
  }
187
270
 
188
271
  return mkResult({
@@ -190,6 +273,7 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
190
273
  stdout: content,
191
274
  code: res.status,
192
275
  usage,
276
+ ...fin,
193
277
  })
194
278
  }
195
279
 
@@ -216,12 +300,13 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
216
300
  * @param {String} [opt.key=''] 輸入API key字串,以Bearer置於Authorization標頭,預設''代表不帶認證標頭
217
301
  * @param {String} [opt.system=''] 輸入system提示詞字串,將以system角色置於messages首位,預設''代表不帶
218
302
  * @param {Object} [opt.body={}] 輸入額外請求本體物件(如temperature、max_tokens、response_format),將併入預設body(同名鍵以此為準),預設{}。注意本轉接器不支援工具,帶入tools而模型回tool_calls時一律以TOOL_CALLS_UNSUPPORTED回報失敗,需要工具請改用CLI類kind
219
- * @param {Object} [opt.headers={}] 輸入額外請求標頭物件,預設{}
303
+ * @param {Object} [opt.headers={}] 輸入額外請求標頭物件,同名鍵覆寫預設標頭;預設標頭含'Accept-Encoding: identity'(防伺服器壓縮卻漏標Content-Encoding,見檔頭),要改回允許壓縮可給{'Accept-Encoding':'gzip, deflate, br'},預設{}
220
304
  * @param {Number} [opt.timeoutMs=300000] 輸入逾時毫秒正整數,逾時將中止請求(含回應串流讀取),全套件統一預設300000
221
- * @param {String|Function} [opt.validate=undefined] 輸入回覆內容驗證規則字串或自訂驗證函數,規則字串支援'nonempty'、'json'、'min:100',多規則可用逗號串接,預設undefined代表不驗證
222
- * @param {Number} [opt.maxRetries=0] 輸入失敗後最大重試次數非負整數,4xx(429除外)不重試,預設0
305
+ * @param {String|Function} [opt.validate=undefined] 輸入回覆內容驗證規則字串或自訂驗證函數,規則字串支援'nonempty'、'json'、'min:100',多規則可用逗號串接,自訂函數拋錯視同驗證失敗,預設undefined代表不驗證
306
+ * @param {Boolean} [opt.acceptTruncated=false] 輸入是否接受被截斷(finish_reason為length)之內容布林值,true代表交validate裁決(無validate則直接接受)且結果標truncated:true;content_filter與可見輸出為空者一律失敗,預設false代表截斷一律失敗(errorType incomplete)
307
+ * @param {Number} [opt.maxRetries=0] 輸入失敗後最大重試次數非負整數,4xx(429除外)與截斷不重試,預設0
223
308
  * @param {Number} [opt.retryDelayMs=5000] 輸入重試間隔毫秒正整數,實際間隔為retryDelayMs乘以重試次數且上限15000ms,預設5000
224
- * @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否成功布林值)、stdout(回覆內容字串)、stderr(失敗時之原始回應本體)、code(HTTP狀態碼,網路錯誤與逾時為null)、error(錯誤訊息字串,成功時為空字串)、errorType(僅失敗時,機器可讀錯誤類別字串,一覽見getErrorType.mjs檔頭)、durationMs(耗時毫秒)、attempts(實際嘗試次數)、usage(原始回應之token用量物件原樣透傳,無則null),本函數不會reject
309
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否成功布林值)、stdout(回覆內容字串)、stderr(失敗時之原始回應本體)、code(HTTP狀態碼,網路錯誤與逾時為null)、error(錯誤訊息字串,成功時為空字串)、errorType(僅失敗時,機器可讀錯誤類別字串,一覽見getErrorType.mjs檔頭)、durationMs(耗時毫秒)、attempts(實際嘗試次數)、usage(原始回應之token用量物件原樣透傳,無則null)、finishReason(已取得回應時之正規化終止原因字串,缺值為'')、truncated(已取得回應時是否被截斷布林值),本函數不會reject
225
310
  * @example
226
311
  * //need network, no cli required
227
312
  *
@@ -315,6 +400,9 @@ async function dispatchApiOpenaiCompat(prompt, opt = {}) {
315
400
  //validator
316
401
  let validator = buildValidator(get(opt, 'validate', null))
317
402
 
403
+ //acceptTruncated, 僅明示true才放行截斷內容(見檔頭)
404
+ let acceptTruncated = get(opt, 'acceptTruncated', null) === true
405
+
318
406
  //url, baseURL尾端斜線正規化後接上端點
319
407
  let url = baseURL.replace(/\/+$/, '') + '/chat/completions'
320
408
 
@@ -328,8 +416,8 @@ async function dispatchApiOpenaiCompat(prompt, opt = {}) {
328
416
  //body, 額外鍵以bodyExtra為準(可覆寫temperature等, 覆寫messages屬進階用法)
329
417
  let body = { model, messages, ...bodyExtra }
330
418
 
331
- //headers
332
- let headers = { 'Content-Type': 'application/json', ...headersExtra }
419
+ //headers, Accept-Encoding預設identity(防伺服器壓縮卻漏標Content-Encoding, 見檔頭), headersExtra同名鍵可覆寫
420
+ let headers = { 'Content-Type': 'application/json', 'Accept-Encoding': 'identity', ...headersExtra }
333
421
  if (isestr(key)) {
334
422
  headers['Authorization'] = `Bearer ${key}`
335
423
  }
@@ -344,7 +432,7 @@ async function dispatchApiOpenaiCompat(prompt, opt = {}) {
344
432
  await delay(Math.min(retryDelayMs * attempt, MAX_RETRY_DELAY_MS))
345
433
  }
346
434
 
347
- lastResult = await callOnce(url, headers, body, timeoutMs, validator)
435
+ lastResult = await callOnce(url, headers, body, timeoutMs, validator, acceptTruncated)
348
436
  totalAttempts = attempt + 1
349
437
 
350
438
  if (lastResult.ok) {
@@ -352,6 +440,11 @@ async function dispatchApiOpenaiCompat(prompt, opt = {}) {
352
440
  return lastResult
353
441
  }
354
442
 
443
+ //不可重試: 截斷為決定性(同一請求必然再截斷, 重試只會再燒一次輸出上限)
444
+ if (lastResult.truncated === true) {
445
+ break
446
+ }
447
+
355
448
  //不可重試: 4xx(429除外)為客戶端錯誤, 重試無意義
356
449
  let c = lastResult.code
357
450
  if (isnum(c) && c >= 400 && c < 500 && c !== 429) {
@@ -11,6 +11,8 @@ import buildValidator from './buildValidator.mjs'
11
11
  import strTruncate from 'wsemi/src/strTruncate.mjs'
12
12
  import getErrorResult from './getErrorResult.mjs'
13
13
  import dfTimeoutMs from './dfTimeoutMs.mjs'
14
+ import { normalizeFinishReason, safeValidate, judgeTruncated } from './checkTruncation.mjs'
15
+ import describeNonJsonBody from './describeNonJsonBody.mjs'
14
16
 
15
17
 
16
18
  // dispatchApiOpenaiResponses.mjs — 以fetch直呼OpenAI Responses API(/responses)
@@ -33,16 +35,25 @@ import dfTimeoutMs from './dfTimeoutMs.mjs'
33
35
  // (chat/completions為prompt_tokens/completion_tokens/total_tokens)。
34
36
  // 本套件usage一律原樣透傳不做正規化, 跨kind加總時呼叫端須自行對應欄位名。
35
37
  //
36
- // 【status不為completed一律視為失敗, 不回半截內容】incomplete(如max_output_tokens
38
+ // 【status不為completed預設失敗, 不回半截內容】incomplete(如max_output_tokens
37
39
  // 耗盡)之output常為空陣列或截斷內容, 當成功回傳會讓截斷結果流入下游而無人察覺;
38
40
  // 故以INCOMPLETE_RESPONSE回報(errorType為incomplete), 呼叫端據此調高max_output_tokens
39
41
  // 或換家。實測: max_output_tokens為16時status為incomplete、incomplete_details為
40
42
  // {reason:'max_output_tokens'}、output為空陣列。
43
+ // 2026-09-24起與dispatchApiOpenaiCompat同一截斷規則(單一來源checkTruncation.mjs): 僅status為incomplete
44
+ // 視為截斷(結果帶truncated:true, finishReason將max_output_tokens正規化為length), 呼叫端明示
45
+ // acceptTruncated:true才放行length之截斷(交validate裁決); failed/缺status/處理中等屬非截斷之失敗,
46
+ // truncated:false, 遞補層照舊換金鑰(服務回錯)。可見輸出為空之截斷訊息附reasoning_tokens。
41
47
  //
42
48
  // 【不支援工具, 與dispatchApiOpenaiCompat同一決策】output含function_call型元素時
43
49
  // 以TOOL_CALLS_UNSUPPORTED回報而不假裝成功; 理由(工具迴圈須自建harness、tool_call
44
50
  // 有會話束縛無法外傳上層agent)詳見dispatchApiOpenaiCompat.mjs與adapters.mjs檔頭。
45
51
  //
52
+ // 【預設帶Accept-Encoding: identity(2026-09-24起)】防伺服器壓縮卻漏標Content-Encoding而令fetch不解壓、
53
+ // JSON.parse失敗; 與dispatchApiOpenaiCompat同一決策與依據(見該檔檔頭), opt.headers可覆寫。
54
+ // HTTP 200但本體非JSON時另報INVALID_RESPONSE: body is not JSON(附原始位元組資訊), 與「缺output陣列」分開
55
+ // (同dispatchApiOpenaiCompat, 見describeNonJsonBody.mjs)。
56
+ //
46
57
  // 【錯誤碼實測(Zen)】壞金鑰401(AuthError); 未知model亦回401(ModelError: Model X is not
47
58
  // supported)而非404——故不可用狀態碼區分「金鑰錯」與「模型名錯」, 須讀stderr之訊息。
48
59
  //
@@ -115,9 +126,10 @@ function extractOutputText(output) {
115
126
  * @param {Object} body 輸入請求本體物件
116
127
  * @param {Number} timeoutMs 輸入逾時毫秒
117
128
  * @param {Function|null} validator 輸入驗證函式
129
+ * @param {Boolean} acceptTruncated 輸入是否明示接受截斷內容
118
130
  * @returns {Promise} 回傳Promise,resolve回傳結果物件
119
131
  */
120
- async function callOnce(url, headers, body, timeoutMs, validator) {
132
+ async function callOnce(url, headers, body, timeoutMs, validator, acceptTruncated) {
121
133
 
122
134
  let t0 = Date.now()
123
135
 
@@ -140,7 +152,9 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
140
152
  controller.abort()
141
153
  }, timeoutMs)
142
154
 
155
+ //本體先取原始位元組再以UTF-8解碼(等同res.text()), 本體非JSON時才有原始位元組可供診斷(見describeNonJsonBody.mjs)
143
156
  let res = null
157
+ let raw = new Uint8Array(0)
144
158
  let txt = ''
145
159
  try {
146
160
  res = await fetch(url, {
@@ -149,7 +163,8 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
149
163
  body: JSON.stringify(body),
150
164
  signal: controller.signal,
151
165
  })
152
- txt = await res.text()
166
+ raw = new Uint8Array(await res.arrayBuffer())
167
+ txt = new TextDecoder('utf-8').decode(raw)
153
168
  }
154
169
  catch (err) {
155
170
  clearTimeout(timer)
@@ -197,18 +212,36 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
197
212
  catch {
198
213
  parsed = false
199
214
  }
200
- if (!parsed || !isarr(output)) {
215
+ //本體非JSON, 與「JSON缺output」分開回報並附原始位元組資訊; 遞補層據前綴整組跳過(見describeNonJsonBody.mjs)
216
+ if (!parsed) {
201
217
  return mkResult({
202
218
  stderr: strTruncate(txt, 500, optTruncate),
203
219
  code: res.status,
204
- error: 'INVALID_RESPONSE: missing output array',
220
+ error: describeNonJsonBody(raw, res.headers),
205
221
  errorType: 'invalid-response',
206
222
  usage,
223
+ finishReason: '',
224
+ truncated: false,
207
225
  })
208
226
  }
209
227
 
228
+ //fin, 自此起之每個結果皆外顯正規化終止原因與是否截斷(與chat/completions對齊, 規則見checkTruncation.mjs):
229
+ //completed→stop; incomplete→依reason(max_output_tokens→length、content_filter原值、缺→incomplete); 其餘status原值; 缺status→''
230
+ let statusN = normalizeFinishReason(status)
231
+ let reasonN = normalizeFinishReason(incompleteReason)
232
+ let finishReason = statusN
233
+ if (statusN === 'completed') {
234
+ finishReason = 'stop'
235
+ }
236
+ else if (statusN === 'incomplete') {
237
+ finishReason = (reasonN === 'max_output_tokens') ? 'length' : (reasonN || 'incomplete')
238
+ }
239
+ let fin = { finishReason, truncated: statusN === 'incomplete' }
240
+ let detail = incompleteReason || failMsg || status || 'unknown'
241
+ let isArr = isarr(output)
242
+
210
243
  //function_call, 本轉接器不支援工具迴圈(見檔頭), 明確回報而不假裝成功
211
- let hasToolCall = output.some((o) => get(o, 'type', '') === 'function_call')
244
+ let hasToolCall = isArr && output.some((o) => get(o, 'type', '') === 'function_call')
212
245
  if (hasToolCall) {
213
246
  return mkResult({
214
247
  stderr: strTruncate(txt, 1000, optTruncate),
@@ -216,12 +249,48 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
216
249
  error: 'TOOL_CALLS_UNSUPPORTED: use a cli kind (opencode/claude/codex/antigravity) when tools are needed',
217
250
  errorType: 'tool-unsupported',
218
251
  usage,
252
+ ...fin,
253
+ })
254
+ }
255
+
256
+ //截斷(status為incomplete), 於output檢查與validate之前裁定: 預設失敗, 明示acceptTruncated才交validate放行(見檔頭)
257
+ if (fin.truncated) {
258
+ let partial = isArr ? extractOutputText(output) : ''
259
+ let d = judgeTruncated({
260
+ finishReason,
261
+ content: partial,
262
+ acceptTruncated,
263
+ validator,
264
+ reasoningTokens: get(usage, 'output_tokens_details.reasoning_tokens', null),
265
+ label: `status=${status} (${detail})`,
219
266
  })
267
+ if (!d.accept) {
268
+ return mkResult({
269
+ stdout: strTruncate(partial, 500, optTruncate),
270
+ stderr: strTruncate((d.threw ? `validate threw: ${d.threw}\n` : '') + txt, 500, optTruncate),
271
+ code: res.status,
272
+ error: d.error,
273
+ errorType: 'incomplete',
274
+ usage,
275
+ ...fin,
276
+ })
277
+ }
278
+ return mkResult({ ok: true, stdout: partial, code: res.status, usage, ...fin })
220
279
  }
221
280
 
222
- //status非completed一律失敗: incomplete之內容為截斷品, 當成功回傳會讓半截結果流入下游
223
- if (status !== 'completed') {
224
- let detail = incompleteReason || failMsg || status || 'unknown'
281
+ if (!isArr) {
282
+ return mkResult({
283
+ stderr: strTruncate(txt, 500, optTruncate),
284
+ code: res.status,
285
+ error: 'INVALID_RESPONSE: missing output array',
286
+ errorType: 'invalid-response',
287
+ usage,
288
+ ...fin,
289
+ })
290
+ }
291
+
292
+ //其餘非completed(failed/缺status/處理中等)屬非截斷之失敗, 不回半截內容; truncated:false故遞補層照舊換金鑰
293
+ if (statusN !== 'completed') {
225
294
  return mkResult({
226
295
  stdout: strTruncate(extractOutputText(output), 500, optTruncate),
227
296
  stderr: strTruncate(txt, 500, optTruncate),
@@ -229,6 +298,7 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
229
298
  error: `INCOMPLETE_RESPONSE: status=${status || 'missing'} (${detail})`,
230
299
  errorType: 'incomplete',
231
300
  usage,
301
+ ...fin,
232
302
  })
233
303
  }
234
304
 
@@ -241,18 +311,24 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
241
311
  error: 'INVALID_RESPONSE: no output_text in output messages',
242
312
  errorType: 'invalid-response',
243
313
  usage,
314
+ ...fin,
244
315
  })
245
316
  }
246
317
 
247
- //validator, error與execCli一致令dispatchAiFallback可統一分流
248
- if (validator && !validator(content)) {
249
- return mkResult({
250
- stdout: strTruncate(content, 500, optTruncate),
251
- code: res.status,
252
- error: 'OUTPUT_VALIDATION_FAILED',
253
- errorType: 'validation',
254
- usage,
255
- })
318
+ //validator, error與execCli一致令dispatchAiFallback可統一分流; 拋錯視同拒絕(不reject), 訊息置stderr
319
+ if (validator) {
320
+ let v = safeValidate(validator, content)
321
+ if (!v.pass) {
322
+ return mkResult({
323
+ stdout: strTruncate(content, 500, optTruncate),
324
+ stderr: v.threw ? `validate threw: ${v.threw}` : '',
325
+ code: res.status,
326
+ error: 'OUTPUT_VALIDATION_FAILED',
327
+ errorType: 'validation',
328
+ usage,
329
+ ...fin,
330
+ })
331
+ }
256
332
  }
257
333
 
258
334
  return mkResult({
@@ -260,6 +336,7 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
260
336
  stdout: content,
261
337
  code: res.status,
262
338
  usage,
339
+ ...fin,
263
340
  })
264
341
  }
265
342
 
@@ -286,12 +363,13 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
286
363
  * @param {String} [opt.key=''] 輸入API key字串,以Bearer置於Authorization標頭,預設''代表不帶認證標頭
287
364
  * @param {String} [opt.system=''] 輸入system提示詞字串,將置於instructions欄位(Responses API之system管道),預設''代表不帶
288
365
  * @param {Object} [opt.body={}] 輸入額外請求本體物件(如temperature、max_output_tokens、reasoning),將併入預設body(同名鍵以此為準),預設{}。注意輸出上限欄位名為max_output_tokens而非max_tokens;本轉接器不支援工具,帶入tools而模型回function_call時一律以TOOL_CALLS_UNSUPPORTED回報失敗
289
- * @param {Object} [opt.headers={}] 輸入額外請求標頭物件,預設{}
366
+ * @param {Object} [opt.headers={}] 輸入額外請求標頭物件,同名鍵覆寫預設標頭;預設標頭含'Accept-Encoding: identity'(防伺服器壓縮卻漏標Content-Encoding,見檔頭),預設{}
290
367
  * @param {Number} [opt.timeoutMs=300000] 輸入逾時毫秒正整數,逾時將中止請求(含回應串流讀取),全套件統一預設300000
291
- * @param {String|Function} [opt.validate=undefined] 輸入回覆內容驗證規則字串或自訂驗證函數,規則字串支援'nonempty'、'json'、'min:100',多規則可用逗號串接,預設undefined代表不驗證
292
- * @param {Number} [opt.maxRetries=0] 輸入失敗後最大重試次數非負整數,4xx(429除外)不重試,預設0
368
+ * @param {String|Function} [opt.validate=undefined] 輸入回覆內容驗證規則字串或自訂驗證函數,規則字串支援'nonempty'、'json'、'min:100',多規則可用逗號串接,自訂函數拋錯視同驗證失敗,預設undefined代表不驗證
369
+ * @param {Boolean} [opt.acceptTruncated=false] 輸入是否接受被截斷(status為incomplete且reason為max_output_tokens)之內容布林值,true代表交validate裁決(無validate則直接接受)且結果標truncated:true;content_filter與可見輸出為空者一律失敗,預設false代表截斷一律失敗
370
+ * @param {Number} [opt.maxRetries=0] 輸入失敗後最大重試次數非負整數,4xx(429除外)與截斷不重試,預設0
293
371
  * @param {Number} [opt.retryDelayMs=5000] 輸入重試間隔毫秒正整數,實際間隔為retryDelayMs乘以重試次數且上限15000ms,預設5000
294
- * @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
372
+ * @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
295
373
  * @example
296
374
  * //need network, no cli required
297
375
  *
@@ -374,6 +452,9 @@ async function dispatchApiOpenaiResponses(prompt, opt = {}) {
374
452
  //validator
375
453
  let validator = buildValidator(get(opt, 'validate', null))
376
454
 
455
+ //acceptTruncated, 僅明示true才放行截斷內容(見檔頭)
456
+ let acceptTruncated = get(opt, 'acceptTruncated', null) === true
457
+
377
458
  //url, baseURL尾端斜線正規化後接上端點
378
459
  let url = baseURL.replace(/\/+$/, '') + '/responses'
379
460
 
@@ -384,8 +465,8 @@ async function dispatchApiOpenaiResponses(prompt, opt = {}) {
384
465
  }
385
466
  body = { ...body, ...bodyExtra }
386
467
 
387
- //headers
388
- let headers = { 'Content-Type': 'application/json', ...headersExtra }
468
+ //headers, Accept-Encoding預設identity(防伺服器壓縮卻漏標Content-Encoding, 見檔頭), headersExtra同名鍵可覆寫
469
+ let headers = { 'Content-Type': 'application/json', 'Accept-Encoding': 'identity', ...headersExtra }
389
470
  if (isestr(key)) {
390
471
  headers['Authorization'] = `Bearer ${key}`
391
472
  }
@@ -400,7 +481,7 @@ async function dispatchApiOpenaiResponses(prompt, opt = {}) {
400
481
  await delay(Math.min(retryDelayMs * attempt, MAX_RETRY_DELAY_MS))
401
482
  }
402
483
 
403
- lastResult = await callOnce(url, headers, body, timeoutMs, validator)
484
+ lastResult = await callOnce(url, headers, body, timeoutMs, validator, acceptTruncated)
404
485
  totalAttempts = attempt + 1
405
486
 
406
487
  if (lastResult.ok) {
@@ -408,6 +489,11 @@ async function dispatchApiOpenaiResponses(prompt, opt = {}) {
408
489
  return lastResult
409
490
  }
410
491
 
492
+ //不可重試: 截斷為決定性(同一請求必然再截斷, 重試只會再燒一次輸出上限)
493
+ if (lastResult.truncated === true) {
494
+ break
495
+ }
496
+
411
497
  //不可重試: 4xx(429除外)為客戶端錯誤, 重試無意義
412
498
  let c = lastResult.code
413
499
  if (isnum(c) && c >= 400 && c < 500 && c !== 429) {