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.
- package/README.md +10 -6
- 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 +199 -0
- package/docs/dfTimeoutMs.mjs.html +2 -2
- package/docs/dispatchAi.mjs.html +2 -2
- package/docs/dispatchAiFallback.mjs.html +33 -8
- package/docs/dispatchAiWkf.mjs.html +2 -2
- package/docs/dispatchAntigravity.mjs.html +2 -2
- package/docs/dispatchApiOpenaiCompat.mjs.html +96 -29
- package/docs/dispatchApiOpenaiResponses.mjs.html +102 -25
- package/docs/dispatchApiTypesafeSystemone.mjs.html +23 -15
- 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 +6 -4
- package/docs/global.html +1234 -115
- 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 +63 -20
- 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 +4 -2
- package/docs/wkf_runFanoutPipeline.mjs.html +2 -2
- package/docs/wkf_runRolePipeline.mjs.html +2 -2
- package/docs/wkf_salvageTruncatedArray.mjs.html +11 -2
- package/package.json +1 -1
- package/src/checkTruncation.mjs +127 -0
- package/src/dispatchAiFallback.mjs +31 -6
- package/src/dispatchApiOpenaiCompat.mjs +94 -27
- package/src/dispatchApiOpenaiResponses.mjs +100 -23
- package/src/dispatchApiTypesafeSystemone.mjs +21 -13
- package/src/getErrorType.mjs +4 -2
- package/src/providers.mjs +1 -1
- package/src/wkf/callAiWithFallback.mjs +61 -18
- package/src/wkf/runFanout.mjs +2 -0
- package/src/wkf/salvageTruncatedArray.mjs +9 -0
- package/test/tools/fakeServerForApiTest.mjs +79 -2
- package/test/unit-dispatchApiOpenaiCompat.test.mjs +169 -1
- package/test/unit-dispatchApiOpenaiResponses.test.mjs +63 -5
- package/test/unit-dispatchApiTypesafeSystemone.test.mjs +17 -0
|
@@ -4,8 +4,11 @@ import isearr from 'wsemi/src/isearr.mjs'
|
|
|
4
4
|
import isestr from 'wsemi/src/isestr.mjs'
|
|
5
5
|
import isfun from 'wsemi/src/isfun.mjs'
|
|
6
6
|
import isobj from 'wsemi/src/isobj.mjs'
|
|
7
|
+
import isbol from 'wsemi/src/isbol.mjs'
|
|
7
8
|
import dispatchAiFallback from '../dispatchAiFallback.mjs'
|
|
8
9
|
import dfTimeoutMs from '../dfTimeoutMs.mjs'
|
|
10
|
+
import buildValidator from '../buildValidator.mjs'
|
|
11
|
+
import { safeValidate } from '../checkTruncation.mjs'
|
|
9
12
|
import extractJsonLoose from './extractJsonLoose.mjs'
|
|
10
13
|
import NO_SIDE_EFFECT from './noSideEffectPrefix.mjs'
|
|
11
14
|
|
|
@@ -19,9 +22,18 @@ import NO_SIDE_EFFECT from './noSideEffectPrefix.mjs'
|
|
|
19
22
|
// 否則fallback名稱打錯字只會讓遞補鏈無感知地短一截, 事後無從察覺。
|
|
20
23
|
//
|
|
21
24
|
// 【JSON驗證接進遞補層】parse+check包成dispatchAiFallback的validate:
|
|
22
|
-
// 回覆非法(
|
|
25
|
+
// 回覆非法(空回、缺欄位)時為OUTPUT_VALIDATION_FAILED, 遞補層視為
|
|
23
26
|
// 與金鑰無關之失敗而「整組跳過換下一家」(不換組內金鑰——同模型換金鑰仍是
|
|
24
27
|
// 同樣的產出習慣); 端點不穩而偶發空回的模型, 以maxRetries調高令同鍵重試。
|
|
28
|
+
// parse或check拋錯視同非法(不reject); 條目自帶validate時與本層validate取交集(兩者皆過才算過)——
|
|
29
|
+
// 遞補層之「條目覆寫共用預設」會讓條目validate取代本層validate, 曾致非法回覆通過轉接器、
|
|
30
|
+
// 本層重解析失敗而回ok:false且error為空(不再遞補), 2026-09-24由複審指出後修正。
|
|
31
|
+
//
|
|
32
|
+
// 【截斷(2026-09-24起)】REST文字類轉接器於validate之前判定截斷, 預設回INCOMPLETE_RESPONSE(errorType
|
|
33
|
+
// incomplete, 遞補層整組跳過且不重試), 不再經驗證失敗路徑。本層之acceptTruncated預設為「有自訂parse且非
|
|
34
|
+
// rawText」: README明文之策略②(組成自訂parse注入搶救截斷前段, 見salvageTruncatedArray.mjs)本身即同意訊號,
|
|
35
|
+
// 既有使用者不必改任何東西; 預設parse(extractJsonLoose)與rawText遇截斷一律判失敗換家。可顯式覆寫。
|
|
36
|
+
// CLI類轉接器拿不到終止訊號, 截斷不可判(已知限制)。
|
|
25
37
|
//
|
|
26
38
|
// 【防寫檔前綴】agentic CLI之cwd不是隔離邊界(可用絕對路徑寫到cwd外),
|
|
27
39
|
// 故預設在prompt前掛「禁止建檔」約束(實測有效); 不需要時傳promptPrefix:''關閉。
|
|
@@ -90,16 +102,17 @@ function buildChain(providers, spec) {
|
|
|
90
102
|
* @param {Function} [opt.check=null] 輸入結果檢核函數(json)=>Boolean,預設null代表只要能解析出JSON即通過
|
|
91
103
|
* @param {Function} [opt.parse=extractJsonLoose] 輸入回覆解析函數(stdout)=>Object|null,預設寬鬆JSON抽取
|
|
92
104
|
* @param {Boolean} [opt.rawText=false] 輸入是否以純文字模式運作布林值,true代表不解析JSON(json欄位為修剪後文字、check收文字),預設false
|
|
105
|
+
* @param {Boolean} [opt.acceptTruncated] 輸入是否接受REST文字類轉接器回報之截斷內容(交parse+check裁決)布林值,預設為「有給自訂parse且非rawText」(自訂parse即搶救策略之同意訊號,見salvageTruncatedArray.mjs),其餘情況截斷一律判失敗換家
|
|
93
106
|
* @param {String} [opt.promptPrefix=防寫檔約束] 輸入prompt前綴字串,預設為防寫檔約束(見wkf/noSideEffectPrefix.mjs),傳''關閉
|
|
94
107
|
* @param {Number} [opt.timeoutMs=300000] 輸入單次嘗試逾時毫秒正整數,全套件統一預設300000
|
|
95
108
|
* @param {Number} [opt.budgetMs=null] 輸入整條遞補鏈之時間預算毫秒正整數,預設null代表不限
|
|
96
109
|
* @param {Number} [opt.minAttemptMs=20000] 輸入搭配budgetMs之開工門檻毫秒正整數,剩餘預算低於此值即不再開工,預設20000
|
|
97
|
-
* @param {Number} [opt.maxRetries=0] 輸入同家重試次數非負整數,預設0(
|
|
110
|
+
* @param {Number} [opt.maxRetries=0] 輸入同家重試次數非負整數,預設0(韌性交給遞補;端點不穩偶發空回之模型可調高令同鍵重試;截斷不重試)
|
|
98
111
|
* @param {String} [opt.cwd=process.cwd()] 輸入子進程工作目錄字串,預設process.cwd()
|
|
99
112
|
* @param {Object} [opt.store=null] 輸入游標持久化物件{get,set},預設null代表用行程內記憶體
|
|
100
113
|
* @param {*} [opt.meta=undefined] 輸入呼叫端自有資訊(分類、標籤、註記),保留鍵保證永不轉傳下層,預設undefined
|
|
101
114
|
* @param {Function} [opt.onEvent=null] 輸入遞補層事件回調函數,預設null。除上列外之其餘鍵(retryDelayMs、maxBuffer、shouldStop、coolDetect、cooldownMs等)亦一律原樣轉傳dispatchAiFallback
|
|
102
|
-
* @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否取得可用結果布林值)、json(解析後物件,rawText模式下為文字)、providerId(實際使用之名稱)、keyIndex、keyId、ms(總耗時毫秒)、tried(遞補嘗試歷程陣列)、usage(api類之token用量原樣透傳,CLI類為null)、error(錯誤訊息字串)、errorType(僅失敗時,機器可讀錯誤類別字串,一覽見getErrorType.mjs檔頭),本函數不會reject
|
|
115
|
+
* @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否取得可用結果布林值)、json(解析後物件,rawText模式下為文字)、providerId(實際使用之名稱)、keyIndex、keyId、ms(總耗時毫秒)、tried(遞補嘗試歷程陣列)、usage(api類之token用量原樣透傳,CLI類為null)、finishReason(REST文字類之正規化終止原因字串,CLI類為'')、truncated(是否為截斷內容布林值,接受搶救時ok亦可能為true)、error(錯誤訊息字串)、errorType(僅失敗時,機器可讀錯誤類別字串,一覽見getErrorType.mjs檔頭),本函數不會reject
|
|
103
116
|
* @example
|
|
104
117
|
* //need cli in system PATH
|
|
105
118
|
*
|
|
@@ -152,7 +165,8 @@ async function callAiWithFallback(prompt, opt = {}) {
|
|
|
152
165
|
|
|
153
166
|
let rawText = get(opt, 'rawText', false) === true
|
|
154
167
|
let parse = get(opt, 'parse', null)
|
|
155
|
-
|
|
168
|
+
let parseGiven = isfun(parse)
|
|
169
|
+
if (!parseGiven) {
|
|
156
170
|
parse = extractJsonLoose
|
|
157
171
|
}
|
|
158
172
|
let check = get(opt, 'check', null)
|
|
@@ -160,44 +174,71 @@ async function callAiWithFallback(prompt, opt = {}) {
|
|
|
160
174
|
check = null
|
|
161
175
|
}
|
|
162
176
|
|
|
177
|
+
//acceptTruncated, 顯式布林值優先; 預設「有自訂parse且非rawText」——自訂parse即搶救策略之同意訊號(見檔頭【截斷】)
|
|
178
|
+
let acceptTruncated = get(opt, 'acceptTruncated', null)
|
|
179
|
+
if (!isbol(acceptTruncated)) {
|
|
180
|
+
acceptTruncated = parseGiven && !rawText
|
|
181
|
+
}
|
|
182
|
+
|
|
163
183
|
let promptPrefix = get(opt, 'promptPrefix', null)
|
|
164
184
|
if (!isestr(promptPrefix)) {
|
|
165
185
|
promptPrefix = (promptPrefix === '') ? '' : NO_SIDE_EFFECT
|
|
166
186
|
}
|
|
167
187
|
|
|
168
|
-
//validate接進遞補層: 非法回覆=這一家失敗,
|
|
188
|
+
//validate接進遞補層: 非法回覆=這一家失敗, 遞補層換下一家; parse/check拋錯視同非法(不reject)
|
|
169
189
|
let validate = (stdout) => {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
190
|
+
try {
|
|
191
|
+
if (rawText) {
|
|
192
|
+
let s = String(stdout || '').trim()
|
|
193
|
+
if (s === '') {
|
|
194
|
+
return false
|
|
195
|
+
}
|
|
196
|
+
return check ? check(s) === true : true
|
|
197
|
+
}
|
|
198
|
+
let j = parse(stdout)
|
|
199
|
+
if (j === null) {
|
|
173
200
|
return false
|
|
174
201
|
}
|
|
175
|
-
return check ? check(
|
|
202
|
+
return check ? check(j) === true : true
|
|
176
203
|
}
|
|
177
|
-
|
|
178
|
-
if (j === null) {
|
|
204
|
+
catch {
|
|
179
205
|
return false
|
|
180
206
|
}
|
|
181
|
-
return check ? check(j) === true : true
|
|
182
207
|
}
|
|
183
208
|
|
|
184
|
-
|
|
209
|
+
//條目自帶validate時與本層validate取交集(兩者皆過才算過), 免遞補層之條目覆寫令本層驗證被繞過(見檔頭)
|
|
210
|
+
chain = chain.map((e) => {
|
|
211
|
+
let ev = buildValidator(get(e, 'validate', null))
|
|
212
|
+
if (ev === null) {
|
|
213
|
+
return e
|
|
214
|
+
}
|
|
215
|
+
return { ...e, validate: (s) => safeValidate(ev, s).pass && validate(s) }
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
//剔除本層自用鍵後原樣轉傳(含minAttemptMs/retryDelayMs/maxBuffer等), providers、validate與acceptTruncated由本層給定
|
|
185
219
|
let r = await dispatchAiFallback(promptPrefix + prompt, {
|
|
186
220
|
...omit(opt, OWN_KEYS),
|
|
187
221
|
providers: chain,
|
|
188
222
|
validate,
|
|
223
|
+
acceptTruncated,
|
|
189
224
|
timeoutMs: get(opt, 'timeoutMs', null) || dfTimeoutMs, //全套件統一預設300000
|
|
190
225
|
})
|
|
191
226
|
|
|
192
|
-
//result, 已過validate故此處parse必然成功(同一解析器),
|
|
227
|
+
//result, 已過validate故此處parse必然成功(同一解析器), 重解析僅為取出物件; 防禦性接住拋錯
|
|
193
228
|
let result = null
|
|
194
229
|
if (r.ok) {
|
|
195
|
-
|
|
230
|
+
try {
|
|
231
|
+
result = rawText ? String(r.stdout || '').trim() : parse(r.stdout)
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
result = null
|
|
235
|
+
}
|
|
196
236
|
}
|
|
237
|
+
let ok = r.ok && result !== null
|
|
197
238
|
let providerId = get(r, 'providerId', null)
|
|
198
239
|
let keyIndex = get(r, 'keyIndex', null)
|
|
199
240
|
return {
|
|
200
|
-
ok
|
|
241
|
+
ok,
|
|
201
242
|
json: result, //rawText模式下此欄為文字
|
|
202
243
|
providerId,
|
|
203
244
|
keyIndex,
|
|
@@ -205,8 +246,10 @@ async function callAiWithFallback(prompt, opt = {}) {
|
|
|
205
246
|
ms: Date.now() - t0,
|
|
206
247
|
tried: get(r, 'tried', []),
|
|
207
248
|
usage: get(r, 'usage', null), //api類轉接器之token用量原樣透傳, CLI類為null(無可靠來源)
|
|
208
|
-
|
|
209
|
-
|
|
249
|
+
finishReason: get(r, 'finishReason', ''), //REST文字類之正規化終止原因, CLI類為''
|
|
250
|
+
truncated: get(r, 'truncated', false) === true, //接受搶救之截斷內容時ok亦可能為true
|
|
251
|
+
error: ok ? '' : (r.ok ? 'OUTPUT_VALIDATION_FAILED' : get(r, 'error', 'unknown error')),
|
|
252
|
+
...(ok ? {} : { errorType: r.ok ? 'validation' : get(r, 'errorType', 'exec') }), //機器可讀錯誤類別, 僅失敗時
|
|
210
253
|
}
|
|
211
254
|
}
|
|
212
255
|
|
package/src/wkf/runFanout.mjs
CHANGED
|
@@ -19,6 +19,8 @@ import callAiWithFallback from './callAiWithFallback.mjs'
|
|
|
19
19
|
//
|
|
20
20
|
// 【實測依據(2026-08-10評比)】整合者是本流程的單點故障——端點不穩的模型
|
|
21
21
|
// (如偶發靜默空回者)當整合者時, 靠spec.fallback遞補或maxRetries調高才能保住整條鏈。
|
|
22
|
+
// 注意推理模型之空回常是推理耗盡輸出上限(REST類以截斷回報且不重試, 見checkTruncation.mjs),
|
|
23
|
+
// 該調的是max_tokens而非maxRetries。
|
|
22
24
|
|
|
23
25
|
|
|
24
26
|
/**
|
|
@@ -13,6 +13,15 @@
|
|
|
13
13
|
//
|
|
14
14
|
// 【只搶救「掃到結尾仍未閉合」的截斷】陣列有正常閉合但parse失敗屬別種毛病
|
|
15
15
|
// (非法字元/壞跳脫), 補`]`救不了反而可能拼出語意錯誤的結果, 一律不碰回null。
|
|
16
|
+
//
|
|
17
|
+
// 【與REST截斷判定之銜接(2026-09-24起)】REST文字類轉接器於validate之前判定截斷且預設失敗;
|
|
18
|
+
// 工作流callAi之acceptTruncated預設為「有自訂parse且非rawText」, 故以本工具組成自訂parse注入即同意
|
|
19
|
+
// 接受截斷內容(既有用法不必改), 結果之truncated:true可辨識救回者為半批; 直接呼叫轉接器須自給acceptTruncated:true。
|
|
20
|
+
// CLI類拿不到終止訊號, 其截斷只能靠本工具於解析時發現。
|
|
21
|
+
//
|
|
22
|
+
// 【已知限制(2026-09-24複審指出, 讀碼確認)】只記錄頂層「物件」元素之結束(`}`), 故元素非物件之陣列
|
|
23
|
+
// (如["a","b",…)救不回; 自第一個`[`起算, 故外包物件(如{"items":[…)會救回內層陣列(形狀改變,
|
|
24
|
+
// 呼叫端check須能接受), 前言含`[`(如「[注意]」)則誤判為已閉合而回null。
|
|
16
25
|
|
|
17
26
|
|
|
18
27
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import http from 'http'
|
|
2
|
+
import zlib from 'zlib'
|
|
2
3
|
|
|
3
4
|
|
|
4
5
|
// fakeServerForApiTest.mjs — 測試用的OpenAI相容假伺服器
|
|
@@ -20,6 +21,9 @@ import http from 'http'
|
|
|
20
21
|
// no-choices — 200但無choices(畸形回應路徑用)
|
|
21
22
|
// not-json — 200但本體非JSON(畸形回應路徑用)
|
|
22
23
|
// tool-calls — 200但finish_reason為tool_calls(工具不支援路徑用)
|
|
24
|
+
// br-noheader — 200, 請求之Accept-Encoding恰為identity才回原文; 否則回brotli壓縮本體且刻意不帶
|
|
25
|
+
// Content-Encoding(模擬2026-09-24使用端回報之伺服器: 壓縮卻漏標, Node fetch因而不解壓)
|
|
26
|
+
// TRUNC_ROUTES — 200, 依表回指定之finish_reason與content(截斷處理之規格測試用, 見下方常數)
|
|
23
27
|
// 其他 — 404
|
|
24
28
|
// 【responses之行為路由(依body.model)】
|
|
25
29
|
// echo — 200, message之output_text為JSON字串{ auth, body }
|
|
@@ -30,19 +34,40 @@ import http from 'http'
|
|
|
30
34
|
// reasoning-only — 200且completed但output僅reasoning無message(結構不合規)
|
|
31
35
|
// tool-calls — 200但output含function_call元素
|
|
32
36
|
// no-output — 200但缺output陣列
|
|
33
|
-
//
|
|
37
|
+
// incomplete-partial — 200, status為incomplete(max_output_tokens)但已有部分文字(可搶救之截斷陣列)
|
|
38
|
+
// incomplete-filter — 200, status為incomplete(content_filter)且有部分文字
|
|
39
|
+
// no-status — 200, 缺status欄但有文字(非截斷之不合規狀態)
|
|
40
|
+
// not-json/slow/err-500/flaky-429/br-noheader — 同chat/completions之對應行為
|
|
34
41
|
// 其他 — 401(同Zen實測: 未知model回401非404)
|
|
35
42
|
// 【systemone之行為路由(POST /v1/systemone, 依body.model; 形狀取自2026-09-17 TypeSafe實測)】
|
|
36
43
|
// echo / jev-latest — 200, 每題回{type:'noul', noul:0.5, echoAuth, echoBody}, 供斷言請求組成
|
|
37
44
|
// missing-answer — 200但answers缺最後一題
|
|
38
45
|
// no-answers — 200但無answers
|
|
39
46
|
// bad-question — 422 {detail:[{type:'union_tag_invalid',...}]}
|
|
40
|
-
// not-json/slow/err-500/flaky-429 — 同chat/completions之對應行為
|
|
47
|
+
// not-json/slow/err-500/flaky-429/br-noheader — 同chat/completions之對應行為
|
|
41
48
|
// 其他 — 400 {detail:{error_type:'api_usage_error', message:'Unknown model: X'}}
|
|
42
49
|
// 【金鑰規則】Authorization含'sk-bad'一律401(優先於model路由), 模擬無效金鑰;
|
|
43
50
|
// systemone路由之401本體採TypeSafe形狀{detail:{error_type:'authentication_error'}}。
|
|
44
51
|
|
|
45
52
|
|
|
53
|
+
//TRUNC_ROUTES, chat/completions之截斷與終止原因情境(2026-09-24截斷處理之規格測試):
|
|
54
|
+
//fr為finish_reason原值(含大小寫與null), content為message.content原值(含null與純空白)
|
|
55
|
+
let TRUNC_ROUTES = {
|
|
56
|
+
'trunc-empty': { fr: 'length', content: '', usage: { prompt_tokens: 181, completion_tokens: 600, total_tokens: 781, completion_tokens_details: { reasoning_tokens: 600 } } }, //推理耗盡(Zen實測形態)
|
|
57
|
+
'trunc-null': { fr: 'length', content: null },
|
|
58
|
+
'trunc-space': { fr: 'length', content: '\n\n' },
|
|
59
|
+
'trunc-array': { fr: 'length', content: '[{"a":1},{"b":2},{"c":' }, //可搶救前段
|
|
60
|
+
'trunc-text': { fr: 'length', content: '第一段說明,第二' },
|
|
61
|
+
'trunc-tail': { fr: 'length', content: '{"a":1}\n\n說明:因為' }, //完整載荷+截尾(寬鬆解析可過)
|
|
62
|
+
'trunc-upper': { fr: 'LENGTH', content: '[{"a":1},{"b":' }, //大小寫不同
|
|
63
|
+
'filtered': { fr: 'content_filter', content: '部分內容' },
|
|
64
|
+
'finish-stop': { fr: 'stop', content: '完成' },
|
|
65
|
+
'finish-null': { fr: null, content: '完成' },
|
|
66
|
+
'finish-other': { fr: 'eos', content: '完成' }, //未知值, 不可誤殺
|
|
67
|
+
'plain-content': { fr: 'stop', content: 'hello world' }, //非JSON純文字
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
46
71
|
/**
|
|
47
72
|
* 啟動測試用OpenAI相容假伺服器
|
|
48
73
|
*
|
|
@@ -73,6 +98,14 @@ async function fakeServerForApiTest() {
|
|
|
73
98
|
|
|
74
99
|
let auth = req.headers['authorization'] || ''
|
|
75
100
|
|
|
101
|
+
//sendUndeclaredBr, br-noheader路由用: 客戶端明示identity才回原文, 否則回brotli本體且刻意不帶Content-Encoding
|
|
102
|
+
let ae = String(req.headers['accept-encoding'] || '').trim().toLowerCase()
|
|
103
|
+
let sendUndeclaredBr = (obj) => {
|
|
104
|
+
let buf = Buffer.from(JSON.stringify(obj))
|
|
105
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
106
|
+
res.end(ae === 'identity' ? buf : zlib.brotliCompressSync(buf))
|
|
107
|
+
}
|
|
108
|
+
|
|
76
109
|
//body非JSON → 400
|
|
77
110
|
let body = null
|
|
78
111
|
try {
|
|
@@ -129,6 +162,13 @@ async function fakeServerForApiTest() {
|
|
|
129
162
|
else if (model === 'bad-question') {
|
|
130
163
|
send(422, { detail: [{ type: 'union_tag_invalid', loc: ['body', 'questions', ids[0]], msg: 'Input tag does not match any of the expected tags' }] })
|
|
131
164
|
}
|
|
165
|
+
else if (model === 'br-noheader') {
|
|
166
|
+
let answers = {}
|
|
167
|
+
for (let id of ids) {
|
|
168
|
+
answers[id] = { type: 'noul', noul: 0.5 }
|
|
169
|
+
}
|
|
170
|
+
sendUndeclaredBr({ model: 'jev-1.13.0', answers, usage })
|
|
171
|
+
}
|
|
132
172
|
else if (model === 'not-json') {
|
|
133
173
|
res.writeHead(200, { 'Content-Type': 'text/plain' })
|
|
134
174
|
res.end('plain text body')
|
|
@@ -217,6 +257,32 @@ async function fakeServerForApiTest() {
|
|
|
217
257
|
else if (model === 'no-output') { //缺output陣列
|
|
218
258
|
send(200, { status: 'completed', model, id: 'resp_y' })
|
|
219
259
|
}
|
|
260
|
+
else if (model === 'incomplete-partial') { //max_output_tokens耗盡但已有部分文字
|
|
261
|
+
send(200, {
|
|
262
|
+
status: 'incomplete',
|
|
263
|
+
model,
|
|
264
|
+
error: null,
|
|
265
|
+
incomplete_details: { reason: 'max_output_tokens' },
|
|
266
|
+
output: [{ type: 'message', content: [{ type: 'output_text', text: '[{"a":1},{"b":2},{"c":' }] }],
|
|
267
|
+
usage: { ...usage, output_tokens_details: { reasoning_tokens: 9 } },
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
else if (model === 'incomplete-filter') {
|
|
271
|
+
send(200, { status: 'incomplete', model, error: null, incomplete_details: { reason: 'content_filter' }, output: [{ type: 'message', content: [{ type: 'output_text', text: '部分' }] }], usage })
|
|
272
|
+
}
|
|
273
|
+
else if (model === 'no-status') {
|
|
274
|
+
send(200, { model, error: null, incomplete_details: null, output: [{ type: 'message', content: [{ type: 'output_text', text: 'x' }] }], usage })
|
|
275
|
+
}
|
|
276
|
+
else if (model === 'br-noheader') {
|
|
277
|
+
sendUndeclaredBr({
|
|
278
|
+
status: 'completed',
|
|
279
|
+
model,
|
|
280
|
+
error: null,
|
|
281
|
+
incomplete_details: null,
|
|
282
|
+
output: [{ type: 'message', content: [{ type: 'output_text', text: '完成' }] }],
|
|
283
|
+
usage,
|
|
284
|
+
})
|
|
285
|
+
}
|
|
220
286
|
else if (model === 'not-json') {
|
|
221
287
|
res.writeHead(200, { 'Content-Type': 'text/plain' })
|
|
222
288
|
res.end('plain text body')
|
|
@@ -290,6 +356,17 @@ async function fakeServerForApiTest() {
|
|
|
290
356
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
291
357
|
res.end(JSON.stringify({ id: 'x', object: 'chat.completion' }))
|
|
292
358
|
}
|
|
359
|
+
else if (model === 'br-noheader') {
|
|
360
|
+
sendUndeclaredBr({ choices: [{ finish_reason: 'stop', message: { role: 'assistant', content: '完成' } }] })
|
|
361
|
+
}
|
|
362
|
+
else if (TRUNC_ROUTES[model] !== undefined) {
|
|
363
|
+
let t = TRUNC_ROUTES[model]
|
|
364
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
365
|
+
res.end(JSON.stringify({
|
|
366
|
+
choices: [{ finish_reason: t.fr, message: { role: 'assistant', content: t.content } }],
|
|
367
|
+
usage: t.usage || { prompt_tokens: 3, completion_tokens: 7, total_tokens: 10 },
|
|
368
|
+
}))
|
|
369
|
+
}
|
|
293
370
|
else if (model === 'not-json') {
|
|
294
371
|
res.writeHead(200, { 'Content-Type': 'text/plain' })
|
|
295
372
|
res.end('plain text body')
|
|
@@ -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,20 @@ 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: 改回允許壓縮時, 遇漏標之壓縮本體即解析失敗(invalid-response)', 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]
|
|
176
|
+
let rr = [false, 200, 'invalid-response', 'INVALID_RESPONSE: missing choices[0].message.content']
|
|
177
|
+
assert.strict.deepEqual(r, rr)
|
|
178
|
+
})
|
|
179
|
+
|
|
164
180
|
it('validate字串規則與自訂函數皆可用, 失敗回傳OUTPUT_VALIDATION_FAILED', async function() {
|
|
165
181
|
let t1 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'empty-content', validate: 'nonempty' })
|
|
166
182
|
let t2 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'echo', validate: 'nonempty,json' })
|
|
@@ -205,7 +221,159 @@ describe('dispatchApiOpenaiCompat', function() {
|
|
|
205
221
|
],
|
|
206
222
|
})
|
|
207
223
|
let r = [t.ok, t.providerId, t.tried.map((x) => [x.keyId, x.outcome])]
|
|
208
|
-
let rr = [true, 'g-text', [['g-tool#0', '
|
|
224
|
+
let rr = [true, 'g-text', [['g-tool#0', 'skip-group'], ['g-text#0', 'ok']]]
|
|
225
|
+
assert.strict.deepEqual(r, rr)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('截斷預設失敗: finish_reason為length且可見輸出為空(空字串/null/純空白)一律incomplete, 訊息標明無可見輸出並附reasoning_tokens', async function() {
|
|
229
|
+
let r = []
|
|
230
|
+
for (let model of ['trunc-empty', 'trunc-null', 'trunc-space']) {
|
|
231
|
+
let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model })
|
|
232
|
+
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')])
|
|
233
|
+
}
|
|
234
|
+
let t0 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'trunc-empty' })
|
|
235
|
+
r.push(t0.error.includes('reasoning_tokens=600'))
|
|
236
|
+
let rr = [
|
|
237
|
+
[false, 'incomplete', true, 'length', true, true],
|
|
238
|
+
[false, 'incomplete', true, 'length', true, true],
|
|
239
|
+
[false, 'incomplete', true, 'length', true, true],
|
|
240
|
+
true,
|
|
241
|
+
]
|
|
242
|
+
assert.strict.deepEqual(r, rr)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('截斷預設失敗且與validate無關: 截斷陣列/截斷文字/完整載荷加截尾 × {無, nonempty, json, 搶救函數}皆incomplete', async function() {
|
|
246
|
+
let salvage = (s) => salvageTruncatedArray(s) !== null
|
|
247
|
+
let r = []
|
|
248
|
+
for (let model of ['trunc-array', 'trunc-text', 'trunc-tail']) {
|
|
249
|
+
for (let validate of [undefined, 'nonempty', 'json', salvage]) {
|
|
250
|
+
let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model, validate })
|
|
251
|
+
r.push([model, t.ok, t.errorType, t.truncated])
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
let rr = []
|
|
255
|
+
for (let model of ['trunc-array', 'trunc-text', 'trunc-tail']) {
|
|
256
|
+
for (let i = 0; i < 4; i++) {
|
|
257
|
+
rr.push([model, false, 'incomplete', true])
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
assert.strict.deepEqual(r, rr)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('acceptTruncated:true才放行: length且內容非空交validate(通過ok帶truncated、不過incomplete、無validate亦ok); 空內容與content_filter仍失敗', async function() {
|
|
264
|
+
let salvage = (s) => salvageTruncatedArray(s) !== null
|
|
265
|
+
let a1 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'trunc-array', acceptTruncated: true, validate: salvage })
|
|
266
|
+
let a2 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'trunc-text', acceptTruncated: true, validate: 'json' })
|
|
267
|
+
let a3 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'trunc-text', acceptTruncated: true })
|
|
268
|
+
let a4 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'trunc-empty', acceptTruncated: true })
|
|
269
|
+
let a5 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'filtered', acceptTruncated: true })
|
|
270
|
+
let r = [
|
|
271
|
+
[a1.ok, a1.stdout, a1.truncated, a1.finishReason],
|
|
272
|
+
[a2.ok, a2.errorType, a2.truncated, a2.error.includes('rejected by validate')],
|
|
273
|
+
[a3.ok, a3.stdout, a3.truncated],
|
|
274
|
+
[a4.ok, a4.errorType],
|
|
275
|
+
[a5.ok, a5.errorType, a5.truncated, a5.finishReason],
|
|
276
|
+
]
|
|
277
|
+
let rr = [
|
|
278
|
+
[true, '[{"a":1},{"b":2},{"c":', true, 'length'],
|
|
279
|
+
[false, 'incomplete', true, true],
|
|
280
|
+
[true, '第一段說明,第二', true],
|
|
281
|
+
[false, 'incomplete'],
|
|
282
|
+
[false, 'incomplete', true, 'content_filter'],
|
|
283
|
+
]
|
|
284
|
+
assert.strict.deepEqual(r, rr)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('防誤殺: finish_reason為stop、null、未知值時照常成功且truncated為false; 截斷判定不分大小寫', async function() {
|
|
288
|
+
let r = []
|
|
289
|
+
for (let model of ['finish-stop', 'finish-null', 'finish-other']) {
|
|
290
|
+
let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model })
|
|
291
|
+
r.push([model, t.ok, t.stdout, t.truncated, t.finishReason])
|
|
292
|
+
}
|
|
293
|
+
let tu = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'trunc-upper' })
|
|
294
|
+
r.push(['trunc-upper', tu.ok, tu.errorType, tu.finishReason])
|
|
295
|
+
let rr = [
|
|
296
|
+
['finish-stop', true, '完成', false, 'stop'],
|
|
297
|
+
['finish-null', true, '完成', false, ''],
|
|
298
|
+
['finish-other', true, '完成', false, 'eos'],
|
|
299
|
+
['trunc-upper', false, 'incomplete', 'length'],
|
|
300
|
+
]
|
|
301
|
+
assert.strict.deepEqual(r, rr)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('截斷與金鑰無關: 遞補鏈只試一把即整組跳過且tried帶truncated; 轉接器重試遇截斷不重打', async function() {
|
|
305
|
+
let t = await dispatchAiFallback('abc', {
|
|
306
|
+
providers: [
|
|
307
|
+
{ id: 'g-trunc', kind: 'api-openai-compat', baseURL: svr.url, model: 'trunc-text', keys: ['sk-a0', 'sk-a1'] },
|
|
308
|
+
{ id: 'g-ok', kind: 'api-openai-compat', baseURL: svr.url, model: 'echo', keys: ['sk-b0'] },
|
|
309
|
+
],
|
|
310
|
+
})
|
|
311
|
+
let t2 = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'trunc-text', maxRetries: 2, retryDelayMs: 10 })
|
|
312
|
+
let r = [t.ok, t.providerId, t.tried.map((x) => [x.keyId, x.outcome, x.truncated === true]), t2.attempts]
|
|
313
|
+
let rr = [true, 'g-ok', [['g-trunc#0', 'skip-group', true], ['g-ok#0', 'ok', false]], 1]
|
|
314
|
+
assert.strict.deepEqual(r, rr)
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('validate拋錯視同驗證失敗, Promise照常resolve不reject', async function() {
|
|
318
|
+
let boom = () => {
|
|
319
|
+
throw new Error('boom-validate')
|
|
320
|
+
}
|
|
321
|
+
let t = await dispatchApiOpenaiCompat('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'echo', validate: boom })
|
|
322
|
+
let r = [t.ok, t.errorType, t.error, t.stderr.includes('boom-validate')]
|
|
323
|
+
let rr = [false, 'validation', 'OUTPUT_VALIDATION_FAILED', true]
|
|
324
|
+
assert.strict.deepEqual(r, rr)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('工作流: 自訂搶救parse遇截斷即視為同意→ok且json為前段、truncated可見; 預設parse、rawText、明示acceptTruncated:false遇截斷→失敗', async function() {
|
|
328
|
+
let wkf = dispatchAiWkf({
|
|
329
|
+
providers: {
|
|
330
|
+
'p-trunc': { kind: 'api-openai-compat', baseURL: svr.url, model: 'trunc-array', keys: ['sk-w1'] },
|
|
331
|
+
'p-trunc-text': { kind: 'api-openai-compat', baseURL: svr.url, model: 'trunc-text', keys: ['sk-w2'] },
|
|
332
|
+
},
|
|
333
|
+
defaults: { promptPrefix: '' },
|
|
334
|
+
})
|
|
335
|
+
let parseSalvage = (s) => extractJsonLoose(s) || salvageTruncatedArray(s)
|
|
336
|
+
let t1 = await wkf.callAi('abc', { spec: { use: 'p-trunc' }, parse: parseSalvage })
|
|
337
|
+
let t2 = await wkf.callAi('abc', { spec: { use: 'p-trunc' } })
|
|
338
|
+
let t3 = await wkf.callAi('abc', { spec: { use: 'p-trunc-text' }, rawText: true })
|
|
339
|
+
let t4 = await wkf.callAi('abc', { spec: { use: 'p-trunc' }, parse: parseSalvage, acceptTruncated: false })
|
|
340
|
+
let r = [
|
|
341
|
+
[t1.ok, t1.json, t1.truncated, t1.finishReason],
|
|
342
|
+
[t2.ok, t2.errorType, t2.truncated, t2.tried.map((x) => x.outcome)],
|
|
343
|
+
[t3.ok, t3.errorType],
|
|
344
|
+
[t4.ok, t4.errorType],
|
|
345
|
+
]
|
|
346
|
+
let rr = [
|
|
347
|
+
[true, [{ a: 1 }, { b: 2 }], true, 'length'],
|
|
348
|
+
[false, 'incomplete', true, ['skip-group']],
|
|
349
|
+
[false, 'incomplete'],
|
|
350
|
+
[false, 'incomplete'],
|
|
351
|
+
]
|
|
352
|
+
assert.strict.deepEqual(r, rr)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('工作流: 條目自帶validate不再繞過工作流驗證(兩者取交集), 不過即遞補下一家', async function() {
|
|
356
|
+
let wkf = dispatchAiWkf({
|
|
357
|
+
providers: {
|
|
358
|
+
'p-plain': { kind: 'api-openai-compat', baseURL: svr.url, model: 'plain-content', keys: ['sk-v1'], validate: 'nonempty' },
|
|
359
|
+
'p-echo': { kind: 'api-openai-compat', baseURL: svr.url, model: 'echo', keys: ['sk-v2'] },
|
|
360
|
+
},
|
|
361
|
+
defaults: { promptPrefix: '' },
|
|
362
|
+
})
|
|
363
|
+
let t = await wkf.callAi('abc', { spec: { use: 'p-plain', fallback: ['p-echo'] } })
|
|
364
|
+
let r = [t.ok, t.providerId, t.tried.map((x) => [x.providerId, x.outcome, x.error || null])]
|
|
365
|
+
let rr = [true, 'p-echo', [['p-plain', 'skip-group', 'OUTPUT_VALIDATION_FAILED'], ['p-echo', 'ok', null]]]
|
|
366
|
+
assert.strict.deepEqual(r, rr)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('工作流: check拋錯視同驗證失敗, callAi照常resolve不reject', async function() {
|
|
370
|
+
let wkf = dispatchAiWkf({
|
|
371
|
+
providers: { 'p-e1': { kind: 'api-openai-compat', baseURL: svr.url, model: 'echo', keys: ['sk-c1'] } },
|
|
372
|
+
defaults: { promptPrefix: '' },
|
|
373
|
+
})
|
|
374
|
+
let t = await wkf.callAi('abc', { spec: { use: 'p-e1' }, check: (j) => j.nope.deep === 1 })
|
|
375
|
+
let r = [t.ok, t.errorType, t.error]
|
|
376
|
+
let rr = [false, 'validation', 'OUTPUT_VALIDATION_FAILED']
|
|
209
377
|
assert.strict.deepEqual(r, rr)
|
|
210
378
|
})
|
|
211
379
|
|
|
@@ -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,77 @@ describe('dispatchApiOpenaiResponses', function() {
|
|
|
77
78
|
assert.strict.deepEqual(r, rr)
|
|
78
79
|
})
|
|
79
80
|
|
|
80
|
-
it('
|
|
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],
|
|
94
|
+
]
|
|
95
|
+
let rr = [
|
|
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],
|
|
98
|
+
]
|
|
99
|
+
assert.strict.deepEqual(r, rr)
|
|
100
|
+
})
|
|
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,
|
|
86
113
|
]
|
|
87
114
|
let rr = [
|
|
88
|
-
[false, 'incomplete', 'INCOMPLETE_RESPONSE: status=incomplete (max_output_tokens)'],
|
|
89
|
-
[
|
|
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,
|
|
90
119
|
]
|
|
91
120
|
assert.strict.deepEqual(r, rr)
|
|
92
121
|
})
|
|
93
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('validate拋錯視同驗證失敗, Promise照常resolve不reject', async function() {
|
|
143
|
+
let boom = () => {
|
|
144
|
+
throw new Error('boom-validate')
|
|
145
|
+
}
|
|
146
|
+
let t = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'echo', validate: boom })
|
|
147
|
+
let r = [t.ok, t.errorType, t.error, t.stderr.includes('boom-validate')]
|
|
148
|
+
let rr = [false, 'validation', 'OUTPUT_VALIDATION_FAILED', true]
|
|
149
|
+
assert.strict.deepEqual(r, rr)
|
|
150
|
+
})
|
|
151
|
+
|
|
94
152
|
it('結構不合規之回應: 缺output陣列、非JSON、僅reasoning無message', async function() {
|
|
95
153
|
let t1 = await dispatchApiOpenaiResponses('abc', { baseURL: svr.url, key: 'sk-good-1', model: 'no-output' })
|
|
96
154
|
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]
|