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
@@ -3,6 +3,8 @@ import dispatchApiOpenaiCompat from '../src/dispatchApiOpenaiCompat.mjs'
3
3
  import dispatchAi from '../src/dispatchAi.mjs'
4
4
  import dispatchAiFallback from '../src/dispatchAiFallback.mjs'
5
5
  import dispatchAiWkf from '../src/dispatchAiWkf.mjs'
6
+ import extractJsonLoose from '../src/wkf/extractJsonLoose.mjs'
7
+ import salvageTruncatedArray from '../src/wkf/salvageTruncatedArray.mjs'
6
8
  import fakeServerForApiTest from './tools/fakeServerForApiTest.mjs'
7
9
 
8
10
 
@@ -161,6 +163,58 @@ describe('dispatchApiOpenaiCompat', function() {
161
163
  assert.strict.deepEqual(r, rr)
162
164
  })
163
165
 
166
+ it('預設帶Accept-Encoding: identity——伺服器壓縮卻漏標Content-Encoding時仍可正確解析(2026-09-24使用端回報)', async function() {
167
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'br-noheader' })
168
+ let r = [t.ok, t.code, t.stdout, t.error]
169
+ let rr = [true, 200, '完成', '']
170
+ assert.strict.deepEqual(r, rr)
171
+ })
172
+
173
+ it('呼叫端headers可覆寫Accept-Encoding: 改回允許壓縮時, 遇漏標之壓縮本體即解析失敗且訊息指出本體非JSON與content-encoding=none', async function() {
174
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'br-noheader', headers: { 'Accept-Encoding': 'gzip, deflate' } })
175
+ let r = [t.ok, t.code, t.errorType, t.error.indexOf('INVALID_RESPONSE: body is not JSON (') === 0, t.error.includes('content-encoding=none')]
176
+ let rr = [false, 200, 'invalid-response', true, true]
177
+ assert.strict.deepEqual(r, rr)
178
+ })
179
+
180
+ it('INVALID_RESPONSE分拆: HTTP 200但本體非JSON另報body is not JSON(附原始位元組數、前段hex、content-encoding與content-type), JSON缺content維持原訊息', async function() {
181
+ let t1 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'garbage-200' })
182
+ let t2 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'not-json' })
183
+ let t3 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'no-choices' })
184
+ let r = [
185
+ [t1.ok, t1.code, t1.errorType, t1.error],
186
+ [t2.ok, t2.errorType, t2.error],
187
+ [t3.ok, t3.errorType, t3.error],
188
+ ]
189
+ let rr = [
190
+ [false, 200, 'invalid-response', 'INVALID_RESPONSE: body is not JSON (8 bytes, first bytes 8bef0200e4ff1122, content-encoding=none, content-type=application/json)'],
191
+ [false, 'invalid-response', 'INVALID_RESPONSE: body is not JSON (15 bytes, first bytes 706c61696e207465787420626f6479, content-encoding=none, content-type=text/plain)'],
192
+ [false, 'invalid-response', 'INVALID_RESPONSE: missing choices[0].message.content'],
193
+ ]
194
+ assert.strict.deepEqual(r, rr)
195
+ })
196
+
197
+ it('本體非JSON與金鑰無關: 遞補鏈只試一把即整組跳過; JSON缺content仍逐把換金鑰', async function() {
198
+ let t1 = await dispatchAiFallback('abc', {
199
+ providers: [
200
+ { id: 'g-garbage', kind: 'api-openai-compat', baseURL: svr.url, model: 'garbage-200', keys: ['sk-g0', 'sk-g1'] },
201
+ { id: 'g-ok2', kind: 'api-openai-compat', baseURL: svr.url, model: 'echo', keys: ['sk-o0'] },
202
+ ],
203
+ })
204
+ let t2 = await dispatchAiFallback('abc', {
205
+ providers: [{ id: 'g-nochoice', kind: 'api-openai-compat', baseURL: svr.url, model: 'no-choices', keys: ['sk-n0', 'sk-n1'] }],
206
+ })
207
+ let r = [
208
+ [t1.ok, t1.providerId, t1.tried.map((x) => [x.keyId, x.outcome])],
209
+ [t2.ok, t2.tried.map((x) => [x.keyId, x.outcome])],
210
+ ]
211
+ let rr = [
212
+ [true, 'g-ok2', [['g-garbage#0', 'skip-group'], ['g-ok2#0', 'ok']]],
213
+ [false, [['g-nochoice#0', 'next-key'], ['g-nochoice#1', 'next-key']]],
214
+ ]
215
+ assert.strict.deepEqual(r, rr)
216
+ })
217
+
164
218
  it('validate字串規則與自訂函數皆可用, 失敗回傳OUTPUT_VALIDATION_FAILED', async function() {
165
219
  let t1 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'empty-content', validate: 'nonempty' })
