w-dispatch-ai 1.0.36 → 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 (62) hide show
  1. package/README.md +10 -6
  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 +96 -29
  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 +1234 -115
  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 +2 -2
  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 +94 -27
  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 +1 -1
  56. package/src/wkf/callAiWithFallback.mjs +61 -18
  57. package/src/wkf/runFanout.mjs +2 -0
  58. package/src/wkf/salvageTruncatedArray.mjs +9 -0
  59. package/test/tools/fakeServerForApiTest.mjs +79 -2
  60. package/test/unit-dispatchApiOpenaiCompat.test.mjs +169 -1
  61. package/test/unit-dispatchApiOpenaiResponses.test.mjs +63 -5
  62. package/test/unit-dispatchApiTypesafeSystemone.test.mjs +17 -0
@@ -10,6 +10,7 @@ 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'
13
14
 
14
15
 
15
16
  // dispatchApiOpenaiCompat.mjs — 以fetch直呼OpenAI相容API(chat/completions)
@@ -39,17 +40,32 @@ import dfTimeoutMs from './dfTimeoutMs.mjs'
39
40
  // 故呼叫端若於body帶入tools, 本函數一律以TOOL_CALLS_UNSUPPORTED回報失敗而不假裝成功
40
41
  // (實測Agnes於tool_calls時content為"\n\n"而非null, 不特別處理會靜默回傳空白內容)。
41
42
  //
42
- // 【重試語意對齊execCli】4xx(429除外)為客戶端錯誤不可重試而立即中止;
43
+ // 【預設帶Accept-Encoding: identity(2026-09-24起, 三個REST轉接器同步)】Node內建fetch(undici)只在回應
44
+ // 帶Content-Encoding時才自動解壓; 伺服器若壓縮了本體卻漏標此標頭, fetch原樣交出壓縮位元組,
45
+ // JSON.parse失敗而回INVALID_RESPONSE(使用端回報Zen之space-bunny-free即此症: 手動brotli解壓得完整答案,
46
+ // 改帶identity即得正常JSON)。本機以假伺服器重現該機制; 惟同日對Zen取樣7次皆正確標示br(未重現漏標),
47
+ // 故屬防禦: identity請伺服器勿壓縮, 從源頭消除「壓縮處理不一致」一類失敗(不論成因在伺服器、代理或執行環境)。
48
+ // 代價僅傳輸量變大(實測Zen回應約5~8KB→20~65KB)。呼叫端可以opt.headers之'Accept-Encoding'覆寫。
49
+ //
50
+ // 【截斷(finish_reason為length或content_filter)預設失敗(2026-09-24起, 規則單一來源見checkTruncation.mjs)】
51
+ // 舊版不看finish_reason, 實測Zen之space-bunny-free於max_tokens:600時推理即耗盡而回content:""、
52
+ // 本轉接器卻回ok:true(靜默成功)。現於null檢查與validate之前裁定: 預設回INCOMPLETE_RESPONSE
53
+ // (errorType incomplete, 結果帶truncated:true), 不論validate為何——「validate接受」不代表內容完整。
54
+ // 呼叫端明示acceptTruncated:true才放行length之截斷(交validate裁決, 通過者仍標truncated:true);
55
+ // content_filter與可見輸出為空者一律失敗。finish_reason為stop/null/未知值者照舊(不可誤殺)。
56
+ //
57
+ // 【重試語意對齊execCli】4xx(429除外)為客戶端錯誤不可重試而立即中止; 截斷亦不重試(同一請求必然再截斷);
43
58
  // 429/5xx/網路錯誤/逾時依maxRetries線性退避重試(間隔retryDelayMs*次數, 上限15000ms)。
44
59
  //
45
60
  // 【結果結構對齊execCli】{ ok, stdout, stderr, code, error, durationMs, attempts, usage },
46
61
  // usage為原始回應之token用量原樣透傳(無則null; 驗證失敗等已耗token之失敗亦帶出),
47
62
  // CLI類轉接器無可靠來源故無此欄——呼叫端可據此把「真實用量」與「只能估算」分開處理。
63
+ // 取得並解析回應後之結果另帶finishReason(正規化終止原因, 缺值為'')與truncated(是否截斷)。
48
64
  // 失敗結果另帶機器可讀之errorType(timeout/fetch/http/tool-unsupported/invalid-response/
49
- // validation/params, 一覽見getErrorType.mjs檔頭), error字串保留不動, 兩者並存。
65
+ // incomplete/validation/params, 一覽見getErrorType.mjs檔頭), error字串保留不動, 兩者並存。
50
66
  // stdout為回覆內容、code為HTTP狀態碼(網路錯誤與逾時為null)、逾時error以TIMEOUT開頭、
