w-dispatch-ai 1.0.37 → 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 (61) hide show
  1. package/README.md +19 -4
  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 +2 -2
  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 +13 -4
  14. package/docs/dispatchAiWkf.mjs.html +2 -2
  15. package/docs/dispatchAntigravity.mjs.html +2 -2
  16. package/docs/dispatchApiOpenaiCompat.mjs.html +33 -7
  17. package/docs/dispatchApiOpenaiResponses.mjs.html +13 -4
  18. package/docs/dispatchApiTypesafeSystemone.mjs.html +21 -4
  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 +5 -3
  25. package/docs/global.html +254 -20
  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 +4 -3
  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 +2 -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 +5 -3
  49. package/package.json +1 -1
  50. package/src/describeNonJsonBody.mjs +50 -0
  51. package/src/dispatchAiFallback.mjs +11 -2
  52. package/src/dispatchApiOpenaiCompat.mjs +31 -5
  53. package/src/dispatchApiOpenaiResponses.mjs +11 -2
  54. package/src/dispatchApiTypesafeSystemone.mjs +19 -2
  55. package/src/getErrorType.mjs +3 -1
  56. package/src/wkf/callAiWithFallback.mjs +2 -1
  57. package/src/wkf/salvageTruncatedArray.mjs +3 -1
  58. package/test/tools/fakeServerForApiTest.mjs +10 -2
  59. package/test/unit-dispatchApiOpenaiCompat.test.mjs +41 -3
  60. package/test/unit-dispatchApiOpenaiResponses.test.mjs +11 -0
  61. package/test/unit-dispatchApiTypesafeSystemone.test.mjs +8 -1
@@ -11,6 +11,7 @@ import strTruncate from 'wsemi/src/strTruncate.mjs'
11
11
  import getErrorResult from './getErrorResult.mjs'
12
12
  import dfTimeoutMs from './dfTimeoutMs.mjs'
13
13
  import { TRUNCATION_REASONS, normalizeFinishReason, safeValidate, judgeTruncated } from './checkTruncation.mjs'
14
+ import describeNonJsonBody from './describeNonJsonBody.mjs'
14
15
 
15
16
 
16
17
  // dispatchApiOpenaiCompat.mjs — 以fetch直呼OpenAI相容API(chat/completions)