166
220
  let t2 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'echo', validate: 'nonempty,json' })
@@ -205,7 +259,159 @@ describe('dispatchApiOpenaiCompat', function() {
205
259
  ],
206
260
  })
207
261
  let r = [t.ok, t.providerId, t.tried.map((x) => [x.keyId, x.outcome])]
208
- let rr = [true, 'g-text', [['g-tool#0', 'next-key'], ['g-tool#1', 'next-key'], ['g-text#0', 'ok']]]
262
+ let rr = [true, 'g-text', [['g-tool#0', 'skip-group'], ['g-text#0', 'ok']]]
263
+ assert.strict.deepEqual(r, rr)
264
+ })
265
+
266
+ it('截斷預設失敗: finish_reason為length且可見輸出為空(空字串/null/純空白)一律incomplete, 訊息標明無可見輸出並附reasoning_tokens', async function() {
267
+ let r = []
268
+ for (let model of ['trunc-empty', 'trunc-null', 'trunc-space']) {
269
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model })
270
+ r.push([t.ok, t.errorType, t.truncated, t.finishReason, t.error.indexOf('INCOMPLETE_RESPONSE: finish_reason=length') === 0, t.error.includes('no visible output')])
271
+ }
272
+ let t0 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'trunc-empty' })
273
+ r.push(t0.error.includes('reasoning_tokens=600'))
274
+ let rr = [
275
+ [false, 'incomplete', true, 'length', true, true],
276
+ [false, 'incomplete', true, 'length', true, true],
277
+ [false, 'incomplete', true, 'length', true, true],
278
+ true,
279
+ ]
280
+ assert.strict.deepEqual(r, rr)
281
+ })
282
+
283
+ it('截斷預設失敗且與validate無關: 截斷陣列/截斷文字/完整載荷加截尾 × {無, nonempty, json, 搶救函數}皆incomplete', async function() {
284
+ let salvage = (s) => salvageTruncatedArray(s) !== null
285
+ let r = []
286
+ for (let model of ['trunc-array', 'trunc-text', 'trunc-tail']) {
287
+ for (let validate of [undefined, 'nonempty', 'json', salvage]) {
288
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model, validate })
289
+ r.push([model, t.ok, t.errorType, t.truncated])
290
+ }
291
+ }
292
+ let rr = []
293
+ for (let model of ['trunc-array', 'trunc-text', 'trunc-tail']) {
294
+ for (let i = 0; i < 4; i++) {
295
+ rr.push([model, false, 'incomplete', true])
296
+ }
297
+ }
298
+ assert.strict.deepEqual(r, rr)
299
+ })
300
+
301
+ it('acceptTruncated:true才放行: length且內容非空交validate(通過ok帶truncated、不過incomplete、無validate亦ok); 空內容與content_filter仍失敗', async function() {
302
+ let salvage = (s) => salvageTruncatedArray(s) !== null
303
+ let a1 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'trunc-array', acceptTruncated: true, validate: salvage })
304
+ let a2 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'trunc-text', acceptTruncated: true, validate: 'json' })
305
+ let a3 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'trunc-text', acceptTruncated: true })
306
+ let a4 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'trunc-empty', acceptTruncated: true })
307
+ let a5 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'filtered', acceptTruncated: true })
308
+ let r = [
309
+ [a1.ok, a1.stdout, a1.truncated, a1.finishReason],
310
+ [a2.ok, a2.errorType, a2.truncated, a2.error.includes('rejected by validate')],
311
+ [a3.ok, a3.stdout, a3.truncated],
312
+ [a4.ok, a4.errorType],
313
+ [a5.ok, a5.errorType, a5.truncated, a5.finishReason],
314
+ ]
315
+ let rr = [
316
+ [true, '[{"a":1},{"b":2},{"c":', true, 'length'],
317
+ [false, 'incomplete', true, true],
318
+ [true, '第一段說明,第二', true],
319
+ [false, 'incomplete'],
320
+ [false, 'incomplete', true, 'content_filter'],
321
+ ]
322
+ assert.strict.deepEqual(r, rr)
323
+ })
324
+
325
+ it('防誤殺: finish_reason為stop、null、未知值時照常成功且truncated為false; 截斷判定不分大小寫', async function() {
326
+ let r = []
327
+ for (let model of ['finish-stop', 'finish-null', 'finish-other']) {
328
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model })
329
+ r.push([model, t.ok, t.stdout, t.truncated, t.finishReason])
330
+ }
331
+ let tu = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'trunc-upper' })
332
+ r.push(['trunc-upper', tu.ok, tu.errorType, tu.finishReason])
333
+ let rr = [
334
+ ['finish-stop', true, '完成', false, 'stop'],
335
+ ['finish-null', true, '完成', false, ''],
336
+ ['finish-other', true, '完成', false, 'eos'],
337
+ ['trunc-upper', false, 'incomplete', 'length'],
338
+ ]
339
+ assert.strict.deepEqual(r, rr)
340
+ })
341
+
342
+ it('截斷與金鑰無關: 遞補鏈只試一把即整組跳過且tried帶truncated; 轉接器重試遇截斷不重打', async function() {
343
+ let t = await dispatchAiFallback('abc', {
344
+ providers: [
345
+ { id: 'g-trunc', kind: 'api-openai-compat', baseURL: svr.url, model: 'trunc-text', keys: ['sk-a0', 'sk-a1'] },
346
+ { id: 'g-ok', kind: 'api-openai-compat', baseURL: svr.url, model: 'echo', keys: ['sk-b0'] },
347
+ ],
348
+ })
349
+ let t2 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'trunc-text', maxRetries: 2, retryDelayMs: 10 })
350
+ let r = [t.ok, t.providerId, t.tried.map((x) => [x.keyId, x.outcome, x.truncated === true]), t2.attempts]
351
+ let rr = [true, 'g-ok', [['g-trunc#0', 'skip-group', true], ['g-ok#0', 'ok', false]], 1]
352
+ assert.strict.deepEqual(r, rr)
353
+ })
354
+
355
+ it('validate拋錯視同驗證失敗, Promise照常resolve不reject', async function() {
356
+ let boom = () => {
357
+ throw new Error('boom-validate')
358
+ }
359
+ let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'echo', validate: boom })
360
+ let r = [t.ok, t.errorType, t.error, t.stderr.includes('boom-validate')]
361
+ let rr = [false, 'validation', 'OUTPUT_VALIDATION_FAILED', true]
362
+ assert.strict.deepEqual(r, rr)
363
+ })
364
+
365
+ it('工作流: 自訂搶救parse遇截斷即視為同意→ok且json為前段、truncated可見; 預設parse、rawText、明示acceptTruncated:false遇截斷→失敗', async function() {
366
+ let wkf = dispatchAiWkf({
367
+ providers: {
368
+ 'p-trunc': { kind: 'api-openai-compat', baseURL: svr.url, model: 'trunc-array', keys: ['sk-w1'] },
369
+ 'p-trunc-text': { kind: 'api-openai-compat', baseURL: svr.url, model: 'trunc-text', keys: ['sk-w2'] },
370
+ },
371
+ defaults: { promptPrefix: '' },
372
+ })
373
+ let parseSalvage = (s) => extractJsonLoose(s) || salvageTruncatedArray(s)
374
+ let t1 = await wkf.callAi('abc', { spec: { use: 'p-trunc' }, parse: parseSalvage })
375
+ let t2 = await wkf.callAi('abc', { spec: { use: 'p-trunc' } })
376
+ let t3 = await wkf.callAi('abc', { spec: { use: 'p-trunc-text' }, rawText: true })
377
+ let t4 = await wkf.callAi('abc', { spec: { use: 'p-trunc' }, parse: parseSalvage, acceptTruncated: false })
378
+ let r = [
379
+ [t1.ok, t1.json, t1.truncated, t1.finishReason],
380
+ [t2.ok, t2.errorType, t2.truncated, t2.tried.map((x) => x.outcome)],
381
+ [t3.ok, t3.errorType],
382
+ [t4.ok, t4.errorType],
383
+ ]
384
+ let rr = [
385
+ [true, [{ a: 1 }, { b: 2 }], true, 'length'],
386
+ [false, 'incomplete', true, ['skip-group']],
387
+ [false, 'incomplete'],
388
+ [false, 'incomplete'],
389
+ ]
390
+ assert.strict.deepEqual(r, rr)
391
+ })
392
+
393
+ it('工作流: 條目自帶validate不再繞過工作流驗證(兩者取交集), 不過即遞補下一家', async function() {
394
+ let wkf = dispatchAiWkf({
395
+ providers: {
396
+ 'p-plain': { kind: 'api-openai-compat', baseURL: svr.url, model: 'plain-content', keys: ['sk-v1'], validate: 'nonempty' },
397
+ 'p-echo': { kind: 'api-openai-compat', baseURL: svr.url, model: 'echo', keys: ['sk-v2'] },
398
+ },
399
+ defaults: { promptPrefix: '' },
400
+ })
401
+ let t = await wkf.callAi('abc', { spec: { use: 'p-plain', fallback: ['p-echo'] } })
402
+ let r = [t.ok, t.providerId, t.tried.map((x) => [x.providerId, x.outcome, x.error || null])]
403
+ let rr = [true, 'p-echo', [['p-plain', 'skip-group', 'OUTPUT_VALIDATION_FAILED'], ['p-echo', 'ok', null]]]
404
+ assert.strict.deepEqual(r, rr)
405
+ })
406
+
407
+ it('工作流: check拋錯視同驗證失敗, callAi照常resolve不reject', async function() {
408
+ let wkf = dispatchAiWkf({
409
+ providers: { 'p-e1': { kind: 'api-openai-compat', baseURL: svr.url, model: 'echo', keys: ['sk-c1'] } },
410
+ defaults: { promptPrefix: '' },
411
+ })
412
+ let t = await wkf.callAi('abc', { spec: { use: 'p-e1' }, check: (j) => j.nope.deep === 1 })
413
+ let r = [t.ok, t.errorType, t.error]
414
+ let rr = [false, 'validation', 'OUTPUT_VALIDATION_FAILED']
209
415
  assert.strict.deepEqual(r, rr)
210
416
  })
