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.
- package/README.md +19 -4
- package/dist/w-dispatch-ai.umd.js +2 -2
- package/dist/w-dispatch-ai.umd.js.map +1 -1
- package/docs/WDispatchAi.mjs.html +2 -2
- package/docs/adapters.mjs.html +2 -2
- package/docs/budgetFor.mjs.html +2 -2
- package/docs/buildValidator.mjs.html +2 -2
- package/docs/castPintOr.mjs.html +2 -2
- package/docs/checkTruncation.mjs.html +2 -2
- package/docs/describeNonJsonBody.mjs.html +122 -0
- package/docs/dfTimeoutMs.mjs.html +2 -2
- package/docs/dispatchAi.mjs.html +2 -2
- package/docs/dispatchAiFallback.mjs.html +13 -4
- package/docs/dispatchAiWkf.mjs.html +2 -2
- package/docs/dispatchAntigravity.mjs.html +2 -2
- package/docs/dispatchApiOpenaiCompat.mjs.html +33 -7
- package/docs/dispatchApiOpenaiResponses.mjs.html +13 -4
- package/docs/dispatchApiTypesafeSystemone.mjs.html +21 -4
- package/docs/dispatchClaude.mjs.html +2 -2
- package/docs/dispatchCodex.mjs.html +2 -2
- package/docs/dispatchOpencode.mjs.html +2 -2
- package/docs/getCliArgs.mjs.html +2 -2
- package/docs/getErrorResult.mjs.html +2 -2
- package/docs/getErrorType.mjs.html +5 -3
- package/docs/global.html +254 -20
- package/docs/index.html +2 -2
- package/docs/quota_dfQuotaTimeoutMs.mjs.html +2 -2
- package/docs/quota_fetchQuotaJson.mjs.html +2 -2
- package/docs/quota_fromCodexUsageHttp.mjs.html +2 -2
- package/docs/quota_getQuotaAntigravity.mjs.html +2 -2
- package/docs/quota_getQuotaClaude.mjs.html +2 -2
- package/docs/quota_getQuotaCodex.mjs.html +2 -2
- package/docs/quota_readJsonOrNull.mjs.html +2 -2
- package/docs/quota_toQuotaLabel.mjs.html +2 -2
- package/docs/quota_toQuotaResult.mjs.html +2 -2
- package/docs/quota_toQuotaScopedLabel.mjs.html +2 -2
- package/docs/quota_toQuotaWindow.mjs.html +2 -2
- package/docs/readEnvFile.mjs.html +2 -2
- package/docs/resolveProviders.mjs.html +2 -2
- package/docs/wkf_callAiWithFallback.mjs.html +4 -3
- package/docs/wkf_createFileStore.mjs.html +2 -2
- package/docs/wkf_createUsageCounter.mjs.html +2 -2
- package/docs/wkf_extractJsonLoose.mjs.html +2 -2
- package/docs/wkf_noSideEffectPrefix.mjs.html +2 -2
- package/docs/wkf_runFanout.mjs.html +2 -2
- package/docs/wkf_runFanoutPipeline.mjs.html +2 -2
- package/docs/wkf_runRolePipeline.mjs.html +2 -2
- package/docs/wkf_salvageTruncatedArray.mjs.html +5 -3
- package/package.json +1 -1
- package/src/describeNonJsonBody.mjs +50 -0
- package/src/dispatchAiFallback.mjs +11 -2
- package/src/dispatchApiOpenaiCompat.mjs +31 -5
- package/src/dispatchApiOpenaiResponses.mjs +11 -2
- package/src/dispatchApiTypesafeSystemone.mjs +19 -2
- package/src/getErrorType.mjs +3 -1
- package/src/wkf/callAiWithFallback.mjs +2 -1
- package/src/wkf/salvageTruncatedArray.mjs +3 -1
- package/test/tools/fakeServerForApiTest.mjs +10 -2
- package/test/unit-dispatchApiOpenaiCompat.test.mjs +41 -3
- package/test/unit-dispatchApiOpenaiResponses.test.mjs +11 -0
- 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)
|
|
47
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
package/src/getErrorType.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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可辨識救回者為半批;
|
|
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: 改回允許壓縮時,
|
|
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',
|
|
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:
|
|
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 })
|