w-dispatch-ai 1.0.37 → 1.0.39
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 +24 -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 +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 +37 -5
- 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 +255 -21
- 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 +35 -3
- 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 +17 -2
- package/test/unit-dispatchAiFallback.test.mjs +245 -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
|
@@ -10,6 +10,7 @@ import dispatchAi from './dispatchAi.mjs'
|
|
|
10
10
|
import getErrorResult from './getErrorResult.mjs'
|
|
11
11
|
import castPintOr from './castPintOr.mjs'
|
|
12
12
|
import dfTimeoutMs from './dfTimeoutMs.mjs'
|
|
13
|
+
import { BODY_NOT_JSON } from './describeNonJsonBody.mjs'
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
// dispatchAiFallback.mjs — 多供應商自動遞補層
|
|
@@ -17,8 +18,10 @@ import dfTimeoutMs from './dfTimeoutMs.mjs'
|
|
|
17
18
|
// 【兩層策略】群組之間依providers宣告順序(優先序), 群組之內(keys多把)以游標輪替(額度均攤)。
|
|
18
19
|
//
|
|
19
20
|
// 【失敗分流】只分兩路:
|
|
20
|
-
// 與金鑰無關之失敗(TIMEOUT/ENOENT/參數錯誤/驗證失敗/未知kind
|
|
21
|
+
// 與金鑰無關之失敗(TIMEOUT/ENOENT/參數錯誤/驗證失敗/未知kind/截斷/工具不支援/本體非JSON) → 整組跳過——
|
|
21
22
|
// 同組各金鑰共用同一exe與model, 換金鑰必然再敗一次, 純屬空耗;
|
|
23
|
+
// 本體非JSON(REST之HTTP 200但無法解析, 見describeNonJsonBody.mjs)屬傳輸或閘道狀態, 2026-09-24依安裝方實例納入
|
|
24
|
+
// (換第二把金鑰只是以剩餘預算重打至逾時); 「JSON缺欄位」不在此列, 仍換金鑰(部分閘道以200回帳號層級錯誤);
|
|
22
25
|
// 截斷(結果之truncated為true, 僅REST文字類可判, 見checkTruncation.mjs)與工具不支援(TOOL_CALLS_UNSUPPORTED)
|
|
23
26
|
// 皆屬模型對同一請求之產出性質, 2026-09-24起納入(前者由複審指出同模型換金鑰再截斷一次;
|
|
24
27
|
// 後者之既有測試標題即寫「不逐把空耗」而斷言卻為逐把換金鑰, 一併更正);
|
|
@@ -51,6 +54,14 @@ import dfTimeoutMs from './dfTimeoutMs.mjs'
|
|
|
51
54
|
// 中止後每個後續呼叫進門即回ABORTED, 整條工作流自然快速收束, 不需逐層實作。
|
|
52
55
|
// 不中止進行中之嘗試(不殺子進程/不斷開請求), 此為已知設計取捨(避免侵入execCli層)。
|
|
53
56
|
//
|
|
57
|
+
// 【組盡事件(group-exhausted)】逐次事件(try/next-key/skip-group等)不帶呼叫識別, 而同一onEvent常被並行呼叫共用
|
|
58
|
+
// (如runFanout之各席位)。呼叫端要判斷「某條目在這次呼叫裡整組試完仍無成交」(如健康層據以降序)時,
|
|
59
|
+
// 若以金鑰數與逐把失敗次數重建, 並行呼叫跨越一次成交就會多計或少計——游標只在成交時推進,
|
|
60
|
+
// 在途呼叫與新呼叫的起點不同(2026-09-24下游w-knowledge-extract實測重現), 且重建本身依賴本層之
|
|
61
|
+
// 游標推進時機、每把至多一次、金鑰濾法三項內部性質。故由本層於本組未成交而試完時直接發出,
|
|
62
|
+
// 每次呼叫每組恰一次; 成交、預算用盡、中止(本組未試完)皆不發——後兩者屬呼叫端的時間或意願, 非該組故障。
|
|
63
|
+
// 組邊界只發事件, 不寫入tried(tried為逐次嘗試歷程, 其長度即嘗試次數)。
|
|
64
|
+
//
|
|
54
65
|
// 【meta保留鍵】「剔除自用鍵後原樣轉傳」令條目即調校點, 但呼叫端放進條目/opt的任何
|
|
55
66
|
// 自有欄位都會被靜默轉傳——保留meta一鍵保證永不轉傳, 呼叫端要掛分類/標籤/註記
|
|
56
67
|
// 一律放meta, 與轉傳機制永久絕緣(工作流各層之規格物件同此約定)。
|
|
@@ -234,6 +245,11 @@ function isKeyIndependentFail(r) {
|
|
|
234
245
|
return true
|
|
235
246
|
}
|
|
236
247
|
|
|
248
|
+
//HTTP 200但本體非JSON, 屬傳輸或閘道狀態, 換金鑰必然再敗(「JSON缺欄位」不在此列, 仍換金鑰; 見describeNonJsonBody.mjs)
|
|
249
|
+
if (error.indexOf(BODY_NOT_JSON) === 0) {
|
|
250
|
+
return true
|
|
251
|
+
}
|
|
252
|
+
|
|
237
253
|
//kind無效, 屬條目設定錯誤
|
|
238
254
|
if (error.indexOf('unknown ai kind') === 0) {
|
|
239
255
|
return true
|
|
@@ -250,7 +266,7 @@ function isKeyIndependentFail(r) {
|
|
|
250
266
|
* providers陣列順序即優先序,排前面的先用;
|
|
251
267
|
* 條目本身即該次調用之opt(除id與keys外原樣透傳對應轉接器),與dispatchAi「條目直接當opt」同一約定;
|
|
252
268
|
* 條目給予keys(多把金鑰)時以游標輪替,某把失敗自動換下一把,全數失敗才遞補下一組;
|
|
253
|
-
* 與金鑰無關之失敗(逾時/執行檔不存在/參數錯誤/輸出未過驗證/未知kind
|
|
269
|
+
* 與金鑰無關之失敗(逾時/執行檔不存在/參數錯誤/輸出未過驗證/未知kind/截斷/工具不支援/HTTP 200但本體非JSON)直接整組跳過,不逐把空耗;
|
|
254
270
|
* 跨次執行僅記憶游標(經store注入持久化),不設金鑰停用清單——額度視窗形態多樣(5小時滾動/逐時/逐日),
|
|
255
271
|
* 停用會把已恢復的金鑰閒置,而重探的代價僅一次快速失敗;
|
|
256
272
|
* 本函數不會reject,一律以結果物件之ok與error欄位回報成敗
|
|
@@ -268,9 +284,10 @@ function isKeyIndependentFail(r) {
|
|
|
268
284
|
* @param {Function} [opt.coolDetect=null] 輸入冷卻觸發判定函數(r)=>Boolean,收完整失敗結果物件(含stdout、stderr、code、error),回傳true即視同冷卻觸發(內建429/TIMEOUT觸發不受影響)——CLI類限流埋在stderr且各家字樣不同,簽章表由觀察到字樣的呼叫端維護,如(r)=>/FreeUsageLimitError/i.test(r.stderr||'');僅cooldownMs>0時有效,回調拋出例外視同false,預設null
|
|
269
285
|
* @param {Function} [opt.shouldStop=null] 輸入中止判定函數()=>Boolean,於每次嘗試之間檢查,回傳true即停止遞補回報ABORTED(不中止進行中之嘗試)——供呼叫端於成果已無人接收時(如客戶端斷線)止損;經工作流層原樣轉傳,中止後各後續呼叫進門即回ABORTED令整條工作流快速收束;回調拋出例外視同false,預設null
|
|
270
286
|
* @param {*} [opt.meta=undefined] 輸入呼叫端自有資訊,保留鍵保證永不轉傳各轉接器,預設undefined
|
|
271
|
-
* @param {Function} [opt.onEvent=null] 輸入事件回調函數(ev)=>{},ev.type可為'try'、'ok'、'next-key'、'skip-group'、'budget-out'、'aborted'、'cooled'(冷卻觸發,帶error與cooldownMs,僅cooldownMs>0時出現);失敗事件(next-key/skip-group)另帶errorType、stdout(被拒回覆)與stderr(錯誤輸出)供診斷,後兩者於失敗路徑已由轉接器截斷;回調拋出例外不影響主流程,預設null
|
|
287
|
+
* @param {Function} [opt.onEvent=null] 輸入事件回調函數(ev)=>{},ev.type可為'try'、'ok'、'next-key'、'skip-group'、'budget-out'、'aborted'、'cooled'(冷卻觸發,帶error與cooldownMs,僅cooldownMs>0時出現)、'group-exhausted'(本組未成交而試完,每次呼叫每組恰一次,位於本組最後一個next-key或skip-group之後、下一組首個try之前;帶keys(有效金鑰數,0代表登入態之單一虛擬金鑰)、attempted(本組實際嘗試數)、by('all-keys'每把皆換鑰失敗,或'skip-group'以與金鑰無關之失敗收尾)、errorTypes(本組各次嘗試之errorType依序)與error(本組最後一次錯誤);成交、預算用盡、中止之組不發,亦不寫入tried);失敗事件(next-key/skip-group)另帶errorType、stdout(被拒回覆)與stderr(錯誤輸出)供診斷,後兩者於失敗路徑已由轉接器截斷;回調拋出例外不影響主流程,預設null
|
|
272
288
|
* @param {Number} [opt.timeoutMs=300000] 輸入各attempt共用之逾時毫秒正整數,條目可覆寫,全套件統一預設300000
|
|
273
289
|
* @param {String|Function} [opt.validate=undefined] 輸入各attempt共用之stdout驗證規則,條目可覆寫,預設undefined
|
|
290
|
+
* @param {Boolean} [opt.acceptTruncated=false] 輸入是否接受REST文字類轉接器回報之截斷內容布林值(原樣轉傳轉接器),1.0.37起截斷於validate之前判失敗,validate內含搶救策略者須給true,預設false
|
|
274
291
|
* @param {Number} [opt.maxRetries=0] 輸入各attempt共用之同家重試次數非負整數,韌性建議交給換家而非重試同一家,預設0
|
|
275
292
|
* @returns {Promise} 回傳Promise,resolve回傳結果物件,除execCli既有欄位(ok、stdout、stderr、code、error、durationMs、attempts、pid)外,追加providerId(實際使用之群組)、keyIndex(實際使用之金鑰索引,無keys時為null)、kind、model、tried(全部嘗試歷程陣列,成功時亦回傳;失敗項含errorType、stdout與stderr供診斷被拒原因);失敗結果帶機器可讀之errorType(一覽見getErrorType.mjs檔頭);api類轉接器提供usage(token用量)時原樣流出於結果與tried各項,CLI類無此欄;REST文字類轉接器另帶finishReason與truncated(是否截斷),同樣流出於結果與tried各項;本函數不會reject
|
|
276
293
|
* @example
|
|
@@ -419,6 +436,7 @@ async function dispatchAiFallback(prompt, opt = {}) {
|
|
|
419
436
|
//組內逐把嘗試, 每把至多一次, 全敗即組盡遞補下一組
|
|
420
437
|
let nAttempts = (nk > 0) ? nk : 1
|
|
421
438
|
let skipGroup = false
|
|
439
|
+
let groupStart = tried.length //本組於tried之起點, 供組盡事件取本組各次嘗試
|
|
422
440
|
for (let a = 0; a < nAttempts && !skipGroup; a++) {
|
|
423
441
|
|
|
424
442
|
//中止檢查(嘗試邊界): 成果已無人接收時止損, 不中止進行中之嘗試(見檔頭【中止】)
|
|
@@ -510,6 +528,20 @@ async function dispatchAiFallback(prompt, opt = {}) {
|
|
|
510
528
|
}
|
|
511
529
|
|
|
512
530
|
}
|
|
531
|
+
|
|
532
|
+
//組盡: 走到這裡即本組未成交且已試完(成交、預算用盡、中止皆於迴圈內回傳), 每次呼叫每組恰發一次(見檔頭【組盡事件】)
|
|
533
|
+
let tg = tried.slice(groupStart)
|
|
534
|
+
emit({
|
|
535
|
+
type: 'group-exhausted',
|
|
536
|
+
providerId: id,
|
|
537
|
+
keyIndex: null,
|
|
538
|
+
keyId: id,
|
|
539
|
+
keys: nk, //有效金鑰數(同上方濾法), 0代表登入態之單一虛擬金鑰
|
|
540
|
+
attempted: tg.length, //本組實際送出之嘗試數
|
|
541
|
+
by: skipGroup ? 'skip-group' : 'all-keys', //以與金鑰無關之失敗收尾, 或每把皆換鑰失敗
|
|
542
|
+
errorTypes: tg.map((t) => t.errorType), //本組各次嘗試之errorType, 依嘗試順序
|
|
543
|
+
error: get(lastResult, 'error', ''), //本組最後一次嘗試之錯誤
|
|
544
|
+
})
|
|
513
545
|
}
|
|
514
546
|
|
|
515
547
|
//全數失敗, 回傳最後一筆失敗結果(含其errorType)與完整歷程
|
|
@@ -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,8 @@ 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位元組(模擬壓縮或損壞本體)
|
|
28
|
+
// slow-401 — 延遲300ms後回401(與金鑰有關之失敗但耗時, 供組內預算用盡之情境)
|
|
27
29
|
// 其他 — 404
|
|
28
30
|
// 【responses之行為路由(依body.model)】
|
|
29
31
|
// echo — 200, message之output_text為JSON字串{ auth, body }
|
|
@@ -37,14 +39,14 @@ import zlib from 'zlib'
|
|
|
37
39
|
// incomplete-partial — 200, status為incomplete(max_output_tokens)但已有部分文字(可搶救之截斷陣列)
|
|
38
40
|
// incomplete-filter — 200, status為incomplete(content_filter)且有部分文字
|
|
39
41
|
// no-status — 200, 缺status欄但有文字(非截斷之不合規狀態)
|
|
40
|
-
// not-json/slow/err-500/flaky-429/br-noheader — 同chat/completions之對應行為
|
|
42
|
+
// not-json/slow/err-500/flaky-429/br-noheader/garbage-200 — 同chat/completions之對應行為
|
|
41
43
|
// 其他 — 401(同Zen實測: 未知model回401非404)
|
|
42
44
|
// 【systemone之行為路由(POST /v1/systemone, 依body.model; 形狀取自2026-09-17 TypeSafe實測)】
|
|
43
45
|
// echo / jev-latest — 200, 每題回{type:'noul', noul:0.5, echoAuth, echoBody}, 供斷言請求組成
|
|
44
46
|
// missing-answer — 200但answers缺最後一題
|
|
45
47
|
// no-answers — 200但無answers
|
|
46
48
|
// bad-question — 422 {detail:[{type:'union_tag_invalid',...}]}
|
|
47
|
-
// not-json/slow/err-500/flaky-429/br-noheader — 同chat/completions之對應行為
|
|
49
|
+
// not-json/slow/err-500/flaky-429/br-noheader/garbage-200 — 同chat/completions之對應行為
|
|
48
50
|
// 其他 — 400 {detail:{error_type:'api_usage_error', message:'Unknown model: X'}}
|
|
49
51
|
// 【金鑰規則】Authorization含'sk-bad'一律401(優先於model路由), 模擬無效金鑰;
|
|
50
52
|
// systemone路由之401本體採TypeSafe形狀{detail:{error_type:'authentication_error'}}。
|
|
@@ -117,6 +119,13 @@ async function fakeServerForApiTest() {
|
|
|
117
119
|
return
|
|
118
120
|
}
|
|
119
121
|
|
|
122
|
+
//garbage-200, 三種端點共用: 200且宣稱JSON, 本體卻是8個非JSON位元組(不論Accept-Encoding)
|
|
123
|
+
if (body.model === 'garbage-200') {
|
|
124
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
125
|
+
res.end(Buffer.from([0x8b, 0xef, 0x02, 0x00, 0xe4, 0xff, 0x11, 0x22]))
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
120
129
|
//無效金鑰, 模擬Zen之401形態(systemone則模擬TypeSafe之形態)
|
|
121
130
|
if (auth.includes('sk-bad')) {
|
|
122
131
|
res.writeHead(401, { 'Content-Type': 'application/json' })
|
|
@@ -359,6 +368,12 @@ async function fakeServerForApiTest() {
|
|
|
359
368
|
else if (model === 'br-noheader') {
|
|
360
369
|
sendUndeclaredBr({ choices: [{ finish_reason: 'stop', message: { role: 'assistant', content: '完成' } }] })
|
|
361
370
|
}
|
|
371
|
+
else if (model === 'slow-401') {
|
|
372
|
+
setTimeout(() => {
|
|
373
|
+
res.writeHead(401, { 'Content-Type': 'application/json' })
|
|
374
|
+
res.end(JSON.stringify({ type: 'error', error: { type: 'AuthError', message: 'Invalid API key.' } }))
|
|
375
|
+
}, 300)
|
|
376
|
+
}
|
|
362
377
|
else if (TRUNC_ROUTES[model] !== undefined) {
|
|
363
378
|
let t = TRUNC_ROUTES[model]
|
|
364
379
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|