211
417
 
@@ -3,6 +3,7 @@ import dispatchApiOpenaiResponses, { extractOutputText } from '../src/dispatchAp
3
3
  import dispatchAi from '../src/dispatchAi.mjs'
4
4
  import dispatchAiFallback from '../src/dispatchAiFallback.mjs'
5
5
  import dispatchAiWkf from '../src/dispatchAiWkf.mjs'
6
+ import salvageTruncatedArray from '../src/wkf/salvageTruncatedArray.mjs'
6
7
  import fakeServerForApiTest from './tools/fakeServerForApiTest.mjs'
7
8
 
8
9
 
@@ -77,20 +78,88 @@ describe('dispatchApiOpenaiResponses', function() {
77
78
  assert.strict.deepEqual(r, rr)
78
79
  })
79
80
 
80
- it('status非completed一律失敗不回半截內容: incomplete與failed', async function() {
81
+ it('預設帶Accept-Encoding: identity——伺服器壓縮卻漏標Content-Encoding時仍可正確解析(同dispatchApiOpenaiCompat)', async function() {
82
+ let t = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'br-noheader' })
83
+ let r = [t.ok, t.code, t.stdout, t.error]
84
+ let rr = [true, 200, '完成', '']
85
+ assert.strict.deepEqual(r, rr)
86
+ })
87
+
88
+ it('status非completed預設失敗不回半截內容: incomplete(截斷, 無可見輸出另附說明)與failed(非截斷)', async function() {
81
89
  let t1 = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'incomplete' })