51
- // 驗證失敗error為OUTPUT_VALIDATION_FAILED——故dispatchAiFallback之失敗分流
52
- // (TIMEOUT/驗證失敗跳組, 其餘換金鑰)對本轉接器同樣成立, 無須任何修改。
67
+ // 驗證失敗error為OUTPUT_VALIDATION_FAILED(validate拋錯亦同, 拋錯訊息置stderr)——
68
+ // dispatchAiFallback據此分流: 逾時/驗證失敗/截斷/工具不支援整組跳過, 其餘換金鑰。
53
69
 
54
70
 
55
71
  //預設值
@@ -72,9 +88,10 @@ let optTruncate = {
72
88
  * @param {Object} body 輸入請求本體物件
73
89
  * @param {Number} timeoutMs 輸入逾時毫秒
74
90
  * @param {Function|null} validator 輸入驗證函式
91
+ * @param {Boolean} acceptTruncated 輸入是否明示接受截斷內容
75
92
  * @returns {Promise} 回傳Promise,resolve回傳結果物件
76
93
  */
77
- async function callOnce(url, headers, body, timeoutMs, validator) {
94
+ async function callOnce(url, headers, body, timeoutMs, validator, acceptTruncated) {
78
95
 
79
96
  let t0 = Date.now()
80
97
 
@@ -134,13 +151,13 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
134
151
 
135
152
  //取出choices[0]與usage(token用量, 原樣透傳; 失敗回應亦可能已耗token, 一併帶出)
136
153
  let content = null
137
- let finishReason = ''
154
+ let finishReasonRaw = null
138
155
  let toolCalls = null
139
156
  let usage = null
140
157
  try {
141
158
  let j = JSON.parse(txt)
142
159
  content = get(j, 'choices.0.message.content', null)
143
- finishReason = get(j, 'choices.0.finish_reason', '')
160
+ finishReasonRaw = get(j, 'choices.0.finish_reason', null)
144
161
  toolCalls = get(j, 'choices.0.message.tool_calls', null)
145
162
  usage = get(j, 'usage', null)
146
163
  if (!isobj(usage)) {
@@ -149,6 +166,10 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
149
166
  }
150
167
  catch {}
151
168
 
169
+ //fin, 自此起之每個結果皆外顯正規化終止原因與是否截斷(規則見checkTruncation.mjs)
170
+ let finishReason = normalizeFinishReason(finishReasonRaw)
171
+ let fin = { finishReason, truncated: TRUNCATION_REASONS.includes(finishReason) }
172
+
152
173
  //tool_calls, 本轉接器不支援工具迴圈(見檔頭), 明確回報而不假裝成功
153
174
  //(Agnes於tool_calls時content為"\n\n"非null, 不攔截會靜默回傳空白內容)
154
175
  if (finishReason === 'tool_calls' || (toolCalls !== null && toolCalls !== undefined)) {
@@ -158,31 +179,67 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
158
179
  error: 'TOOL_CALLS_UNSUPPORTED: use a cli kind (opencode/claude/codex/antigravity) when tools are needed',
159
180
  errorType: 'tool-unsupported',
160
181
  usage,
182
+ ...fin,
183
+ })
184
+ }
185
+
186
+ //content字串化(少數閘道回array形態); null/undefined一律視為無內容
187
+ if (content === undefined) {
188
+ content = null
189
+ }
190
+ if (content !== null && typeof content !== 'string') {
191
+ content = JSON.stringify(content)
192
+ }
193
+
194
+ //截斷, 於null檢查與validate之前裁定: 預設失敗, 明示acceptTruncated才交validate放行(見檔頭)
195
+ if (fin.truncated) {
196
+ let d = judgeTruncated({
197
+ finishReason,
198
+ content,
199
+ acceptTruncated,
200
+ validator,
201
+ reasoningTokens: get(usage, 'completion_tokens_details.reasoning_tokens', null),
202
+ label: `finish_reason=${finishReason}`,
161
203
  })
204
+ if (!d.accept) {
205
+ return mkResult({
206
+ stdout: strTruncate(content || '', 500, optTruncate),
207
+ stderr: strTruncate((d.threw ? `validate threw: ${d.threw}\n` : '') + txt, 500, optTruncate),
208
+ code: res.status,
209
+ error: d.error,
210
+ errorType: 'incomplete',
211
+ usage,
212
+ ...fin,
213
+ })
214
+ }
215
+ return mkResult({ ok: true, stdout: content, code: res.status, usage, ...fin })
162
216
  }
163
217
 
164
- if (content === null || content === undefined) {
218
+ if (content === null) {
165
219
  return mkResult({
166
220
  stderr: strTruncate(txt, 500, optTruncate),
167
221
  code: res.status,
168
222
  error: 'INVALID_RESPONSE: missing choices[0].message.content',
169
223
  errorType: 'invalid-response',
170
224
  usage,
225
+ ...fin,
171
226
  })
172
227
  }
173
- if (typeof content !== 'string') {
174
- content = JSON.stringify(content) //少數閘道回array形態
175
- }
176
228
 
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
- })
229
+ //validator, error與execCli一致令dispatchAiFallback可統一分流; 拋錯視同拒絕(不reject), 訊息置stderr
230
+ if (validator) {
231
+ let v = safeValidate(validator, content)
232
+ if (!v.pass) {
233
+ return mkResult({
234
+ stdout: strTruncate(content, 500, optTruncate),
235
+ stderr: v.threw ? `validate threw: ${v.threw}` : '',
236
+ code: res.status,
237
+ error: 'OUTPUT_VALIDATION_FAILED',
238
+ errorType: 'validation',
239
+ usage,
240
+ ...fin,
241
+ })
242
+ }
186
243
  }
187
244
 
188
245
  return mkResult({
@@ -190,6 +247,7 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
190
247
  stdout: content,
191
248
  code: res.status,
192
249
  usage,
250
+ ...fin,
193
251
  })
194
252
  }