@@ -43,10 +44,17 @@ import { TRUNCATION_REASONS, normalizeFinishReason, safeValidate, judgeTruncated
43
44
  // 【預設帶Accept-Encoding: identity(2026-09-24起, 三個REST轉接器同步)】Node內建fetch(undici)只在回應
44
45
  // 帶Content-Encoding時才自動解壓; 伺服器若壓縮了本體卻漏標此標頭, fetch原樣交出壓縮位元組,
45
46
  // JSON.parse失敗而回INVALID_RESPONSE(使用端回報Zen之space-bunny-free即此症: 手動brotli解壓得完整答案,
46
- // 改帶identity即得正常JSON)。本機以假伺服器重現該機制; 惟同日對Zen取樣7次皆正確標示br(未重現漏標),
47
- // 故屬防禦: identity請伺服器勿壓縮, 從源頭消除「壓縮處理不一致」一類失敗(不論成因在伺服器、代理或執行環境)。
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請伺服器勿壓縮, 從源頭消除「壓縮處理不一致」一類失敗(不論成因在伺服器、代理或執行環境),
48
52
  // 代價僅傳輸量變大(實測Zen回應約5~8KB→20~65KB)。呼叫端可以opt.headers之'Accept-Encoding'覆寫。
49
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
+ //
50
58
  // 【截斷(finish_reason為length或content_filter)預設失敗(2026-09-24起, 規則單一來源見checkTruncation.mjs)】
51
59
  // 舊版不看finish_reason, 實測Zen之space-bunny-free於max_tokens:600時推理即耗盡而回content:""、
52
60
  // 本轉接器卻回ok:true(靜默成功)。現於null檢查與validate之前裁定: 預設回INCOMPLETE_RESPONSE
@@ -65,7 +73,7 @@ import { TRUNCATION_REASONS, normalizeFinishReason, safeValidate, judgeTruncated
65
73
  // incomplete/validation/params, 一覽見getErrorType.mjs檔頭), error字串保留不動, 兩者並存。
66
74
  // stdout為回覆內容、code為HTTP狀態碼(網路錯誤與逾時為null)、逾時error以TIMEOUT開頭、
67
75
  // 驗證失敗error為OUTPUT_VALIDATION_FAILED(validate拋錯亦同, 拋錯訊息置stderr)——
68
- // dispatchAiFallback據此分流: 逾時/驗證失敗/截斷/工具不支援整組跳過, 其餘換金鑰。
76
+ // dispatchAiFallback據此分流: 逾時/驗證失敗/截斷/工具不支援/本體非JSON整組跳過, 其餘換金鑰。
69
77
 
70
78
 
71
79
  //預設值
@@ -114,7 +122,9 @@ async function callOnce(url, headers, body, timeoutMs, validator, acceptTruncate
114
122
  controller.abort()
115
123
  }, timeoutMs)
116
124
 
125
+ //本體先取原始位元組再以UTF-8解碼(等同res.text()), 本體非JSON時才有原始位元組可供診斷(見describeNonJsonBody.mjs)
117
126
  let res = null
127
+ let raw = new Uint8Array(0)
118
128
  let txt = ''
119
129
  try {
120
130
  res = await fetch(url, {
@@ -123,7 +133,8 @@ async function callOnce(url, headers, body, timeoutMs, validator, acceptTruncate
123
133
  body: JSON.stringify(body),
124
134
  signal: controller.signal,
125
135
  })
126
- txt = await res.text()
136
+ raw = new Uint8Array(await res.arrayBuffer())
137
+ txt = new TextDecoder('utf-8').decode(raw)
127
138
  }
128
139
  catch (err) {
129
140
  clearTimeout(timer)
@@ -154,6 +165,7 @@ async function callOnce(url, headers, body, timeoutMs, validator, acceptTruncate
154
165
  let finishReasonRaw = null
155
166
  let toolCalls = null
156
167
  let usage = null
168
+ let parsed = true
157
169
  try {
158
170
  let j = JSON.parse(txt)
159
171
  content = get(j, 'choices.0.message.content', null)
@@ -164,7 +176,21 @@ async function callOnce(url, headers, body, timeoutMs, validator, acceptTruncate
164
176
  usage = null
165
177
  }
166
178
  }
167
- 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
+ }
168
194
 
169
195
  //fin, 自此起之每個結果皆外顯正規化終止原因與是否截斷(規則見checkTruncation.mjs)
170
196
  let finishReason = normalizeFinishReason(finishReasonRaw)
@@ -12,6 +12,7 @@ import strTruncate from 'wsemi/src/strTruncate.mjs'
12
12
  import getErrorResult from './getErrorResult.mjs'
13
13
  import dfTimeoutMs from './dfTimeoutMs.mjs'
14
14
  import { normalizeFinishReason, safeValidate, judgeTruncated } from './checkTruncation.mjs'
15
+ import describeNonJsonBody from './describeNonJsonBody.mjs'
15
16
 
16
17
 
17
18
  // dispatchApiOpenaiResponses.mjs — 以fetch直呼OpenAI Responses API(/responses)
@@ -50,6 +51,8 @@ import { normalizeFinishReason, safeValidate, judgeTruncated } from './checkTrun
50
51
  //
51
52
  // 【預設帶Accept-Encoding: identity(2026-09-24起)】防伺服器壓縮卻漏標Content-Encoding而令fetch不解壓、
52
53
  // JSON.parse失敗; 與dispatchApiOpenaiCompat同一決策與依據(見該檔檔頭), opt.headers可覆寫。
54
+ // HTTP 200但本體非JSON時另報INVALID_RESPONSE: body is not JSON(附原始位元組資訊), 與「缺output陣列」分開
55
+ // (同dispatchApiOpenaiCompat, 見describeNonJsonBody.mjs)。
53
56
  //
54
57
  // 【錯誤碼實測(Zen)】壞金鑰401(AuthError); 未知model亦回401(ModelError: Model X is not
55
58
  // supported)而非404——故不可用狀態碼區分「金鑰錯」與「模型名錯」, 須讀stderr之訊息。
@@ -149,7 +152,9 @@ async function callOnce(url, headers, body, timeoutMs, validator, acceptTruncate
149
152
  controller.abort()
150
153
  }, timeoutMs)
151
154
 
155
+ //本體先取原始位元組再以UTF-8解碼(等同res.text()), 本體非JSON時才有原始位元組可供診斷(見describeNonJsonBody.mjs)
152
156
  let res = null
157
+ let raw = new Uint8Array(0)
153
158
  let txt = ''
154
159
  try {
155
160
  res = await fetch(url, {
@@ -158,7 +163,8 @@ async function callOnce(url, headers, body, timeoutMs, validator, acceptTruncate
158
163
  body: JSON.stringify(body),
159
164
  signal: controller.signal,
160
165
  })
161
- txt = await res.text()
166
+ raw = new Uint8Array(await res.arrayBuffer())
167
+ txt = new TextDecoder('utf-8').decode(raw)
162
168
  }
163
169
  catch (err) {
164
170
  clearTimeout(timer)
@@ -206,13 +212,16 @@ async function callOnce(url, headers, body, timeoutMs, validator, acceptTruncate
206
212
  catch {
207
213
  parsed = false
208
214
  }
215
+ //本體非JSON, 與「JSON缺output」分開回報並附原始位元組資訊; 遞補層據前綴整組跳過(見describeNonJsonBody.mjs)
209
216
  if (!parsed) {
210
217
  return mkResult({
211
218
  stderr: strTruncate(txt, 500, optTruncate),
212
219
  code: res.status,
213
- error: 'INVALID_RESPONSE: missing output array',
220
+ error: describeNonJsonBody(raw, res.headers),
214
221
  errorType: 'invalid-response',
215
222
  usage,
223
+ finishReason: '',
224
+ truncated: false,
216
225
  })
217
226
  }
218
227
 
@@ -12,6 +12,7 @@ import buildValidator from './buildValidator.mjs'
12
12
  import getErrorResult from './getErrorResult.mjs'
13
13
  import dfTimeoutMs from './dfTimeoutMs.mjs'
14
14
  import { safeValidate } from './checkTruncation.mjs'
15
+ import describeNonJsonBody from './describeNonJsonBody.mjs'
15
16
 
16
17
 
17
18
  // dispatchApiTypesafeSystemone.mjs — 以fetch直呼TypeSafe AI之System One API(POST /v1/systemone)
@@ -44,6 +45,8 @@ import { safeValidate } from './checkTruncation.mjs'
44
45
  //
45
46
  // 【預設帶Accept-Encoding: identity(2026-09-24起)】防伺服器壓縮卻漏標Content-Encoding而令fetch不解壓、
46
47
  // JSON.parse失敗; 與dispatchApiOpenaiCompat同一決策與依據(見該檔檔頭; 本kind亦經Zen之/systemone), opt.headers可覆寫。
48
+ // HTTP 200但本體非JSON時另報INVALID_RESPONSE: body is not JSON(附原始位元組資訊), 與「缺answers」分開
49
+ // (同dispatchApiOpenaiCompat, 見describeNonJsonBody.mjs)。
47
50
  //
48
51
  // 【混用注意】
49
52
  // 1. 答案形狀與文字模型完全不同, 不可與文字生成條目混在同一條dispatchAiFallback鏈中遞補;
@@ -103,7 +106,9 @@ async function callOnce(url, headers, body, ids, timeoutMs, validator) {
103
106
  controller.abort()
104
107
  }, timeoutMs)
105
108
 
109
+ //本體先取原始位元組再以UTF-8解碼(等同res.text()), 本體非JSON時才有原始位元組可供診斷(見describeNonJsonBody.mjs)
106
110
  let res = null
111
+ let raw = new Uint8Array(0)
107
112
  let txt = ''
108
113
  try {
109
114
  res = await fetch(url, {
@@ -112,7 +117,8 @@ async function callOnce(url, headers, body, ids, timeoutMs, validator) {
112
117
  body: JSON.stringify(body),
113
118
  signal: controller.signal,
114
119
  })
115
- txt = await res.text()
120
+ raw = new Uint8Array(await res.arrayBuffer())
121
+ txt = new TextDecoder('utf-8').decode(raw)
116
122
  }
117
123
  catch (err) {
118
124
  clearTimeout(timer)
@@ -142,6 +148,7 @@ async function callOnce(url, headers, body, ids, timeoutMs, validator) {
142
148
  let answers = null
143
149
  let modelResolved = ''
144
150
  let usage = null
151
+ let parsed = true
145
152
  try {
146
153
  let j = JSON.parse(txt)
147
154
  answers = get(j, 'answers', null)
@@ -155,7 +162,17 @@ async function callOnce(url, headers, body, ids, timeoutMs, validator) {
155
162
  }
156
163
  }
157
164
  catch {
158
- answers = null
165
+ parsed = false
166
+ }
167
+
168
+ //本體非JSON, 與「JSON缺answers」分開回報並附原始位元組資訊; 遞補層據前綴整組跳過(見describeNonJsonBody.mjs)
169
+ if (!parsed) {
170
+ return mkResult({
171
+ stderr: strTruncate(txt, 500, optTruncate),
172
+ code: res.status,
173
+ error: describeNonJsonBody(raw, res.headers),
174
+ errorType: 'invalid-response',
175
+ })
159
176
  }
160
177
  if (!isobj(answers)) {
161
178
  return mkResult({
@@ -23,7 +23,9 @@ import isestr from 'wsemi/src/isestr.mjs'
23
23
  // 'fetch' 網路層錯誤(DNS/連線拒絕, 僅api類)
24
24
  // 'tool-unsupported' 模型回tool_calls而本轉接器不支援工具(僅api類)
25
25
  // 'invalid-response' 回應結構不合規(缺choices[0].message.content、缺output陣列,
26
- // 或api-typesafe-systemone缺answers物件/缺所請求題目之答案, 僅api類)
26
+ // 或api-typesafe-systemone缺answers物件/缺所請求題目之答案, 僅api類);
27
+ // HTTP 200但本體非JSON時error另為'INVALID_RESPONSE: body is not JSON (...)'(附位元組資訊,
28
+ // 遞補層整組跳過; 見describeNonJsonBody.mjs), 「JSON缺欄位」則仍換金鑰
27
29
  // 'incomplete' 回應未完整: 截斷(chat/completions之finish_reason為length/content_filter、
28
30
  // Responses API之status為incomplete; 結果另帶truncated:true, 規則見checkTruncation.mjs)
29
31
  // 與Responses API之其餘非completed狀態(如failed; truncated:false)。僅api類;
@@ -32,7 +32,8 @@ import NO_SIDE_EFFECT from './noSideEffectPrefix.mjs'
32
32
  // 【截斷(2026-09-24起)】REST文字類轉接器於validate之前判定截斷, 預設回INCOMPLETE_RESPONSE(errorType
33
33
  // incomplete, 遞補層整組跳過且不重試), 不再經驗證失敗路徑。本層之acceptTruncated預設為「有自訂parse且非
34
34
  // rawText」: README明文之策略②(組成自訂parse注入搶救截斷前段, 見salvageTruncatedArray.mjs)本身即同意訊號,
35
- // 既有使用者不必改任何東西; 預設parse(extractJsonLoose)與rawText遇截斷一律判失敗換家。可顯式覆寫。
35
+ // 本層之既有使用者不必改任何東西; 預設parse(extractJsonLoose)與rawText遇截斷一律判失敗換家。可顯式覆寫。
36
+ // 注意此自動同意只在本層: 直接呼叫dispatchAiFallback或轉接器、把搶救寫在validate裡者須自給acceptTruncated:true。
36
37
  // CLI類轉接器拿不到終止訊號, 截斷不可判(已知限制)。
37
38
  //
38
39
  // 【防寫檔前綴】agentic CLI之cwd不是隔離邊界(可用絕對路徑寫到cwd外),
@@ -16,7 +16,9 @@
16
16
  //
17
17
  // 【與REST截斷判定之銜接(2026-09-24起)】REST文字類轉接器於validate之前判定截斷且預設失敗;
18
18
  // 工作流callAi之acceptTruncated預設為「有自訂parse且非rawText」, 故以本工具組成自訂parse注入即同意
19
- // 接受截斷內容(既有用法不必改), 結果之truncated:true可辨識救回者為半批; 直接呼叫轉接器須自給acceptTruncated:true。
19
+ // 接受截斷內容(既有用法不必改), 結果之truncated:true可辨識救回者為半批; 直接呼叫dispatchAiFallback或轉接器、
20
+ // 且把本工具寫在validate裡者須自給acceptTruncated:true(1.0.37起, 否則截斷在validate之前即判失敗;
21
+ // 安裝方實例: w-knowledge-extract 1.0.1之callJson因此失去搶救, 2026-09-24回報)。
20
22
  // CLI類拿不到終止訊號, 其截斷只能靠本工具於解析時發現。
21
23
  //
22
24
  // 【已知限制(2026-09-24複審指出, 讀碼確認)】只記錄頂層「物件」元素之結束(`}`), 故元素非物件之陣列
@@ -24,6 +24,7 @@ import zlib from 'zlib'
24
24
  // br-noheader — 200, 請求之Accept-Encoding恰為identity才回原文; 否則回brotli壓縮本體且刻意不帶
25
25
  // Content-Encoding(模擬2026-09-24使用端回報之伺服器: 壓縮卻漏標, Node fetch因而不解壓)
26
26
  // TRUNC_ROUTES — 200, 依表回指定之finish_reason與content(截斷處理之規格測試用, 見下方常數)
27
+ // garbage-200 — 200, Content-Type為application/json但本體為8個非JSON位元組(模擬壓縮或損壞本體)
27
28
  // 其他 — 404
28
29
  // 【responses之行為路由(依body.model)】
29
30
  // echo — 200, message之output_text為JSON字串{ auth, body }
@@ -37,14 +38,14 @@ import zlib from 'zlib'
37
38
  // incomplete-partial — 200, status為incomplete(max_output_tokens)但已有部分文字(可搶救之截斷陣列)
38
39
  // incomplete-filter — 200, status為incomplete(content_filter)且有部分文字
39
40
  // no-status — 200, 缺status欄但有文字(非截斷之不合規狀態)
40
- // not-json/slow/err-500/flaky-429/br-noheader — 同chat/completions之對應行為
41
+ // not-json/slow/err-500/flaky-429/br-noheader/garbage-200 — 同chat/completions之對應行為
41
42
  // 其他 — 401(同Zen實測: 未知model回401非404)
42
43
  // 【systemone之行為路由(POST /v1/systemone, 依body.model; 形狀取自2026-09-17 TypeSafe實測)】
43
44
  // echo / jev-latest — 200, 每題回{type:'noul', noul:0.5, echoAuth, echoBody}, 供斷言請求組成
44
45
  // missing-answer — 200但answers缺最後一題
45
46
  // no-answers — 200但無answers
46
47
  // bad-question — 422 {detail:[{type:'union_tag_invalid',...}]}
47
- // not-json/slow/err-500/flaky-429/br-noheader — 同chat/completions之對應行為
48
+ // not-json/slow/err-500/flaky-429/br-noheader/garbage-200 — 同chat/completions之對應行為
48
49
  // 其他 — 400 {detail:{error_type:'api_usage_error', message:'Unknown model: X'}}
49
50
  // 【金鑰規則】Authorization含'sk-bad'一律401(優先於model路由), 模擬無效金鑰;
50
51
  // systemone路由之401本體採TypeSafe形狀{detail:{error_type:'authentication_error'}}。
@@ -117,6 +118,13 @@ async function fakeServerForApiTest() {
117
118
  return
118
119
  }
119
120
 
121
+ //garbage-200, 三種端點共用: 200且宣稱JSON, 本體卻是8個非JSON位元組(不論Accept-Encoding)
122
+ if (body.model === 'garbage-200') {
123
+ res.writeHead(200, { 'Content-Type': 'application/json' })
124
+ res.end(Buffer.from([0x8b, 0xef, 0x02, 0x00, 0xe4, 0xff, 0x11, 0x22]))
125
+ return
126
+ }
127
+
120
128
  //無效金鑰, 模擬Zen之401形態(systemone則模擬TypeSafe之形態)
121
129
  if (auth.includes('sk-bad')) {
122
130
  res.writeHead(401, { 'Content-Type': 'application/json' })
@@ -170,10 +170,48 @@ describe('dispatchApiOpenaiCompat', function() {
170
170
  assert.strict.deepEqual(r, rr)
171
171
  })
172
172
 
173
- it('呼叫端headers可覆寫Accept-Encoding: 改回允許壓縮時, 遇漏標之壓縮本體即解析失敗(invalid-response)', async function() {
173
+ it('呼叫端headers可覆寫Accept-Encoding: 改回允許壓縮時, 遇漏標之壓縮本體即解析失敗且訊息指出本體非JSON與content-encoding=none', async function() {
174
174
  let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'br-noheader', headers: { 'Accept-Encoding': 'gzip, deflate' } })
175
- let r = [t.ok, t.code, t.errorType, t.error]
176
- let rr = [false, 200, 'invalid-response', 'INVALID_RESPONSE: missing choices[0].message.content']
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
+ ]
177
215
  assert.strict.deepEqual(r, rr)
178
216
  })
179
217
 
@@ -139,6 +139,17 @@ describe('dispatchApiOpenaiResponses', function() {
139
139
  assert.strict.deepEqual(r, rr)
140
140
  })
141
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
+
142
153
  it('validate拋錯視同驗證失敗, Promise照常resolve不reject', async function() {
143
154
  let boom = () => {
144
155
  throw new Error('boom-validate')
@@ -94,11 +94,18 @@ describe('dispatchApiTypesafeSystemone', function() {
94
94
  let rr = [
95
95
  [false, 'invalid-response', 'INVALID_RESPONSE: missing answers for [urgent]', null, '', 'jev-1.13.0', { input_tokens: 7, output_tokens: 3 }],
96
96
  [false, 'invalid-response', 'INVALID_RESPONSE: missing answers object'],
97
- [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)'],
98
98
  ]
99
99
  assert.strict.deepEqual(r, rr)
100
100
  })
101
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
+
102
109
  it('壞金鑰401、未知model 400、題型不合規422皆為http且4xx不重試, 原始本體置stderr', async function() {
103
110
  let t1 = await dispatchApiTypesafeSystemone('abc', { baseURL: svr.url, key: 'sk-bad', model: 'echo', questions: Q, maxRetries: 3 })
104
111
  let t2 = await dispatchApiTypesafeSystemone('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'jev-nope', questions: Q, maxRetries: 3 })