82
90
  let t2 = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'failed' })
83
91
  let r = [
84
- [t1.ok, t1.errorType, t1.error],
85
- [t2.ok, t2.errorType, t2.error],
92
+ [t1.ok, t1.errorType, t1.error, t1.truncated, t1.finishReason],
93
+ [t2.ok, t2.errorType, t2.error, t2.truncated],
86
94
  ]
87
95
  let rr = [
88
- [false, 'incomplete', 'INCOMPLETE_RESPONSE: status=incomplete (max_output_tokens)'],
89
- [false, 'incomplete', 'INCOMPLETE_RESPONSE: status=failed (upstream blew up)'],
96
+ [false, 'incomplete', 'INCOMPLETE_RESPONSE: status=incomplete (max_output_tokens); no visible output (reasoning may have used up the output token limit)', true, 'length'],
97
+ [false, 'incomplete', 'INCOMPLETE_RESPONSE: status=failed (upstream blew up)', false],
90
98
  ]
91
99
  assert.strict.deepEqual(r, rr)
92
100
  })
93
101
 
102
+ it('截斷(incomplete)帶部分文字預設失敗; acceptTruncated:true且validate通過則ok帶truncated; content_filter一律失敗; 重試遇截斷不重打', async function() {
103
+ let salvage = (s) => salvageTruncatedArray(s) !== null
104
+ let t1 = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'incomplete-partial' })
105
+ let t2 = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'incomplete-partial', acceptTruncated: true, validate: salvage })
106
+ let t3 = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'incomplete-filter', acceptTruncated: true })
107
+ let t4 = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'incomplete-partial', maxRetries: 2, retryDelayMs: 10 })
108
+ let r = [
109
+ [t1.ok, t1.errorType, t1.truncated, t1.finishReason, t1.error],
110
+ [t2.ok, t2.stdout, t2.truncated, t2.finishReason],
111
+ [t3.ok, t3.errorType, t3.truncated, t3.finishReason],
112
+ t4.attempts,
113
+ ]
114
+ let rr = [
115
+ [false, 'incomplete', true, 'length', 'INCOMPLETE_RESPONSE: status=incomplete (max_output_tokens)'],
116
+ [true, '[{"a":1},{"b":2},{"c":', true, 'length'],
117
+ [false, 'incomplete', true, 'content_filter'],
118
+ 1,
119
+ ]
120
+ assert.strict.deepEqual(r, rr)
121
+ })
122
+
123
+ it('failed與缺status非截斷(truncated為false), 遞補鏈照舊逐把換金鑰; 完成時finishReason為stop', async function() {
124
+ let t1 = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'no-status' })
125
+ let t2 = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'echo' })
126
+ let tf = await dispatchAiFallback('abc', {
127
+ providers: [{ id: 'g-failed', kind: 'api-openai-responses', baseURL: svr.url, model: 'failed', keys: ['sk-f0', 'sk-f1'] }],
128
+ })
129
+ let r = [
130
+ [t1.ok, t1.errorType, t1.truncated, t1.error],
131
+ [t2.ok, t2.truncated, t2.finishReason],
132
+ tf.tried.map((x) => [x.keyId, x.outcome]),
133
+ ]
134
+ let rr = [
135
+ [false, 'incomplete', false, 'INCOMPLETE_RESPONSE: status=missing (unknown)'],
136
+ [true, false, 'stop'],
137
+ [['g-failed#0', 'next-key'], ['g-failed#1', 'next-key']],
138
+ ]
139
+ assert.strict.deepEqual(r, rr)
140
+ })
141
+
142
+ it('INVALID_RESPONSE分拆: HTTP 200但本體非JSON另報body is not JSON(附原始位元組資訊), JSON缺output維持原訊息', async function() {
143
+ let t1 = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'garbage-200' })
144
+ let t2 = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'no-output' })
145
+ let r = [[t1.ok, t1.errorType, t1.error], [t2.ok, t2.errorType, t2.error]]
146
+ let rr = [
147
+ [false, 'invalid-response', 'INVALID_RESPONSE: body is not JSON (8 bytes, first bytes 8bef0200e4ff1122, content-encoding=none, content-type=application/json)'],
148
+ [false, 'invalid-response', 'INVALID_RESPONSE: missing output array'],
149
+ ]
150
+ assert.strict.deepEqual(r, rr)
151
+ })
152
+
153
+ it('validate拋錯視同驗證失敗, Promise照常resolve不reject', async function() {
154
+ let boom = () => {
155
+ throw new Error('boom-validate')
156
+ }
157
+ let t = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'echo', validate: boom })
158
+ let r = [t.ok, t.errorType, t.error, t.stderr.includes('boom-validate')]
159
+ let rr = [false, 'validation', 'OUTPUT_VALIDATION_FAILED', true]
160
+ assert.strict.deepEqual(r, rr)
161
+ })
162
+
94
163
  it('結構不合規之回應: 缺output陣列、非JSON、僅reasoning無message', async function() {
95
164
  let t1 = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'no-output' })