195
253
 
@@ -216,12 +274,13 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
216
274
  * @param {String} [opt.key=''] 輸入API key字串,以Bearer置於Authorization標頭,預設''代表不帶認證標頭
217
275
  * @param {String} [opt.system=''] 輸入system提示詞字串,將以system角色置於messages首位,預設''代表不帶
218
276
  * @param {Object} [opt.body={}] 輸入額外請求本體物件(如temperature、max_tokens、response_format),將併入預設body(同名鍵以此為準),預設{}。注意本轉接器不支援工具,帶入tools而模型回tool_calls時一律以TOOL_CALLS_UNSUPPORTED回報失敗,需要工具請改用CLI類kind
219
- * @param {Object} [opt.headers={}] 輸入額外請求標頭物件,預設{}
277
+ * @param {Object} [opt.headers={}] 輸入額外請求標頭物件,同名鍵覆寫預設標頭;預設標頭含'Accept-Encoding: identity'(防伺服器壓縮卻漏標Content-Encoding,見檔頭),要改回允許壓縮可給{'Accept-Encoding':'gzip, deflate, br'},預設{}
220
278
  * @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
279
+ * @param {String|Function} [opt.validate=undefined] 輸入回覆內容驗證規則字串或自訂驗證函數,規則字串支援'nonempty'、'json'、'min:100',多規則可用逗號串接,自訂函數拋錯視同驗證失敗,預設undefined代表不驗證
280
+ * @param {Boolean} [opt.acceptTruncated=false] 輸入是否接受被截斷(finish_reason為length)之內容布林值,true代表交validate裁決(無validate則直接接受)且結果標truncated:true;content_filter與可見輸出為空者一律失敗,預設false代表截斷一律失敗(errorType incomplete)
281
+ * @param {Number} [opt.maxRetries=0] 輸入失敗後最大重試次數非負整數,4xx(429除外)與截斷不重試,預設0
223
282
  * @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
283
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否成功布林值)、stdout(回覆內容字串)、stderr(失敗時之原始回應本體)、code(HTTP狀態碼,網路錯誤與逾時為null)、error(錯誤訊息字串,成功時為空字串)、errorType(僅失敗時,機器可讀錯誤類別字串,一覽見getErrorType.mjs檔頭)、durationMs(耗時毫秒)、attempts(實際嘗試次數)、usage(原始回應之token用量物件原樣透傳,無則null)、finishReason(已取得回應時之正規化終止原因字串,缺值為'')、truncated(已取得回應時是否被截斷布林值),本函數不會reject
225
284
  * @example
226
285
  * //need network, no cli required
227
286
  *
