w-dispatch-ai 1.0.36 → 1.0.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -7
- 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/describeNonJsonBody.mjs.html +122 -0
- package/docs/dfTimeoutMs.mjs.html +2 -2
- package/docs/dispatchAi.mjs.html +2 -2
- package/docs/dispatchAiFallback.mjs.html +42 -8
- package/docs/dispatchAiWkf.mjs.html +2 -2
- package/docs/dispatchAntigravity.mjs.html +2 -2
- package/docs/dispatchApiOpenaiCompat.mjs.html +124 -31
- package/docs/dispatchApiOpenaiResponses.mjs.html +113 -27
- package/docs/dispatchApiTypesafeSystemone.mjs.html +42 -17
- 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 +9 -5
- package/docs/global.html +3159 -1806
- 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 +64 -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 +13 -2
- package/package.json +1 -1
- package/src/checkTruncation.mjs +127 -0
- package/src/describeNonJsonBody.mjs +50 -0
- package/src/dispatchAiFallback.mjs +40 -6
- package/src/dispatchApiOpenaiCompat.mjs +122 -29
- package/src/dispatchApiOpenaiResponses.mjs +111 -25
- package/src/dispatchApiTypesafeSystemone.mjs +40 -15
- package/src/getErrorType.mjs +7 -3
- package/src/providers.mjs +1 -1
- package/src/wkf/callAiWithFallback.mjs +62 -18
- package/src/wkf/runFanout.mjs +2 -0
- package/src/wkf/salvageTruncatedArray.mjs +11 -0
- package/test/tools/fakeServerForApiTest.mjs +87 -2
- package/test/unit-dispatchApiOpenaiCompat.test.mjs +207 -1
- package/test/unit-dispatchApiOpenaiResponses.test.mjs +74 -5
- package/test/unit-dispatchApiTypesafeSystemone.test.mjs +25 -1
|
@@ -11,6 +11,8 @@ import castPintOr from './castPintOr.mjs'
|
|
|
11
11
|
import buildValidator from './buildValidator.mjs'
|
|
12
12
|
import getErrorResult from './getErrorResult.mjs'
|
|
13
13
|
import dfTimeoutMs from './dfTimeoutMs.mjs'
|
|
14
|
+
import { safeValidate } from './checkTruncation.mjs'
|
|
15
|
+
import describeNonJsonBody from './describeNonJsonBody.mjs'
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
// dispatchApiTypesafeSystemone.mjs — 以fetch直呼TypeSafe AI之System One API(POST /v1/systemone)
|
|
@@ -41,6 +43,11 @@ import dfTimeoutMs from './dfTimeoutMs.mjs'
|
|
|
41
43
|
// {detail:{error_type:'api_usage_error', message:'Unknown model: X'}}; 題型不合規或questions為空422
|
|
42
44
|
// {detail:[{type,loc,msg,...}]}。皆以HTTP <code>回報、原始本體置stderr; 4xx(429除外)不重試。
|
|
43
45
|
//
|
|
46
|
+
// 【預設帶Accept-Encoding: identity(2026-09-24起)】防伺服器壓縮卻漏標Content-Encoding而令fetch不解壓、
|
|
47
|
+
// JSON.parse失敗; 與dispatchApiOpenaiCompat同一決策與依據(見該檔檔頭; 本kind亦經Zen之/systemone), opt.headers可覆寫。
|
|
48
|
+
// HTTP 200但本體非JSON時另報INVALID_RESPONSE: body is not JSON(附原始位元組資訊), 與「缺answers」分開
|
|
49
|
+
// (同dispatchApiOpenaiCompat, 見describeNonJsonBody.mjs)。
|
|
50
|
+
//
|
|
44
51
|
// 【混用注意】
|
|
45
52
|
// 1. 答案形狀與文字模型完全不同, 不可與文字生成條目混在同一條dispatchAiFallback鏈中遞補;
|
|
46
53
|
// 同一鏈只放本kind之條目(可多把金鑰輪替)。預設providers.mjs收有typesafe:jev-latest,
|
|
@@ -99,7 +106,9 @@ async function callOnce(url, headers, body, ids, timeoutMs, validator) {
|
|
|
99
106
|
controller.abort()
|
|
100
107
|
}, timeoutMs)
|
|
101
108
|
|
|
109
|
+
//本體先取原始位元組再以UTF-8解碼(等同res.text()), 本體非JSON時才有原始位元組可供診斷(見describeNonJsonBody.mjs)
|
|
102
110
|
let res = null
|
|
111
|
+
let raw = new Uint8Array(0)
|
|
103
112
|
let txt = ''
|
|
104
113
|
try {
|
|
105
114
|
res = await fetch(url, {
|
|
@@ -108,7 +117,8 @@ async function callOnce(url, headers, body, ids, timeoutMs, validator) {
|
|
|
108
117
|
body: JSON.stringify(body),
|
|
109
118
|
signal: controller.signal,
|
|
110
119
|
})
|
|
111
|
-
|
|
120
|
+
raw = new Uint8Array(await res.arrayBuffer())
|
|
121
|
+
txt = new TextDecoder('utf-8').decode(raw)
|
|
112
122
|
}
|
|
113
123
|
catch (err) {
|
|
114
124
|
clearTimeout(timer)
|
|
@@ -138,6 +148,7 @@ async function callOnce(url, headers, body, ids, timeoutMs, validator) {
|
|
|
138
148
|
let answers = null
|
|
139
149
|
let modelResolved = ''
|
|
140
150
|
let usage = null
|
|
151
|
+
let parsed = true
|
|
141
152
|
try {
|
|
142
153
|
let j = JSON.parse(txt)
|
|
143
154
|
answers = get(j, 'answers', null)
|
|
@@ -151,7 +162,17 @@ async function callOnce(url, headers, body, ids, timeoutMs, validator) {
|
|
|
151
162
|
}
|
|
152
163
|
}
|
|
153
164
|
catch {
|
|
154
|
-
|
|
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
|
+
})
|
|
155
176
|
}
|
|
156
177
|
if (!isobj(answers)) {
|
|
157
178
|
return mkResult({
|
|
@@ -180,16 +201,20 @@ async function callOnce(url, headers, body, ids, timeoutMs, validator) {
|
|
|
180
201
|
//content, answers序列化為stdout(令遞補層與工作流層之parse/check通用)
|
|
181
202
|
let content = JSON.stringify(answers)
|
|
182
203
|
|
|
183
|
-
//validator, error與execCli一致令dispatchAiFallback
|
|
184
|
-
if (validator
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
204
|
+
//validator, error與execCli一致令dispatchAiFallback可統一分流; 拋錯視同拒絕(不reject), 訊息置stderr
|
|
205
|
+
if (validator) {
|
|
206
|
+
let v = safeValidate(validator, content)
|
|
207
|
+
if (!v.pass) {
|
|
208
|
+
return mkResult({
|
|
209
|
+
stdout: strTruncate(content, 500, optTruncate),
|
|
210
|
+
stderr: v.threw ? `validate threw: ${v.threw}` : '',
|
|
211
|
+
code: res.status,
|
|
212
|
+
error: 'OUTPUT_VALIDATION_FAILED',
|
|
213
|
+
errorType: 'validation',
|
|
214
|
+
usage,
|
|
215
|
+
modelResolved,
|
|
216
|
+
})
|
|
217
|
+
}
|
|
193
218
|
}
|
|
194
219
|
|
|
195
220
|
return mkResult({
|
|
@@ -225,9 +250,9 @@ async function callOnce(url, headers, body, ids, timeoutMs, validator) {
|
|
|
225
250
|
* @param {String} [opt.model='jev-latest'] 輸入模型名稱字串,預設'jev-latest'(另有'jev-preview')
|
|
226
251
|
* @param {String} [opt.key=''] 輸入API key字串,以Bearer置於Authorization標頭,預設''代表不帶認證標頭
|
|
227
252
|
* @param {Object} [opt.body={}] 輸入額外請求本體物件,將併入預設body(同名鍵以此為準),預設{}
|
|
228
|
-
* @param {Object} [opt.headers={}]
|
|
253
|
+
* @param {Object} [opt.headers={}] 輸入額外請求標頭物件,同名鍵覆寫預設標頭;預設標頭含'Accept-Encoding: identity'(防伺服器壓縮卻漏標Content-Encoding,見檔頭),預設{}
|
|
229
254
|
* @param {Number} [opt.timeoutMs=300000] 輸入逾時毫秒正整數,逾時將中止請求,全套件統一預設300000
|
|
230
|
-
* @param {String|Function} [opt.validate=undefined] 輸入stdout(answers之JSON字串)驗證規則字串或自訂驗證函數,規則字串支援'nonempty'、'json'、'min:100'
|
|
255
|
+
* @param {String|Function} [opt.validate=undefined] 輸入stdout(answers之JSON字串)驗證規則字串或自訂驗證函數,規則字串支援'nonempty'、'json'、'min:100',多規則可用逗號串接,自訂函數拋錯視同驗證失敗,預設undefined代表不驗證
|
|
231
256
|
* @param {Number} [opt.maxRetries=0] 輸入失敗後最大重試次數非負整數,4xx(429除外)不重試,預設0
|
|
232
257
|
* @param {Number} [opt.retryDelayMs=5000] 輸入重試間隔毫秒正整數,實際間隔為retryDelayMs乘以重試次數且上限15000ms,預設5000
|
|
233
258
|
* @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否成功布林值)、stdout(answers之JSON字串)、stderr(失敗時之原始回應本體)、code(HTTP狀態碼,網路錯誤與逾時為null)、error(錯誤訊息字串,成功時為空字串)、errorType(僅失敗時,機器可讀錯誤類別字串,一覽見getErrorType.mjs檔頭)、durationMs(耗時毫秒)、attempts(實際嘗試次數)、usage(原始回應之token用量物件原樣透傳,欄位名為input_tokens/output_tokens,無則null)、answers(成功時為已解析之答案物件,否則null)、modelResolved(回應所載之實際模型版本字串,如'jev-1.13.0',無則''),本函數不會reject
|
|
@@ -335,7 +360,7 @@ async function dispatchApiTypesafeSystemone(prompt, opt = {}) {
|
|
|
335
360
|
let ids = Object.keys(isobj(body.questions) ? body.questions : questions)
|
|
336
361
|
|
|
337
362
|
//headers
|
|
338
|
-
let headers = { 'Content-Type': 'application/json', ...headersExtra }
|
|
363
|
+
let headers = { 'Content-Type': 'application/json', 'Accept-Encoding': 'identity', ...headersExtra } //Accept-Encoding預設identity, 見檔頭
|
|
339
364
|
if (isestr(key)) {
|
|
340
365
|
headers['Authorization'] = `Bearer ${key}`
|
|
341
366
|
}
|
package/src/getErrorType.mjs
CHANGED
|
@@ -23,9 +23,13 @@ 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類)
|
|
27
|
-
//
|
|
28
|
-
//
|
|
26
|
+
// 或api-typesafe-systemone缺answers物件/缺所請求題目之答案, 僅api類);
|
|
27
|
+
// HTTP 200但本體非JSON時error另為'INVALID_RESPONSE: body is not JSON (...)'(附位元組資訊,
|
|
28
|
+
// 遞補層整組跳過; 見describeNonJsonBody.mjs), 「JSON缺欄位」則仍換金鑰
|
|
29
|
+
// 'incomplete' 回應未完整: 截斷(chat/completions之finish_reason為length/content_filter、
|
|
30
|
+
// Responses API之status為incomplete; 結果另帶truncated:true, 規則見checkTruncation.mjs)
|
|
31
|
+
// 與Responses API之其餘非completed狀態(如failed; truncated:false)。僅api類;
|
|
32
|
+
// 半截內容預設不當成功回傳, 呼叫端明示acceptTruncated才可放行(2026-09-24起兩轉接器同規則)
|
|
29
33
|
// 'aborted' shouldStop中止(僅dispatchAiFallback)
|
|
30
34
|
// 'budget' 時間預算用盡(僅dispatchAiFallback)
|
|
31
35
|
|
package/src/providers.mjs
CHANGED
|
@@ -398,7 +398,7 @@ let providers = [
|
|
|
398
398
|
body: { max_tokens: 32768 },
|
|
399
399
|
//2026-09-24新增: 對話型免費模型中少數REST可通者(免費層閘門未套用, 見檔頭; 例外可能隨時收回)——
|
|
400
400
|
//匿名1.9s、第1把金鑰2.0s皆200。max_tokens刻意不用zen條目慣例之8192: 此為推理模型, 推理token計入max_tokens,
|
|
401
|
-
//同日實測列10縣市一題即用5222(推理4849+正文373), 8192
|
|
401
|
+
//同日實測列10縣市一題即用5222(推理4849+正文373), 8192易截斷(轉接器遇截斷預設判失敗換家, 見checkTruncation.mjs);
|
|
402
402
|
//長文(README前8000字)摘要成JSON一題用3385(推理3045), 三題finish_reason皆stop, 故取32768留約6倍餘裕。
|
|
403
403
|
},
|
|
404
404
|
|
|
@@ -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,19 @@ 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
|
+
// 注意此自動同意只在本層: 直接呼叫dispatchAiFallback或轉接器、把搶救寫在validate裡者須自給acceptTruncated:true。
|
|
37
|
+
// CLI類轉接器拿不到終止訊號, 截斷不可判(已知限制)。
|
|
25
38
|
//
|
|
26
39
|
// 【防寫檔前綴】agentic CLI之cwd不是隔離邊界(可用絕對路徑寫到cwd外),
|
|
27
40
|
// 故預設在prompt前掛「禁止建檔」約束(實測有效); 不需要時傳promptPrefix:''關閉。
|
|
@@ -90,16 +103,17 @@ function buildChain(providers, spec) {
|
|
|
90
103
|
* @param {Function} [opt.check=null] 輸入結果檢核函數(json)=>Boolean,預設null代表只要能解析出JSON即通過
|
|
91
104
|
* @param {Function} [opt.parse=extractJsonLoose] 輸入回覆解析函數(stdout)=>Object|null,預設寬鬆JSON抽取
|
|
92
105
|
* @param {Boolean} [opt.rawText=false] 輸入是否以純文字模式運作布林值,true代表不解析JSON(json欄位為修剪後文字、check收文字),預設false
|
|
106
|
+
* @param {Boolean} [opt.acceptTruncated] 輸入是否接受REST文字類轉接器回報之截斷內容(交parse+check裁決)布林值,預設為「有給自訂parse且非rawText」(自訂parse即搶救策略之同意訊號,見salvageTruncatedArray.mjs),其餘情況截斷一律判失敗換家
|
|
93
107
|
* @param {String} [opt.promptPrefix=防寫檔約束] 輸入prompt前綴字串,預設為防寫檔約束(見wkf/noSideEffectPrefix.mjs),傳''關閉
|
|
94
108
|
* @param {Number} [opt.timeoutMs=300000] 輸入單次嘗試逾時毫秒正整數,全套件統一預設300000
|
|
95
109
|
* @param {Number} [opt.budgetMs=null] 輸入整條遞補鏈之時間預算毫秒正整數,預設null代表不限
|
|
96
110
|
* @param {Number} [opt.minAttemptMs=20000] 輸入搭配budgetMs之開工門檻毫秒正整數,剩餘預算低於此值即不再開工,預設20000
|
|
97
|
-
* @param {Number} [opt.maxRetries=0] 輸入同家重試次數非負整數,預設0(
|
|
111
|
+
* @param {Number} [opt.maxRetries=0] 輸入同家重試次數非負整數,預設0(韌性交給遞補;端點不穩偶發空回之模型可調高令同鍵重試;截斷不重試)
|
|
98
112
|
* @param {String} [opt.cwd=process.cwd()] 輸入子進程工作目錄字串,預設process.cwd()
|
|
99
113
|
* @param {Object} [opt.store=null] 輸入游標持久化物件{get,set},預設null代表用行程內記憶體
|
|
100
114
|
* @param {*} [opt.meta=undefined] 輸入呼叫端自有資訊(分類、標籤、註記),保留鍵保證永不轉傳下層,預設undefined
|
|
101
115
|
* @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
|
|
116
|
+
* @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
117
|
* @example
|
|
104
118
|
* //need cli in system PATH
|
|
105
119
|
*
|
|
@@ -152,7 +166,8 @@ async function callAiWithFallback(prompt, opt = {}) {
|
|
|
152
166
|
|
|
153
167
|
let rawText = get(opt, 'rawText', false) === true
|
|
154
168
|
let parse = get(opt, 'parse', null)
|
|
155
|
-
|
|
169
|
+
let parseGiven = isfun(parse)
|
|
170
|
+
if (!parseGiven) {
|
|
156
171
|
parse = extractJsonLoose
|
|
157
172
|
}
|
|
158
173
|
let check = get(opt, 'check', null)
|
|
@@ -160,44 +175,71 @@ async function callAiWithFallback(prompt, opt = {}) {
|
|
|
160
175
|
check = null
|
|
161
176
|
}
|
|
162
177
|
|
|
178
|
+
//acceptTruncated, 顯式布林值優先; 預設「有自訂parse且非rawText」——自訂parse即搶救策略之同意訊號(見檔頭【截斷】)
|
|
179
|
+
let acceptTruncated = get(opt, 'acceptTruncated', null)
|
|
180
|
+
if (!isbol(acceptTruncated)) {
|
|
181
|
+
acceptTruncated = parseGiven && !rawText
|
|
182
|
+
}
|
|
183
|
+
|
|
163
184
|
let promptPrefix = get(opt, 'promptPrefix', null)
|
|
164
185
|
if (!isestr(promptPrefix)) {
|
|
165
186
|
promptPrefix = (promptPrefix === '') ? '' : NO_SIDE_EFFECT
|
|
166
187
|
}
|
|
167
188
|
|
|
168
|
-
//validate接進遞補層: 非法回覆=這一家失敗,
|
|
189
|
+
//validate接進遞補層: 非法回覆=這一家失敗, 遞補層換下一家; parse/check拋錯視同非法(不reject)
|
|
169
190
|
let validate = (stdout) => {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
191
|
+
try {
|
|
192
|
+
if (rawText) {
|
|
193
|
+
let s = String(stdout || '').trim()
|
|
194
|
+
if (s === '') {
|
|
195
|
+
return false
|
|
196
|
+
}
|
|
197
|
+
return check ? check(s) === true : true
|
|
198
|
+
}
|
|
199
|
+
let j = parse(stdout)
|
|
200
|
+
if (j === null) {
|
|
173
201
|
return false
|
|
174
202
|
}
|
|
175
|
-
return check ? check(
|
|
203
|
+
return check ? check(j) === true : true
|
|
176
204
|
}
|
|
177
|
-
|
|
178
|
-
if (j === null) {
|
|
205
|
+
catch {
|
|
179
206
|
return false
|
|
180
207
|
}
|
|
181
|
-
return check ? check(j) === true : true
|
|
182
208
|
}
|
|
183
209
|
|
|
184
|
-
|
|
210
|
+
//條目自帶validate時與本層validate取交集(兩者皆過才算過), 免遞補層之條目覆寫令本層驗證被繞過(見檔頭)
|
|
211
|
+
chain = chain.map((e) => {
|
|
212
|
+
let ev = buildValidator(get(e, 'validate', null))
|
|
213
|
+
if (ev === null) {
|
|
214
|
+
return e
|
|
215
|
+
}
|
|
216
|
+
return { ...e, validate: (s) => safeValidate(ev, s).pass && validate(s) }
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
//剔除本層自用鍵後原樣轉傳(含minAttemptMs/retryDelayMs/maxBuffer等), providers、validate與acceptTruncated由本層給定
|
|
185
220
|
let r = await dispatchAiFallback(promptPrefix + prompt, {
|
|
186
221
|
...omit(opt, OWN_KEYS),
|
|
187
222
|
providers: chain,
|
|
188
223
|
validate,
|
|
224
|
+
acceptTruncated,
|
|
189
225
|
timeoutMs: get(opt, 'timeoutMs', null) || dfTimeoutMs, //全套件統一預設300000
|
|
190
226
|
})
|
|
191
227
|
|
|
192
|
-
//result, 已過validate故此處parse必然成功(同一解析器),
|
|
228
|
+
//result, 已過validate故此處parse必然成功(同一解析器), 重解析僅為取出物件; 防禦性接住拋錯
|
|
193
229
|
let result = null
|
|
194
230
|
if (r.ok) {
|
|
195
|
-
|
|
231
|
+
try {
|
|
232
|
+
result = rawText ? String(r.stdout || '').trim() : parse(r.stdout)
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
result = null
|
|
236
|
+
}
|
|
196
237
|
}
|
|
238
|
+
let ok = r.ok && result !== null
|
|
197
239
|
let providerId = get(r, 'providerId', null)
|
|
198
240
|
let keyIndex = get(r, 'keyIndex', null)
|
|
199
241
|
return {
|
|
200
|
-
ok
|
|
242
|
+
ok,
|
|
201
243
|
json: result, //rawText模式下此欄為文字
|
|
202
244
|
providerId,
|
|
203
245
|
keyIndex,
|
|
@@ -205,8 +247,10 @@ async function callAiWithFallback(prompt, opt = {}) {
|
|
|
205
247
|
ms: Date.now() - t0,
|
|
206
248
|
tried: get(r, 'tried', []),
|
|
207
249
|
usage: get(r, 'usage', null), //api類轉接器之token用量原樣透傳, CLI類為null(無可靠來源)
|
|
208
|
-
|
|
209
|
-
|
|
250
|
+
finishReason: get(r, 'finishReason', ''), //REST文字類之正規化終止原因, CLI類為''
|
|
251
|
+
truncated: get(r, 'truncated', false) === true, //接受搶救之截斷內容時ok亦可能為true
|
|
252
|
+
error: ok ? '' : (r.ok ? 'OUTPUT_VALIDATION_FAILED' : get(r, 'error', 'unknown error')),
|
|
253
|
+
...(ok ? {} : { errorType: r.ok ? 'validation' : get(r, 'errorType', 'exec') }), //機器可讀錯誤類別, 僅失敗時
|
|
210
254
|
}
|
|
211
255
|
}
|
|
212
256
|
|
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,17 @@
|
|
|
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可辨識救回者為半批; 直接呼叫dispatchAiFallback或轉接器、
|
|
20
|
+
// 且把本工具寫在validate裡者須自給acceptTruncated:true(1.0.37起, 否則截斷在validate之前即判失敗;
|
|
21
|
+
// 安裝方實例: w-knowledge-extract 1.0.1之callJson因此失去搶救, 2026-09-24回報)。
|
|
22
|
+
// CLI類拿不到終止訊號, 其截斷只能靠本工具於解析時發現。
|
|
23
|
+
//
|
|
24
|
+
// 【已知限制(2026-09-24複審指出, 讀碼確認)】只記錄頂層「物件」元素之結束(`}`), 故元素非物件之陣列
|
|
25
|
+
// (如["a","b",…)救不回; 自第一個`[`起算, 故外包物件(如{"items":[…)會救回內層陣列(形狀改變,
|
|
26
|
+
// 呼叫端check須能接受), 前言含`[`(如「[注意]」)則誤判為已閉合而回null。
|
|
16
27
|
|
|
17
28
|
|
|
18
29
|
/**
|
|
@@ -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,10 @@ 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(截斷處理之規格測試用, 見下方常數)
|
|
27
|
+
// garbage-200 — 200, Content-Type為application/json但本體為8個非JSON位元組(模擬壓縮或損壞本體)
|
|
23
28
|
// 其他 — 404
|
|
24
29
|
// 【responses之行為路由(依body.model)】
|
|
25
30
|
// echo — 200, message之output_text為JSON字串{ auth, body }
|
|
@@ -30,19 +35,40 @@ import http from 'http'
|
|
|
30
35
|
// reasoning-only — 200且completed但output僅reasoning無message(結構不合規)
|
|
31
36
|
// tool-calls — 200但output含function_call元素
|
|
32
37
|
// no-output — 200但缺output陣列
|
|
33
|
-
//
|
|
38
|
+
// incomplete-partial — 200, status為incomplete(max_output_tokens)但已有部分文字(可搶救之截斷陣列)
|
|
39
|
+
// incomplete-filter — 200, status為incomplete(content_filter)且有部分文字
|
|
40
|
+
// no-status — 200, 缺status欄但有文字(非截斷之不合規狀態)
|
|
41
|
+
// not-json/slow/err-500/flaky-429/br-noheader/garbage-200 — 同chat/completions之對應行為
|
|
34
42
|
// 其他 — 401(同Zen實測: 未知model回401非404)
|
|
35
43
|
// 【systemone之行為路由(POST /v1/systemone, 依body.model; 形狀取自2026-09-17 TypeSafe實測)】
|
|
36
44
|
// echo / jev-latest — 200, 每題回{type:'noul', noul:0.5, echoAuth, echoBody}, 供斷言請求組成
|
|
37
45
|
// missing-answer — 200但answers缺最後一題
|
|
38
46
|
// no-answers — 200但無answers
|
|
39
47
|
// bad-question — 422 {detail:[{type:'union_tag_invalid',...}]}
|
|
40
|
-
// not-json/slow/err-500/flaky-429 — 同chat/completions之對應行為
|
|
48
|
+
// not-json/slow/err-500/flaky-429/br-noheader/garbage-200 — 同chat/completions之對應行為
|
|
41
49
|
// 其他 — 400 {detail:{error_type:'api_usage_error', message:'Unknown model: X'}}
|
|
42
50
|
// 【金鑰規則】Authorization含'sk-bad'一律401(優先於model路由), 模擬無效金鑰;
|
|
43
51
|
// systemone路由之401本體採TypeSafe形狀{detail:{error_type:'authentication_error'}}。
|
|
44
52
|
|
|
45
53
|
|
|
54
|
+
//TRUNC_ROUTES, chat/completions之截斷與終止原因情境(2026-09-24截斷處理之規格測試):
|
|
55
|
+
//fr為finish_reason原值(含大小寫與null), content為message.content原值(含null與純空白)
|
|
56
|
+
let TRUNC_ROUTES = {
|
|
57
|
+
'trunc-empty': { fr: 'length', content: '', usage: { prompt_tokens: 181, completion_tokens: 600, total_tokens: 781, completion_tokens_details: { reasoning_tokens: 600 } } }, //推理耗盡(Zen實測形態)
|
|
58
|
+
'trunc-null': { fr: 'length', content: null },
|
|
59
|
+
'trunc-space': { fr: 'length', content: '\n\n' },
|
|
60
|
+
'trunc-array': { fr: 'length', content: '[{"a":1},{"b":2},{"c":' }, //可搶救前段
|
|
61
|
+
'trunc-text': { fr: 'length', content: '第一段說明,第二' },
|
|
62
|
+
'trunc-tail': { fr: 'length', content: '{"a":1}\n\n說明:因為' }, //完整載荷+截尾(寬鬆解析可過)
|
|
63
|
+
'trunc-upper': { fr: 'LENGTH', content: '[{"a":1},{"b":' }, //大小寫不同
|
|
64
|
+
'filtered': { fr: 'content_filter', content: '部分內容' },
|
|
65
|
+
'finish-stop': { fr: 'stop', content: '完成' },
|
|
66
|
+
'finish-null': { fr: null, content: '完成' },
|
|
67
|
+
'finish-other': { fr: 'eos', content: '完成' }, //未知值, 不可誤殺
|
|
68
|
+
'plain-content': { fr: 'stop', content: 'hello world' }, //非JSON純文字
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
46
72
|
/**
|
|
47
73
|
* 啟動測試用OpenAI相容假伺服器
|
|
48
74
|
*
|
|
@@ -73,6 +99,14 @@ async function fakeServerForApiTest() {
|
|
|
73
99
|
|
|
74
100
|
let auth = req.headers['authorization'] || ''
|
|
75
101
|
|
|
102
|
+
//sendUndeclaredBr, br-noheader路由用: 客戶端明示identity才回原文, 否則回brotli本體且刻意不帶Content-Encoding
|
|
103
|
+
let ae = String(req.headers['accept-encoding'] || '').trim().toLowerCase()
|
|
104
|
+
let sendUndeclaredBr = (obj) => {
|
|
105
|
+
let buf = Buffer.from(JSON.stringify(obj))
|
|
106
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
107
|
+
res.end(ae === 'identity' ? buf : zlib.brotliCompressSync(buf))
|
|
108
|
+
}
|
|
109
|
+
|
|
76
110
|
//body非JSON → 400
|
|
77
111
|
let body = null
|
|
78
112
|
try {
|
|
@@ -84,6 +118,13 @@ async function fakeServerForApiTest() {
|
|
|
84
118
|
return
|
|
85
119
|
}
|
|
86
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
|
+
|
|
87
128
|
//無效金鑰, 模擬Zen之401形態(systemone則模擬TypeSafe之形態)
|
|
88
129
|
if (auth.includes('sk-bad')) {
|
|
89
130
|
res.writeHead(401, { 'Content-Type': 'application/json' })
|
|
@@ -129,6 +170,13 @@ async function fakeServerForApiTest() {
|
|
|
129
170
|
else if (model === 'bad-question') {
|
|
130
171
|
send(422, { detail: [{ type: 'union_tag_invalid', loc: ['body', 'questions', ids[0]], msg: 'Input tag does not match any of the expected tags' }] })
|
|
131
172
|
}
|
|
173
|
+
else if (model === 'br-noheader') {
|
|
174
|
+
let answers = {}
|
|
175
|
+
for (let id of ids) {
|
|
176
|
+
answers[id] = { type: 'noul', noul: 0.5 }
|
|
177
|
+
}
|
|
178
|
+
sendUndeclaredBr({ model: 'jev-1.13.0', answers, usage })
|
|
179
|
+
}
|
|
132
180
|
else if (model === 'not-json') {
|
|
133
181
|
res.writeHead(200, { 'Content-Type': 'text/plain' })
|
|
134
182
|
res.end('plain text body')
|
|
@@ -217,6 +265,32 @@ async function fakeServerForApiTest() {
|
|
|
217
265
|
else if (model === 'no-output') { //缺output陣列
|
|
218
266
|
send(200, { status: 'completed', model, id: 'resp_y' })
|
|
219
267
|
}
|
|
268
|
+
else if (model === 'incomplete-partial') { //max_output_tokens耗盡但已有部分文字
|
|
269
|
+
send(200, {
|
|
270
|
+
status: 'incomplete',
|
|
271
|
+
model,
|
|
272
|
+
error: null,
|
|
273
|
+
incomplete_details: { reason: 'max_output_tokens' },
|
|
274
|
+
output: [{ type: 'message', content: [{ type: 'output_text', text: '[{"a":1},{"b":2},{"c":' }] }],
|
|
275
|
+
usage: { ...usage, output_tokens_details: { reasoning_tokens: 9 } },
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
else if (model === 'incomplete-filter') {
|
|
279
|
+
send(200, { status: 'incomplete', model, error: null, incomplete_details: { reason: 'content_filter' }, output: [{ type: 'message', content: [{ type: 'output_text', text: '部分' }] }], usage })
|
|
280
|
+
}
|
|
281
|
+
else if (model === 'no-status') {
|
|
282
|
+
send(200, { model, error: null, incomplete_details: null, output: [{ type: 'message', content: [{ type: 'output_text', text: 'x' }] }], usage })
|
|
283
|
+
}
|
|
284
|
+
else if (model === 'br-noheader') {
|
|
285
|
+
sendUndeclaredBr({
|
|
286
|
+
status: 'completed',
|
|
287
|
+
model,
|
|
288
|
+
error: null,
|
|
289
|
+
incomplete_details: null,
|
|
290
|
+
output: [{ type: 'message', content: [{ type: 'output_text', text: '完成' }] }],
|
|
291
|
+
usage,
|
|
292
|
+
})
|
|
293
|
+
}
|
|
220
294
|
else if (model === 'not-json') {
|
|
221
295
|
res.writeHead(200, { 'Content-Type': 'text/plain' })
|
|
222
296
|
res.end('plain text body')
|
|
@@ -290,6 +364,17 @@ async function fakeServerForApiTest() {
|
|
|
290
364
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
291
365
|
res.end(JSON.stringify({ id: 'x', object: 'chat.completion' }))
|
|
292
366
|
}
|
|
367
|
+
else if (model === 'br-noheader') {
|
|
368
|
+
sendUndeclaredBr({ choices: [{ finish_reason: 'stop', message: { role: 'assistant', content: '完成' } }] })
|
|
369
|
+
}
|
|
370
|
+
else if (TRUNC_ROUTES[model] !== undefined) {
|
|
371
|
+
let t = TRUNC_ROUTES[model]
|
|
372
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
373
|
+
res.end(JSON.stringify({
|
|
374
|
+
choices: [{ finish_reason: t.fr, message: { role: 'assistant', content: t.content } }],
|
|
375
|
+
usage: t.usage || { prompt_tokens: 3, completion_tokens: 7, total_tokens: 10 },
|
|
376
|
+
}))
|
|
377
|
+
}
|
|
293
378
|
else if (model === 'not-json') {
|
|
294
379
|
res.writeHead(200, { 'Content-Type': 'text/plain' })
|
|
295
380
|
res.end('plain text body')
|