96
165
  let t2 = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'not-json' })
@@ -58,6 +58,23 @@ describe('dispatchApiTypesafeSystemone', function() {
58
58
  assert.strict.deepEqual(r, rr)
59
59
  })
60
60
 
61
+ it('預設帶Accept-Encoding: identity——伺服器壓縮卻漏標Content-Encoding時仍可正確解析(同dispatchApiOpenaiCompat)', async function() {
62
+ let t = await dispatchApiTypesafeSystemone('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'br-noheader', questions: Q })
63
+ let r = [t.ok, t.code, Object.keys(t.answers || {}), t.error]
64
+ let rr = [true, 200, ['category', 'urgent'], '']
65
+ assert.strict.deepEqual(r, rr)
66
+ })
67
+
68
+ it('validate拋錯視同驗證失敗, Promise照常resolve不reject(同dispatchApiOpenaiCompat)', async function() {
69
+ let boom = () => {
70
+ throw new Error('boom-validate')
71
+ }
72
+ let t = await dispatchApiTypesafeSystemone('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'echo', questions: Q, validate: boom })
73
+ let r = [t.ok, t.errorType, t.error, t.stderr.includes('boom-validate')]
74
+ let rr = [false, 'validation', 'OUTPUT_VALIDATION_FAILED', true]
75
+ assert.strict.deepEqual(r, rr)
76
+ })
77
+
61
78
  it('成功: stdout為answers之JSON字串且與answers一致, 追加modelResolved, usage原樣透傳', async function() {
62
79
  let t = await dispatchApiTypesafeSystemone('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'echo', questions: Q })
63
80
  let r = [t.ok, t.error, JSON.parse(t.stdout), Object.keys(t.answers), t.modelResolved, t.usage]
@@ -77,11 +94,18 @@ describe('dispatchApiTypesafeSystemone', function() {
77
94
  let rr = [
78
95
  [false, 'invalid-response', 'INVALID_RESPONSE: missing answers for [urgent]', null, '', 'jev-1.13.0', { input_tokens: 7, output_tokens: 3 }],
79
96
  [false, 'invalid-response', 'INVALID_RESPONSE: missing answers object'],
80
- [false, 'invalid-response', 'INVALID_RESPONSE: missing answers object'],
97
+ [false, 'invalid-response', 'INVALID_RESPONSE: body is not JSON (15 bytes, first bytes 706c61696e207465787420626f6479, content-encoding=none, content-type=text/plain)'],
81
98
  ]
82
99
  assert.strict.deepEqual(r, rr)
83
100
  })
84
101
 
102
+ it('HTTP 200但本體非JSON另報body is not JSON(附原始位元組資訊, 同dispatchApiOpenaiCompat)', async function() {
103
+ let t = await dispatchApiTypesafeSystemone('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'garbage-200', questions: Q })
104
+ let r = [t.ok, t.errorType, t.error]
105
+ let rr = [false, 'invalid-response', 'INVALID_RESPONSE: body is not JSON (8 bytes, first bytes 8bef0200e4ff1122, content-encoding=none, content-type=application/json)']
106
+ assert.strict.deepEqual(r, rr)
107
+ })
108
+
85
109
  it('壞金鑰401、未知model 400、題型不合規422皆為http且4xx不重試, 原始本體置stderr', async function() {
86
110
  let t1 = await dispatchApiTypesafeSystemone('abc', { baseURL: svr.url, key: 'sk-bad', model: 'echo', questions: Q, maxRetries: 3 })
87
111
  let t2 = await dispatchApiTypesafeSystemone('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'jev-nope', questions: Q, maxRetries: 3 })