@@ -315,6 +374,9 @@ async function dispatchApiOpenaiCompat(prompt, opt = {}) {
315
374
  //validator
316
375
  let validator = buildValidator(get(opt, 'validate', null))
317
376
 
377
+ //acceptTruncated, 僅明示true才放行截斷內容(見檔頭)
378
+ let acceptTruncated = get(opt, 'acceptTruncated', null) === true
379
+
318
380
  //url, baseURL尾端斜線正規化後接上端點
319
381
  let url = baseURL.replace(/\/+$/, '') + '/chat/completions'
320
382
 
@@ -328,8 +390,8 @@ async function dispatchApiOpenaiCompat(prompt, opt = {}) {
328
390
  //body, 額外鍵以bodyExtra為準(可覆寫temperature等, 覆寫messages屬進階用法)
329
391
  let body = { model, messages, ...bodyExtra }
330
392
 
331
- //headers
332
- let headers = { 'Content-Type': 'application/json', ...headersExtra }
393
+ //headers, Accept-Encoding預設identity(防伺服器壓縮卻漏標Content-Encoding, 見檔頭), headersExtra同名鍵可覆寫
394
+ let headers = { 'Content-Type': 'application/json', 'Accept-Encoding': 'identity', ...headersExtra }
333
395
  if (isestr(key)) {
334
396
  headers['Authorization'] = `Bearer ${key}`
335
397
  }
@@ -344,7 +406,7 @@ async function dispatchApiOpenaiCompat(prompt, opt = {}) {
344
406
  await delay(Math.min(retryDelayMs * attempt, MAX_RETRY_DELAY_MS))
345
407
  }
346
408
 
347
- lastResult = await callOnce(url, headers, body, timeoutMs, validator)
409
+ lastResult = await callOnce(url, headers, body, timeoutMs, validator, acceptTruncated)
348
410
  totalAttempts = attempt + 1
349
411
 
350
412
  if (lastResult.ok) {
@@ -352,6 +414,11 @@ async function dispatchApiOpenaiCompat(prompt, opt = {}) {
352
414
  return lastResult
353
415
  }
354
416
 
417
+ //不可重試: 截斷為決定性(同一請求必然再截斷, 重試只會再燒一次輸出上限)
418
+ if (lastResult.truncated === true) {
419
+ break
420
+ }
421
+
355
422
  //不可重試: 4xx(429除外)為客戶端錯誤, 重試無意義
356
423
  let c = lastResult.code
357
424
  if (isnum(c) && c >= 400 && c < 500 && c !== 429) {
@@ -11,6 +11,7 @@ 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'
14
15
 
15
16
 
16
17
  // dispatchApiOpenaiResponses.mjs — 以fetch直呼OpenAI Responses API(/responses)
@@ -33,16 +34,23 @@ import dfTimeoutMs from './dfTimeoutMs.mjs'
33
34
  // (chat/completions為prompt_tokens/completion_tokens/total_tokens)。
34
35
  // 本套件usage一律原樣透傳不做正規化, 跨kind加總時呼叫端須自行對應欄位名。
35
36
  //
36
- // 【status不為completed一律視為失敗, 不回半截內容】incomplete(如max_output_tokens
37
+ // 【status不為completed預設失敗, 不回半截內容】incomplete(如max_output_tokens
37
38
  // 耗盡)之output常為空陣列或截斷內容, 當成功回傳會讓截斷結果流入下游而無人察覺;
38
39
  // 故以INCOMPLETE_RESPONSE回報(errorType為incomplete), 呼叫端據此調高max_output_tokens
39
40
  // 或換家。實測: max_output_tokens為16時status為incomplete、incomplete_details為
40
41
  // {reason:'max_output_tokens'}、output為空陣列。
42
+ // 2026-09-24起與dispatchApiOpenaiCompat同一截斷規則(單一來源checkTruncation.mjs): 僅status為incomplete
43
+ // 視為截斷(結果帶truncated:true, finishReason將max_output_tokens正規化為length), 呼叫端明示
44
+ // acceptTruncated:true才放行length之截斷(交validate裁決); failed/缺status/處理中等屬非截斷之失敗,
45
+ // truncated:false, 遞補層照舊換金鑰(服務回錯)。可見輸出為空之截斷訊息附reasoning_tokens。
41
46
  //
42
47
  // 【不支援工具, 與dispatchApiOpenaiCompat同一決策】output含function_call型元素時
43
48
  // 以TOOL_CALLS_UNSUPPORTED回報而不假裝成功; 理由(工具迴圈須自建harness、tool_call
44
49
  // 有會話束縛無法外傳上層agent)詳見dispatchApiOpenaiCompat.mjs與adapters.mjs檔頭。
45
50
  //
51
+ // 【預設帶Accept-Encoding: identity(2026-09-24起)】防伺服器壓縮卻漏標Content-Encoding而令fetch不解壓、
52
+ // JSON.parse失敗; 與dispatchApiOpenaiCompat同一決策與依據(見該檔檔頭), opt.headers可覆寫。
53
+ //
46
54
  // 【錯誤碼實測(Zen)】壞金鑰401(AuthError); 未知model亦回401(ModelError: Model X is not
47
55
  // supported)而非404——故不可用狀態碼區分「金鑰錯」與「模型名錯」, 須讀stderr之訊息。
48
56
  //
@@ -115,9 +123,10 @@ function extractOutputText(output) {
115
123
  * @param {Object} body 輸入請求本體物件
116
124
  * @param {Number} timeoutMs 輸入逾時毫秒
117
125
  * @param {Function|null} validator 輸入驗證函式
126
+ * @param {Boolean} acceptTruncated 輸入是否明示接受截斷內容
118
127
  * @returns {Promise} 回傳Promise,resolve回傳結果物件
119
128
  */
120
- async function callOnce(url, headers, body, timeoutMs, validator) {
129
+ async function callOnce(url, headers, body, timeoutMs, validator, acceptTruncated) {
121
130
 
122
131
  let t0 = Date.now()
123
132
 
@@ -197,7 +206,7 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
197
206
  catch {
198
207
  parsed = false
199
208
  }
200
- if (!parsed || !isarr(output)) {
209
+ if (!parsed) {
201
210
  return mkResult({
202
211
  stderr: strTruncate(txt, 500, optTruncate),
203
212
  code: res.status,
@@ -207,8 +216,23 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
207
216
  })
208
217
  }
209
218
 
219
+ //fin, 自此起之每個結果皆外顯正規化終止原因與是否截斷(與chat/completions對齊, 規則見checkTruncation.mjs):
220
+ //completed→stop; incomplete→依reason(max_output_tokens→length、content_filter原值、缺→incomplete); 其餘status原值; 缺status→''
221
+ let statusN = normalizeFinishReason(status)
222
+ let reasonN = normalizeFinishReason(incompleteReason)
223
+ let finishReason = statusN
224
+ if (statusN === 'completed') {
225
+ finishReason = 'stop'
226
+ }
227
+ else if (statusN === 'incomplete') {
228
+ finishReason = (reasonN === 'max_output_tokens') ? 'length' : (reasonN || 'incomplete')
229
+ }
230
+ let fin = { finishReason, truncated: statusN === 'incomplete' }
231
+ let detail = incompleteReason || failMsg || status || 'unknown'
232
+ let isArr = isarr(output)
233
+
210
234
  //function_call, 本轉接器不支援工具迴圈(見檔頭), 明確回報而不假裝成功
211
- let hasToolCall = output.some((o) => get(o, 'type', '') === 'function_call')
235
+ let hasToolCall = isArr && output.some((o) => get(o, 'type', '') === 'function_call')
212
236
  if (hasToolCall) {
213
237
  return mkResult({
214
238
  stderr: strTruncate(txt, 1000, optTruncate),
@@ -216,12 +240,48 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
216
240
  error: 'TOOL_CALLS_UNSUPPORTED: use a cli kind (opencode/claude/codex/antigravity) when tools are needed',
217
241
  errorType: 'tool-unsupported',
218
242
  usage,
243
+ ...fin,
244
+ })
245
+ }
246
+
247
+ //截斷(status為incomplete), 於output檢查與validate之前裁定: 預設失敗, 明示acceptTruncated才交validate放行(見檔頭)
248
+ if (fin.truncated) {
249
+ let partial = isArr ? extractOutputText(output) : ''
250
+ let d = judgeTruncated({
251
+ finishReason,
252
+ content: partial,
253
+ acceptTruncated,
254
+ validator,
255
+ reasoningTokens: get(usage, 'output_tokens_details.reasoning_tokens', null),
256
+ label: `status=${status} (${detail})`,
257
+ })
258
+ if (!d.accept) {
259
+ return mkResult({
260
+ stdout: strTruncate(partial, 500, optTruncate),
261
+ stderr: strTruncate((d.threw ? `validate threw: ${d.threw}\n` : '') + txt, 500, optTruncate),
262
+ code: res.status,
263
+ error: d.error,
264
+ errorType: 'incomplete',
265
+ usage,
266
+ ...fin,
267
+ })
268
+ }
269
+ return mkResult({ ok: true, stdout: partial, code: res.status, usage, ...fin })
270
+ }
271
+
272
+ if (!isArr) {
273
+ return mkResult({
274
+ stderr: strTruncate(txt, 500, optTruncate),
275
+ code: res.status,
276
+ error: 'INVALID_RESPONSE: missing output array',
277
+ errorType: 'invalid-response',
278
+ usage,
279
+ ...fin,
219
280
  })
220
281
  }
221
282
 
222
- //status非completed一律失敗: incomplete之內容為截斷品, 當成功回傳會讓半截結果流入下游
223
- if (status !== 'completed') {
224
- let detail = incompleteReason || failMsg || status || 'unknown'
283
+ //其餘非completed(failed/缺status/處理中等)屬非截斷之失敗, 不回半截內容; truncated:false故遞補層照舊換金鑰
284
+ if (statusN !== 'completed') {
225
285
  return mkResult({
226
286
  stdout: strTruncate(extractOutputText(output), 500, optTruncate),
227
287
  stderr: strTruncate(txt, 500, optTruncate),
@@ -229,6 +289,7 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
229
289
  error: `INCOMPLETE_RESPONSE: status=${status || 'missing'} (${detail})`,
230
290
  errorType: 'incomplete',
231
291
  usage,
292
+ ...fin,
232
293
  })
233
294
  }
234
295
 
@@ -241,18 +302,24 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
241
302
  error: 'INVALID_RESPONSE: no output_text in output messages',
242
303
  errorType: 'invalid-response',
243
304
  usage,
305
+ ...fin,
244
306
  })
245
307
  }
246
308
 
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
- })
309
+ //validator, error與execCli一致令dispatchAiFallback可統一分流; 拋錯視同拒絕(不reject), 訊息置stderr
310
+ if (validator) {
311
+ let v = safeValidate(validator, content)
312
+ if (!v.pass) {
313
+ return mkResult({
314
+ stdout: strTruncate(content, 500, optTruncate),
315
+ stderr: v.threw ? `validate threw: ${v.threw}` : '',
316
+ code: res.status,
317
+ error: 'OUTPUT_VALIDATION_FAILED',
318
+ errorType: 'validation',
319
+ usage,
320
+ ...fin,
321
+ })
322
+ }
256
323
  }
257
324
 
258
325
  return mkResult({
@@ -260,6 +327,7 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
260
327
  stdout: content,
261
328
  code: res.status,
262
329
  usage,
330
+ ...fin,
263
331
  })
264
332
  }
265
333
 
@@ -286,12 +354,13 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
286
354
  * @param {String} [opt.key=''] 輸入API key字串,以Bearer置於Authorization標頭,預設''代表不帶認證標頭
287
355
  * @param {String} [opt.system=''] 輸入system提示詞字串,將置於instructions欄位(Responses API之system管道),預設''代表不帶
288
356
  * @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={}] 輸入額外請求標頭物件,預設{}
357
+ * @param {Object} [opt.headers={}] 輸入額外請求標頭物件,同名鍵覆寫預設標頭;預設標頭含'Accept-Encoding: identity'(防伺服器壓縮卻漏標Content-Encoding,見檔頭),預設{}
290
358
  * @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
359
+ * @param {String|Function} [opt.validate=undefined] 輸入回覆內容驗證規則字串或自訂驗證函數,規則字串支援'nonempty'、'json'、'min:100',多規則可用逗號串接,自訂函數拋錯視同驗證失敗,預設undefined代表不驗證
360
+ * @param {Boolean} [opt.acceptTruncated=false] 輸入是否接受被截斷(status為incomplete且reason為max_output_tokens)之內容布林值,true代表交validate裁決(無validate則直接接受)且結果標truncated:true;content_filter與可見輸出為空者一律失敗,預設false代表截斷一律失敗
361
+ * @param {Number} [opt.maxRetries=0] 輸入失敗後最大重試次數非負整數,4xx(429除外)與截斷不重試,預設0
293
362
  * @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
363
+ * @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
364
  * @example
296
365
  * //need network, no cli required
297
366
  *
@@ -374,6 +443,9 @@ async function dispatchApiOpenaiResponses(prompt, opt = {}) {
374
443
  //validator
375
444
  let validator = buildValidator(get(opt, 'validate', null))
376
445
 
446
+ //acceptTruncated, 僅明示true才放行截斷內容(見檔頭)
447
+ let acceptTruncated = get(opt, 'acceptTruncated', null) === true
448
+
377
449
  //url, baseURL尾端斜線正規化後接上端點
378
450
  let url = baseURL.replace(/\/+$/, '') + '/responses'
379
451
 
@@ -384,8 +456,8 @@ async function dispatchApiOpenaiResponses(prompt, opt = {}) {
384
456
  }
385
457
  body = { ...body, ...bodyExtra }
386
458
 
387
- //headers
388
- let headers = { 'Content-Type': 'application/json', ...headersExtra }
459
+ //headers, Accept-Encoding預設identity(防伺服器壓縮卻漏標Content-Encoding, 見檔頭), headersExtra同名鍵可覆寫
460
+ let headers = { 'Content-Type': 'application/json', 'Accept-Encoding': 'identity', ...headersExtra }
389
461
  if (isestr(key)) {
390
462
  headers['Authorization'] = `Bearer ${key}`
391
463
  }
@@ -400,7 +472,7 @@ async function dispatchApiOpenaiResponses(prompt, opt = {}) {
400
472
  await delay(Math.min(retryDelayMs * attempt, MAX_RETRY_DELAY_MS))
401
473
  }
402
474
 
403
- lastResult = await callOnce(url, headers, body, timeoutMs, validator)
475
+ lastResult = await callOnce(url, headers, body, timeoutMs, validator, acceptTruncated)
404
476
  totalAttempts = attempt + 1
405
477
 
406
478
  if (lastResult.ok) {
@@ -408,6 +480,11 @@ async function dispatchApiOpenaiResponses(prompt, opt = {}) {
408
480
  return lastResult
409
481
  }
410
482
 
483
+ //不可重試: 截斷為決定性(同一請求必然再截斷, 重試只會再燒一次輸出上限)
484
+ if (lastResult.truncated === true) {
485
+ break
486
+ }
487
+
411
488
  //不可重試: 4xx(429除外)為客戶端錯誤, 重試無意義
412
489
  let c = lastResult.code
413
490
  if (isnum(c) && c >= 400 && c < 500 && c !== 429) {
@@ -11,6 +11,7 @@ import castPintOr from './castPintOr.mjs'
11
11
  import buildValidator from './buildValidator.mjs'
12
12
  import getErrorResult from './getErrorResult.mjs'
13
13
  import dfTimeoutMs from './dfTimeoutMs.mjs'
14
+ import { safeValidate } from './checkTruncation.mjs'
14
15
 
15
16
 
16
17
  // dispatchApiTypesafeSystemone.mjs — 以fetch直呼TypeSafe AI之System One API(POST /v1/systemone)
@@ -41,6 +42,9 @@ import dfTimeoutMs from './dfTimeoutMs.mjs'
41
42
  // {detail:{error_type:'api_usage_error', message:'Unknown model: X'}}; 題型不合規或questions為空422
42
43
  // {detail:[{type,loc,msg,...}]}。皆以HTTP <code>回報、原始本體置stderr; 4xx(429除外)不重試。
43
44
  //
45
+ // 【預設帶Accept-Encoding: identity(2026-09-24起)】防伺服器壓縮卻漏標Content-Encoding而令fetch不解壓、
46
+ // JSON.parse失敗; 與dispatchApiOpenaiCompat同一決策與依據(見該檔檔頭; 本kind亦經Zen之/systemone), opt.headers可覆寫。
47
+ //
44
48
  // 【混用注意】
45
49
  // 1. 答案形狀與文字模型完全不同, 不可與文字生成條目混在同一條dispatchAiFallback鏈中遞補;
46
50
  // 同一鏈只放本kind之條目(可多把金鑰輪替)。預設providers.mjs收有typesafe:jev-latest,
@@ -180,16 +184,20 @@ async function callOnce(url, headers, body, ids, timeoutMs, validator) {
180
184
  //content, answers序列化為stdout(令遞補層與工作流層之parse/check通用)
181
185
  let content = JSON.stringify(answers)
182
186
 
183
- //validator, error與execCli一致令dispatchAiFallback可統一分流
184
- if (validator && !validator(content)) {
185
- return mkResult({
186
- stdout: strTruncate(content, 500, optTruncate),
187
- code: res.status,
188
- error: 'OUTPUT_VALIDATION_FAILED',
189
- errorType: 'validation',
190
- usage,
191
- modelResolved,
192
- })
187
+ //validator, error與execCli一致令dispatchAiFallback可統一分流; 拋錯視同拒絕(不reject), 訊息置stderr
188
+ if (validator) {
189
+ let v = safeValidate(validator, content)
190
+ if (!v.pass) {
191
+ return mkResult({
192
+ stdout: strTruncate(content, 500, optTruncate),
193
+ stderr: v.threw ? `validate threw: ${v.threw}` : '',
194
+ code: res.status,
195
+ error: 'OUTPUT_VALIDATION_FAILED',
196
+ errorType: 'validation',
197
+ usage,
198
+ modelResolved,
199
+ })
200
+ }
193
201
  }
194
202
 
195
203
  return mkResult({
@@ -225,9 +233,9 @@ async function callOnce(url, headers, body, ids, timeoutMs, validator) {
225
233
  * @param {String} [opt.model='jev-latest'] 輸入模型名稱字串,預設'jev-latest'(另有'jev-preview')
226
234
  * @param {String} [opt.key=''] 輸入API key字串,以Bearer置於Authorization標頭,預設''代表不帶認證標頭
227
235
  * @param {Object} [opt.body={}] 輸入額外請求本體物件,將併入預設body(同名鍵以此為準),預設{}
228
- * @param {Object} [opt.headers={}] 輸入額外請求標頭物件,預設{}
236
+ * @param {Object} [opt.headers={}] 輸入額外請求標頭物件,同名鍵覆寫預設標頭;預設標頭含'Accept-Encoding: identity'(防伺服器壓縮卻漏標Content-Encoding,見檔頭),預設{}
229
237
  * @param {Number} [opt.timeoutMs=300000] 輸入逾時毫秒正整數,逾時將中止請求,全套件統一預設300000
230
- * @param {String|Function} [opt.validate=undefined] 輸入stdout(answers之JSON字串)驗證規則字串或自訂驗證函數,規則字串支援'nonempty'、'json'、'min:100',多規則可用逗號串接,預設undefined代表不驗證
238
+ * @param {String|Function} [opt.validate=undefined] 輸入stdout(answers之JSON字串)驗證規則字串或自訂驗證函數,規則字串支援'nonempty'、'json'、'min:100',多規則可用逗號串接,自訂函數拋錯視同驗證失敗,預設undefined代表不驗證
231
239
  * @param {Number} [opt.maxRetries=0] 輸入失敗後最大重試次數非負整數,4xx(429除外)不重試,預設0
232
240
  * @param {Number} [opt.retryDelayMs=5000] 輸入重試間隔毫秒正整數,實際間隔為retryDelayMs乘以重試次數且上限15000ms,預設5000
233
241
  * @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否成功布林值)、stdout(answers之JSON字串)、stderr(失敗時之原始回應本體)、code(HTTP狀態碼,網路錯誤與逾時為null)、error(錯誤訊息字串,成功時為空字串)、errorType(僅失敗時,機器可讀錯誤類別字串,一覽見getErrorType.mjs檔頭)、durationMs(耗時毫秒)、attempts(實際嘗試次數)、usage(原始回應之token用量物件原樣透傳,欄位名為input_tokens/output_tokens,無則null)、answers(成功時為已解析之答案物件,否則null)、modelResolved(回應所載之實際模型版本字串,如'jev-1.13.0',無則''),本函數不會reject
@@ -335,7 +343,7 @@ async function dispatchApiTypesafeSystemone(prompt, opt = {}) {
335
343
  let ids = Object.keys(isobj(body.questions) ? body.questions : questions)
336
344
 
337
345
  //headers
338
- let headers = { 'Content-Type': 'application/json', ...headersExtra }
346
+ let headers = { 'Content-Type': 'application/json', 'Accept-Encoding': 'identity', ...headersExtra } //Accept-Encoding預設identity, 見檔頭
339
347
  if (isestr(key)) {
340
348
  headers['Authorization'] = `Bearer ${key}`
341
349
  }
@@ -24,8 +24,10 @@ import isestr from 'wsemi/src/isestr.mjs'
24
24
  // 'tool-unsupported' 模型回tool_calls而本轉接器不支援工具(僅api類)
25
25
  // 'invalid-response' 回應結構不合規(缺choices[0].message.content、缺output陣列,
26
26
  // 或api-typesafe-systemone缺answers物件/缺所請求題目之答案, 僅api類)
27
- // 'incomplete' Responses API之status非completed(如max_output_tokens耗盡而截斷,
28
- // 僅api-openai-responses; 半截內容不當成功回傳, 見該轉接器檔頭)
27
+ // 'incomplete' 回應未完整: 截斷(chat/completions之finish_reason為length/content_filter、
28
+ // Responses API之status為incomplete; 結果另帶truncated:true, 規則見checkTruncation.mjs)
29
+ // 與Responses API之其餘非completed狀態(如failed; truncated:false)。僅api類;
30
+ // 半截內容預設不當成功回傳, 呼叫端明示acceptTruncated才可放行(2026-09-24起兩轉接器同規則)
29
31
  // 'aborted' shouldStop中止(僅dispatchAiFallback)
30
32
  // 'budget' 時間預算用盡(僅dispatchAiFallback)
31
33
 
package/src/providers.mjs CHANGED
@@ -398,7 +398,7 @@ let providers = [
398
398
  body: { max_tokens: 32768 },
399
399
  //2026-09-24新增: 對話型免費模型中少數REST可通者(免費層閘門未套用, 見檔頭; 例外可能隨時收回)——
400
400
  //匿名1.9s、第1把金鑰2.0s皆200。max_tokens刻意不用zen條目慣例之8192: 此為推理模型, 推理token計入max_tokens,
401
- //同日實測列10縣市一題即用5222(推理4849+正文373), 8192易截斷, 而本轉接器遇截斷仍回ok(內容殘缺或為空);
401
+ //同日實測列10縣市一題即用5222(推理4849+正文373), 8192易截斷(轉接器遇截斷預設判失敗換家, 見checkTruncation.mjs);
402
402
  //長文(README前8000字)摘要成JSON一題用3385(推理3045), 三題finish_reason皆stop, 故取32768留約6倍餘裕。
403
403
  },
404
404