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
|
@@ -10,6 +10,8 @@ import buildValidator from './buildValidator.mjs'
|
|
|
10
10
|
import strTruncate from 'wsemi/src/strTruncate.mjs'
|
|
11
11
|
import getErrorResult from './getErrorResult.mjs'
|
|
12
12
|
import dfTimeoutMs from './dfTimeoutMs.mjs'
|
|
13
|
+
import { TRUNCATION_REASONS, normalizeFinishReason, safeValidate, judgeTruncated } from './checkTruncation.mjs'
|
|
14
|
+
import describeNonJsonBody from './describeNonJsonBody.mjs'
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
// dispatchApiOpenaiCompat.mjs — 以fetch直呼OpenAI相容API(chat/completions)
|
|
@@ -39,17 +41,39 @@ import dfTimeoutMs from './dfTimeoutMs.mjs'
|
|
|
39
41
|
// 故呼叫端若於body帶入tools, 本函數一律以TOOL_CALLS_UNSUPPORTED回報失敗而不假裝成功
|
|
40
42
|
// (實測Agnes於tool_calls時content為"\n\n"而非null, 不特別處理會靜默回傳空白內容)。
|
|
41
43
|
//
|
|
42
|
-
//
|
|
44
|
+
// 【預設帶Accept-Encoding: identity(2026-09-24起, 三個REST轉接器同步)】Node內建fetch(undici)只在回應
|
|
45
|
+
// 帶Content-Encoding時才自動解壓; 伺服器若壓縮了本體卻漏標此標頭, fetch原樣交出壓縮位元組,
|
|
46
|
+
// JSON.parse失敗而回INVALID_RESPONSE(使用端回報Zen之space-bunny-free即此症: 手動brotli解壓得完整答案,
|
|
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請伺服器勿壓縮, 從源頭消除「壓縮處理不一致」一類失敗(不論成因在伺服器、代理或執行環境),
|
|
52
|
+
// 代價僅傳輸量變大(實測Zen回應約5~8KB→20~65KB)。呼叫端可以opt.headers之'Accept-Encoding'覆寫。
|
|
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
|
+
//
|
|
58
|
+
// 【截斷(finish_reason為length或content_filter)預設失敗(2026-09-24起, 規則單一來源見checkTruncation.mjs)】
|
|
59
|
+
// 舊版不看finish_reason, 實測Zen之space-bunny-free於max_tokens:600時推理即耗盡而回content:""、
|
|
60
|
+
// 本轉接器卻回ok:true(靜默成功)。現於null檢查與validate之前裁定: 預設回INCOMPLETE_RESPONSE
|
|
61
|
+
// (errorType incomplete, 結果帶truncated:true), 不論validate為何——「validate接受」不代表內容完整。
|
|
62
|
+
// 呼叫端明示acceptTruncated:true才放行length之截斷(交validate裁決, 通過者仍標truncated:true);
|
|
63
|
+
// content_filter與可見輸出為空者一律失敗。finish_reason為stop/null/未知值者照舊(不可誤殺)。
|
|
64
|
+
//
|
|
65
|
+
// 【重試語意對齊execCli】4xx(429除外)為客戶端錯誤不可重試而立即中止; 截斷亦不重試(同一請求必然再截斷);
|
|
43
66
|
// 429/5xx/網路錯誤/逾時依maxRetries線性退避重試(間隔retryDelayMs*次數, 上限15000ms)。
|
|
44
67
|
//
|
|
45
68
|
// 【結果結構對齊execCli】{ ok, stdout, stderr, code, error, durationMs, attempts, usage },
|
|
46
69
|
// usage為原始回應之token用量原樣透傳(無則null; 驗證失敗等已耗token之失敗亦帶出),
|
|
47
70
|
// CLI類轉接器無可靠來源故無此欄——呼叫端可據此把「真實用量」與「只能估算」分開處理。
|
|
71
|
+
// 取得並解析回應後之結果另帶finishReason(正規化終止原因, 缺值為'')與truncated(是否截斷)。
|
|
48
72
|
// 失敗結果另帶機器可讀之errorType(timeout/fetch/http/tool-unsupported/invalid-response/
|
|
49
|
-
// validation/params, 一覽見getErrorType.mjs檔頭), error字串保留不動, 兩者並存。
|
|
73
|
+
// incomplete/validation/params, 一覽見getErrorType.mjs檔頭), error字串保留不動, 兩者並存。
|
|
50
74
|
// stdout為回覆內容、code為HTTP狀態碼(網路錯誤與逾時為null)、逾時error以TIMEOUT開頭、
|
|
51
|
-
// 驗證失敗error為OUTPUT_VALIDATION_FAILED
|
|
52
|
-
//
|
|
75
|
+
// 驗證失敗error為OUTPUT_VALIDATION_FAILED(validate拋錯亦同, 拋錯訊息置stderr)——
|
|
76
|
+
// dispatchAiFallback據此分流: 逾時/驗證失敗/截斷/工具不支援/本體非JSON整組跳過, 其餘換金鑰。
|
|
53
77
|
|
|
54
78
|
|
|
55
79
|
//預設值
|
|
@@ -72,9 +96,10 @@ let optTruncate = {
|
|
|
72
96
|
* @param {Object} body 輸入請求本體物件
|
|
73
97
|
* @param {Number} timeoutMs 輸入逾時毫秒
|
|
74
98
|
* @param {Function|null} validator 輸入驗證函式
|
|
99
|
+
* @param {Boolean} acceptTruncated 輸入是否明示接受截斷內容
|
|
75
100
|
* @returns {Promise} 回傳Promise,resolve回傳結果物件
|
|
76
101
|
*/
|
|
77
|
-
async function callOnce(url, headers, body, timeoutMs, validator) {
|
|
102
|
+
async function callOnce(url, headers, body, timeoutMs, validator, acceptTruncated) {
|
|
78
103
|
|
|
79
104
|
let t0 = Date.now()
|
|
80
105
|
|
|
@@ -97,7 +122,9 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
|
|
|
97
122
|
controller.abort()
|
|
98
123
|
}, timeoutMs)
|
|
99
124
|
|
|
125
|
+
//本體先取原始位元組再以UTF-8解碼(等同res.text()), 本體非JSON時才有原始位元組可供診斷(見describeNonJsonBody.mjs)
|
|
100
126
|
let res = null
|
|
127
|
+
let raw = new Uint8Array(0)
|
|
101
128
|
let txt = ''
|
|
102
129
|
try {
|
|
103
130
|
res = await fetch(url, {
|
|
@@ -106,7 +133,8 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
|
|
|
106
133
|
body: JSON.stringify(body),
|
|
107
134
|
signal: controller.signal,
|
|
108
135
|
})
|
|
109
|
-
|
|
136
|
+
raw = new Uint8Array(await res.arrayBuffer())
|
|
137
|
+
txt = new TextDecoder('utf-8').decode(raw)
|
|
110
138
|
}
|
|
111
139
|
catch (err) {
|
|
112
140
|
clearTimeout(timer)
|
|
@@ -134,20 +162,39 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
|
|
|
134
162
|
|
|
135
163
|
//取出choices[0]與usage(token用量, 原樣透傳; 失敗回應亦可能已耗token, 一併帶出)
|
|
136
164
|
let content = null
|
|
137
|
-
let
|
|
165
|
+
let finishReasonRaw = null
|
|
138
166
|
let toolCalls = null
|
|
139
167
|
let usage = null
|
|
168
|
+
let parsed = true
|
|
140
169
|
try {
|
|
141
170
|
let j = JSON.parse(txt)
|
|
142
171
|
content = get(j, 'choices.0.message.content', null)
|
|
143
|
-
|
|
172
|
+
finishReasonRaw = get(j, 'choices.0.finish_reason', null)
|
|
144
173
|
toolCalls = get(j, 'choices.0.message.tool_calls', null)
|
|
145
174
|
usage = get(j, 'usage', null)
|
|
146
175
|
if (!isobj(usage)) {
|
|
147
176
|
usage = null
|
|
148
177
|
}
|
|
149
178
|
}
|
|
150
|
-
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
|
+
}
|
|
194
|
+
|
|
195
|
+
//fin, 自此起之每個結果皆外顯正規化終止原因與是否截斷(規則見checkTruncation.mjs)
|
|
196
|
+
let finishReason = normalizeFinishReason(finishReasonRaw)
|
|
197
|
+
let fin = { finishReason, truncated: TRUNCATION_REASONS.includes(finishReason) }
|
|
151
198
|
|
|
152
199
|
//tool_calls, 本轉接器不支援工具迴圈(見檔頭), 明確回報而不假裝成功
|
|
153
200
|
//(Agnes於tool_calls時content為"\n\n"非null, 不攔截會靜默回傳空白內容)
|
|
@@ -158,31 +205,67 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
|
|
|
158
205
|
error: 'TOOL_CALLS_UNSUPPORTED: use a cli kind (opencode/claude/codex/antigravity) when tools are needed',
|
|
159
206
|
errorType: 'tool-unsupported',
|
|
160
207
|
usage,
|
|
208
|
+
...fin,
|
|
161
209
|
})
|
|
162
210
|
}
|
|
163
211
|
|
|
164
|
-
|
|
212
|
+
//content字串化(少數閘道回array形態); null/undefined一律視為無內容
|
|
213
|
+
if (content === undefined) {
|
|
214
|
+
content = null
|
|
215
|
+
}
|
|
216
|
+
if (content !== null && typeof content !== 'string') {
|
|
217
|
+
content = JSON.stringify(content)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
//截斷, 於null檢查與validate之前裁定: 預設失敗, 明示acceptTruncated才交validate放行(見檔頭)
|
|
221
|
+
if (fin.truncated) {
|
|
222
|
+
let d = judgeTruncated({
|
|
223
|
+
finishReason,
|
|
224
|
+
content,
|
|
225
|
+
acceptTruncated,
|
|
226
|
+
validator,
|
|
227
|
+
reasoningTokens: get(usage, 'completion_tokens_details.reasoning_tokens', null),
|
|
228
|
+
label: `finish_reason=${finishReason}`,
|
|
229
|
+
})
|
|
230
|
+
if (!d.accept) {
|
|
231
|
+
return mkResult({
|
|
232
|
+
stdout: strTruncate(content || '', 500, optTruncate),
|
|
233
|
+
stderr: strTruncate((d.threw ? `validate threw: ${d.threw}\n` : '') + txt, 500, optTruncate),
|
|
234
|
+
code: res.status,
|
|
235
|
+
error: d.error,
|
|
236
|
+
errorType: 'incomplete',
|
|
237
|
+
usage,
|
|
238
|
+
...fin,
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
return mkResult({ ok: true, stdout: content, code: res.status, usage, ...fin })
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (content === null) {
|
|
165
245
|
return mkResult({
|
|
166
246
|
stderr: strTruncate(txt, 500, optTruncate),
|
|
167
247
|
code: res.status,
|
|
168
248
|
error: 'INVALID_RESPONSE: missing choices[0].message.content',
|
|
169
249
|
errorType: 'invalid-response',
|
|
170
250
|
usage,
|
|
251
|
+
...fin,
|
|
171
252
|
})
|
|
172
253
|
}
|
|
173
|
-
if (typeof content !== 'string') {
|
|
174
|
-
content = JSON.stringify(content) //少數閘道回array形態
|
|
175
|
-
}
|
|
176
254
|
|
|
177
|
-
//validator, error與execCli一致令dispatchAiFallback
|
|
178
|
-
if (validator
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
255
|
+
//validator, error與execCli一致令dispatchAiFallback可統一分流; 拋錯視同拒絕(不reject), 訊息置stderr
|
|
256
|
+
if (validator) {
|
|
257
|
+
let v = safeValidate(validator, content)
|
|
258
|
+
if (!v.pass) {
|
|
259
|
+
return mkResult({
|
|
260
|
+
stdout: strTruncate(content, 500, optTruncate),
|
|
261
|
+
stderr: v.threw ? `validate threw: ${v.threw}` : '',
|
|
262
|
+
code: res.status,
|
|
263
|
+
error: 'OUTPUT_VALIDATION_FAILED',
|
|
264
|
+
errorType: 'validation',
|
|
265
|
+
usage,
|
|
266
|
+
...fin,
|
|
267
|
+
})
|
|
268
|
+
}
|
|
186
269
|
}
|
|
187
270
|
|
|
188
271
|
return mkResult({
|
|
@@ -190,6 +273,7 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
|
|
|
190
273
|
stdout: content,
|
|
191
274
|
code: res.status,
|
|
192
275
|
usage,
|
|
276
|
+
...fin,
|
|
193
277
|
})
|
|
194
278
|
}
|
|
195
279
|
|
|
@@ -216,12 +300,13 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
|
|
|
216
300
|
* @param {String} [opt.key=''] 輸入API key字串,以Bearer置於Authorization標頭,預設''代表不帶認證標頭
|
|
217
301
|
* @param {String} [opt.system=''] 輸入system提示詞字串,將以system角色置於messages首位,預設''代表不帶
|
|
218
302
|
* @param {Object} [opt.body={}] 輸入額外請求本體物件(如temperature、max_tokens、response_format),將併入預設body(同名鍵以此為準),預設{}。注意本轉接器不支援工具,帶入tools而模型回tool_calls時一律以TOOL_CALLS_UNSUPPORTED回報失敗,需要工具請改用CLI類kind
|
|
219
|
-
* @param {Object} [opt.headers={}]
|
|
303
|
+
* @param {Object} [opt.headers={}] 輸入額外請求標頭物件,同名鍵覆寫預設標頭;預設標頭含'Accept-Encoding: identity'(防伺服器壓縮卻漏標Content-Encoding,見檔頭),要改回允許壓縮可給{'Accept-Encoding':'gzip, deflate, br'},預設{}
|
|
220
304
|
* @param {Number} [opt.timeoutMs=300000] 輸入逾時毫秒正整數,逾時將中止請求(含回應串流讀取),全套件統一預設300000
|
|
221
|
-
* @param {String|Function} [opt.validate=undefined] 輸入回覆內容驗證規則字串或自訂驗證函數,規則字串支援'nonempty'、'json'、'min:100'
|
|
222
|
-
* @param {
|
|
305
|
+
* @param {String|Function} [opt.validate=undefined] 輸入回覆內容驗證規則字串或自訂驗證函數,規則字串支援'nonempty'、'json'、'min:100',多規則可用逗號串接,自訂函數拋錯視同驗證失敗,預設undefined代表不驗證
|
|
306
|
+
* @param {Boolean} [opt.acceptTruncated=false] 輸入是否接受被截斷(finish_reason為length)之內容布林值,true代表交validate裁決(無validate則直接接受)且結果標truncated:true;content_filter與可見輸出為空者一律失敗,預設false代表截斷一律失敗(errorType incomplete)
|
|
307
|
+
* @param {Number} [opt.maxRetries=0] 輸入失敗後最大重試次數非負整數,4xx(429除外)與截斷不重試,預設0
|
|
223
308
|
* @param {Number} [opt.retryDelayMs=5000] 輸入重試間隔毫秒正整數,實際間隔為retryDelayMs乘以重試次數且上限15000ms,預設5000
|
|
224
|
-
* @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否成功布林值)、stdout(回覆內容字串)、stderr(失敗時之原始回應本體)、code(HTTP狀態碼,網路錯誤與逾時為null)、error(錯誤訊息字串,成功時為空字串)、errorType(僅失敗時,機器可讀錯誤類別字串,一覽見getErrorType.mjs檔頭)、durationMs(耗時毫秒)、attempts(實際嘗試次數)、usage(原始回應之token用量物件原樣透傳,無則null),本函數不會reject
|
|
309
|
+
* @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否成功布林值)、stdout(回覆內容字串)、stderr(失敗時之原始回應本體)、code(HTTP狀態碼,網路錯誤與逾時為null)、error(錯誤訊息字串,成功時為空字串)、errorType(僅失敗時,機器可讀錯誤類別字串,一覽見getErrorType.mjs檔頭)、durationMs(耗時毫秒)、attempts(實際嘗試次數)、usage(原始回應之token用量物件原樣透傳,無則null)、finishReason(已取得回應時之正規化終止原因字串,缺值為'')、truncated(已取得回應時是否被截斷布林值),本函數不會reject
|
|
225
310
|
* @example
|
|
226
311
|
* //need network, no cli required
|
|
227
312
|
*
|
|
@@ -315,6 +400,9 @@ async function dispatchApiOpenaiCompat(prompt, opt = {}) {
|
|
|
315
400
|
//validator
|
|
316
401
|
let validator = buildValidator(get(opt, 'validate', null))
|
|
317
402
|
|
|
403
|
+
//acceptTruncated, 僅明示true才放行截斷內容(見檔頭)
|
|
404
|
+
let acceptTruncated = get(opt, 'acceptTruncated', null) === true
|
|
405
|
+
|
|
318
406
|
//url, baseURL尾端斜線正規化後接上端點
|
|
319
407
|
let url = baseURL.replace(/\/+$/, '') + '/chat/completions'
|
|
320
408
|
|
|
@@ -328,8 +416,8 @@ async function dispatchApiOpenaiCompat(prompt, opt = {}) {
|
|
|
328
416
|
//body, 額外鍵以bodyExtra為準(可覆寫temperature等, 覆寫messages屬進階用法)
|
|
329
417
|
let body = { model, messages, ...bodyExtra }
|
|
330
418
|
|
|
331
|
-
//headers
|
|
332
|
-
let headers = { 'Content-Type': 'application/json', ...headersExtra }
|
|
419
|
+
//headers, Accept-Encoding預設identity(防伺服器壓縮卻漏標Content-Encoding, 見檔頭), headersExtra同名鍵可覆寫
|
|
420
|
+
let headers = { 'Content-Type': 'application/json', 'Accept-Encoding': 'identity', ...headersExtra }
|
|
333
421
|
if (isestr(key)) {
|
|
334
422
|
headers['Authorization'] = `Bearer ${key}`
|
|
335
423
|
}
|
|
@@ -344,7 +432,7 @@ async function dispatchApiOpenaiCompat(prompt, opt = {}) {
|
|
|
344
432
|
await delay(Math.min(retryDelayMs * attempt, MAX_RETRY_DELAY_MS))
|
|
345
433
|
}
|
|
346
434
|
|
|
347
|
-
lastResult = await callOnce(url, headers, body, timeoutMs, validator)
|
|
435
|
+
lastResult = await callOnce(url, headers, body, timeoutMs, validator, acceptTruncated)
|
|
348
436
|
totalAttempts = attempt + 1
|
|
349
437
|
|
|
350
438
|
if (lastResult.ok) {
|
|
@@ -352,6 +440,11 @@ async function dispatchApiOpenaiCompat(prompt, opt = {}) {
|
|
|
352
440
|
return lastResult
|
|
353
441
|
}
|
|
354
442
|
|
|
443
|
+
//不可重試: 截斷為決定性(同一請求必然再截斷, 重試只會再燒一次輸出上限)
|
|
444
|
+
if (lastResult.truncated === true) {
|
|
445
|
+
break
|
|
446
|
+
}
|
|
447
|
+
|
|
355
448
|
//不可重試: 4xx(429除外)為客戶端錯誤, 重試無意義
|
|
356
449
|
let c = lastResult.code
|
|
357
450
|
if (isnum(c) && c >= 400 && c < 500 && c !== 429) {
|
|
@@ -11,6 +11,8 @@ import buildValidator from './buildValidator.mjs'
|
|
|
11
11
|
import strTruncate from 'wsemi/src/strTruncate.mjs'
|
|
12
12
|
import getErrorResult from './getErrorResult.mjs'
|
|
13
13
|
import dfTimeoutMs from './dfTimeoutMs.mjs'
|
|
14
|
+
import { normalizeFinishReason, safeValidate, judgeTruncated } from './checkTruncation.mjs'
|
|
15
|
+
import describeNonJsonBody from './describeNonJsonBody.mjs'
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
// dispatchApiOpenaiResponses.mjs — 以fetch直呼OpenAI Responses API(/responses)
|
|
@@ -33,16 +35,25 @@ import dfTimeoutMs from './dfTimeoutMs.mjs'
|
|
|
33
35
|
// (chat/completions為prompt_tokens/completion_tokens/total_tokens)。
|
|
34
36
|
// 本套件usage一律原樣透傳不做正規化, 跨kind加總時呼叫端須自行對應欄位名。
|
|
35
37
|
//
|
|
36
|
-
// 【status不為completed
|
|
38
|
+
// 【status不為completed預設失敗, 不回半截內容】incomplete(如max_output_tokens
|
|
37
39
|
// 耗盡)之output常為空陣列或截斷內容, 當成功回傳會讓截斷結果流入下游而無人察覺;
|
|
38
40
|
// 故以INCOMPLETE_RESPONSE回報(errorType為incomplete), 呼叫端據此調高max_output_tokens
|
|
39
41
|
// 或換家。實測: max_output_tokens為16時status為incomplete、incomplete_details為
|
|
40
42
|
// {reason:'max_output_tokens'}、output為空陣列。
|
|
43
|
+
// 2026-09-24起與dispatchApiOpenaiCompat同一截斷規則(單一來源checkTruncation.mjs): 僅status為incomplete
|
|
44
|
+
// 視為截斷(結果帶truncated:true, finishReason將max_output_tokens正規化為length), 呼叫端明示
|
|
45
|
+
// acceptTruncated:true才放行length之截斷(交validate裁決); failed/缺status/處理中等屬非截斷之失敗,
|
|
46
|
+
// truncated:false, 遞補層照舊換金鑰(服務回錯)。可見輸出為空之截斷訊息附reasoning_tokens。
|
|
41
47
|
//
|
|
42
48
|
// 【不支援工具, 與dispatchApiOpenaiCompat同一決策】output含function_call型元素時
|
|
43
49
|
// 以TOOL_CALLS_UNSUPPORTED回報而不假裝成功; 理由(工具迴圈須自建harness、tool_call
|
|
44
50
|
// 有會話束縛無法外傳上層agent)詳見dispatchApiOpenaiCompat.mjs與adapters.mjs檔頭。
|
|
45
51
|
//
|
|
52
|
+
// 【預設帶Accept-Encoding: identity(2026-09-24起)】防伺服器壓縮卻漏標Content-Encoding而令fetch不解壓、
|
|
53
|
+
// JSON.parse失敗; 與dispatchApiOpenaiCompat同一決策與依據(見該檔檔頭), opt.headers可覆寫。
|
|
54
|
+
// HTTP 200但本體非JSON時另報INVALID_RESPONSE: body is not JSON(附原始位元組資訊), 與「缺output陣列」分開
|
|
55
|
+
// (同dispatchApiOpenaiCompat, 見describeNonJsonBody.mjs)。
|
|
56
|
+
//
|
|
46
57
|
// 【錯誤碼實測(Zen)】壞金鑰401(AuthError); 未知model亦回401(ModelError: Model X is not
|
|
47
58
|
// supported)而非404——故不可用狀態碼區分「金鑰錯」與「模型名錯」, 須讀stderr之訊息。
|
|
48
59
|
//
|
|
@@ -115,9 +126,10 @@ function extractOutputText(output) {
|
|
|
115
126
|
* @param {Object} body 輸入請求本體物件
|
|
116
127
|
* @param {Number} timeoutMs 輸入逾時毫秒
|
|
117
128
|
* @param {Function|null} validator 輸入驗證函式
|
|
129
|
+
* @param {Boolean} acceptTruncated 輸入是否明示接受截斷內容
|
|
118
130
|
* @returns {Promise} 回傳Promise,resolve回傳結果物件
|
|
119
131
|
*/
|
|
120
|
-
async function callOnce(url, headers, body, timeoutMs, validator) {
|
|
132
|
+
async function callOnce(url, headers, body, timeoutMs, validator, acceptTruncated) {
|
|
121
133
|
|
|
122
134
|
let t0 = Date.now()
|
|
123
135
|
|
|
@@ -140,7 +152,9 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
|
|
|
140
152
|
controller.abort()
|
|
141
153
|
}, timeoutMs)
|
|
142
154
|
|
|
155
|
+
//本體先取原始位元組再以UTF-8解碼(等同res.text()), 本體非JSON時才有原始位元組可供診斷(見describeNonJsonBody.mjs)
|
|
143
156
|
let res = null
|
|
157
|
+
let raw = new Uint8Array(0)
|
|
144
158
|
let txt = ''
|
|
145
159
|
try {
|
|
146
160
|
res = await fetch(url, {
|
|
@@ -149,7 +163,8 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
|
|
|
149
163
|
body: JSON.stringify(body),
|
|
150
164
|
signal: controller.signal,
|
|
151
165
|
})
|
|
152
|
-
|
|
166
|
+
raw = new Uint8Array(await res.arrayBuffer())
|
|
167
|
+
txt = new TextDecoder('utf-8').decode(raw)
|
|
153
168
|
}
|
|
154
169
|
catch (err) {
|
|
155
170
|
clearTimeout(timer)
|
|
@@ -197,18 +212,36 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
|
|
|
197
212
|
catch {
|
|
198
213
|
parsed = false
|
|
199
214
|
}
|
|
200
|
-
|
|
215
|
+
//本體非JSON, 與「JSON缺output」分開回報並附原始位元組資訊; 遞補層據前綴整組跳過(見describeNonJsonBody.mjs)
|
|
216
|
+
if (!parsed) {
|
|
201
217
|
return mkResult({
|
|
202
218
|
stderr: strTruncate(txt, 500, optTruncate),
|
|
203
219
|
code: res.status,
|
|
204
|
-
error:
|
|
220
|
+
error: describeNonJsonBody(raw, res.headers),
|
|
205
221
|
errorType: 'invalid-response',
|
|
206
222
|
usage,
|
|
223
|
+
finishReason: '',
|
|
224
|
+
truncated: false,
|
|
207
225
|
})
|
|
208
226
|
}
|
|
209
227
|
|
|
228
|
+
//fin, 自此起之每個結果皆外顯正規化終止原因與是否截斷(與chat/completions對齊, 規則見checkTruncation.mjs):
|
|
229
|
+
//completed→stop; incomplete→依reason(max_output_tokens→length、content_filter原值、缺→incomplete); 其餘status原值; 缺status→''
|
|
230
|
+
let statusN = normalizeFinishReason(status)
|
|
231
|
+
let reasonN = normalizeFinishReason(incompleteReason)
|
|
232
|
+
let finishReason = statusN
|
|
233
|
+
if (statusN === 'completed') {
|
|
234
|
+
finishReason = 'stop'
|
|
235
|
+
}
|
|
236
|
+
else if (statusN === 'incomplete') {
|
|
237
|
+
finishReason = (reasonN === 'max_output_tokens') ? 'length' : (reasonN || 'incomplete')
|
|
238
|
+
}
|
|
239
|
+
let fin = { finishReason, truncated: statusN === 'incomplete' }
|
|
240
|
+
let detail = incompleteReason || failMsg || status || 'unknown'
|
|
241
|
+
let isArr = isarr(output)
|
|
242
|
+
|
|
210
243
|
//function_call, 本轉接器不支援工具迴圈(見檔頭), 明確回報而不假裝成功
|
|
211
|
-
let hasToolCall = output.some((o) => get(o, 'type', '') === 'function_call')
|
|
244
|
+
let hasToolCall = isArr && output.some((o) => get(o, 'type', '') === 'function_call')
|
|
212
245
|
if (hasToolCall) {
|
|
213
246
|
return mkResult({
|
|
214
247
|
stderr: strTruncate(txt, 1000, optTruncate),
|
|
@@ -216,12 +249,48 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
|
|
|
216
249
|
error: 'TOOL_CALLS_UNSUPPORTED: use a cli kind (opencode/claude/codex/antigravity) when tools are needed',
|
|
217
250
|
errorType: 'tool-unsupported',
|
|
218
251
|
usage,
|
|
252
|
+
...fin,
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
//截斷(status為incomplete), 於output檢查與validate之前裁定: 預設失敗, 明示acceptTruncated才交validate放行(見檔頭)
|
|
257
|
+
if (fin.truncated) {
|
|
258
|
+
let partial = isArr ? extractOutputText(output) : ''
|
|
259
|
+
let d = judgeTruncated({
|
|
260
|
+
finishReason,
|
|
261
|
+
content: partial,
|
|
262
|
+
acceptTruncated,
|
|
263
|
+
validator,
|
|
264
|
+
reasoningTokens: get(usage, 'output_tokens_details.reasoning_tokens', null),
|
|
265
|
+
label: `status=${status} (${detail})`,
|
|
219
266
|
})
|
|
267
|
+
if (!d.accept) {
|
|
268
|
+
return mkResult({
|
|
269
|
+
stdout: strTruncate(partial, 500, optTruncate),
|
|
270
|
+
stderr: strTruncate((d.threw ? `validate threw: ${d.threw}\n` : '') + txt, 500, optTruncate),
|
|
271
|
+
code: res.status,
|
|
272
|
+
error: d.error,
|
|
273
|
+
errorType: 'incomplete',
|
|
274
|
+
usage,
|
|
275
|
+
...fin,
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
return mkResult({ ok: true, stdout: partial, code: res.status, usage, ...fin })
|
|
220
279
|
}
|
|
221
280
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
281
|
+
if (!isArr) {
|
|
282
|
+
return mkResult({
|
|
283
|
+
stderr: strTruncate(txt, 500, optTruncate),
|
|
284
|
+
code: res.status,
|
|
285
|
+
error: 'INVALID_RESPONSE: missing output array',
|
|
286
|
+
errorType: 'invalid-response',
|
|
287
|
+
usage,
|
|
288
|
+
...fin,
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
//其餘非completed(failed/缺status/處理中等)屬非截斷之失敗, 不回半截內容; truncated:false故遞補層照舊換金鑰
|
|
293
|
+
if (statusN !== 'completed') {
|
|
225
294
|
return mkResult({
|
|
226
295
|
stdout: strTruncate(extractOutputText(output), 500, optTruncate),
|
|
227
296
|
stderr: strTruncate(txt, 500, optTruncate),
|
|
@@ -229,6 +298,7 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
|
|
|
229
298
|
error: `INCOMPLETE_RESPONSE: status=${status || 'missing'} (${detail})`,
|
|
230
299
|
errorType: 'incomplete',
|
|
231
300
|
usage,
|
|
301
|
+
...fin,
|
|
232
302
|
})
|
|
233
303
|
}
|
|
234
304
|
|
|
@@ -241,18 +311,24 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
|
|
|
241
311
|
error: 'INVALID_RESPONSE: no output_text in output messages',
|
|
242
312
|
errorType: 'invalid-response',
|
|
243
313
|
usage,
|
|
314
|
+
...fin,
|
|
244
315
|
})
|
|
245
316
|
}
|
|
246
317
|
|
|
247
|
-
//validator, error與execCli一致令dispatchAiFallback
|
|
248
|
-
if (validator
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
318
|
+
//validator, error與execCli一致令dispatchAiFallback可統一分流; 拋錯視同拒絕(不reject), 訊息置stderr
|
|
319
|
+
if (validator) {
|
|
320
|
+
let v = safeValidate(validator, content)
|
|
321
|
+
if (!v.pass) {
|
|
322
|
+
return mkResult({
|
|
323
|
+
stdout: strTruncate(content, 500, optTruncate),
|
|
324
|
+
stderr: v.threw ? `validate threw: ${v.threw}` : '',
|
|
325
|
+
code: res.status,
|
|
326
|
+
error: 'OUTPUT_VALIDATION_FAILED',
|
|
327
|
+
errorType: 'validation',
|
|
328
|
+
usage,
|
|
329
|
+
...fin,
|
|
330
|
+
})
|
|
331
|
+
}
|
|
256
332
|
}
|
|
257
333
|
|
|
258
334
|
return mkResult({
|
|
@@ -260,6 +336,7 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
|
|
|
260
336
|
stdout: content,
|
|
261
337
|
code: res.status,
|
|
262
338
|
usage,
|
|
339
|
+
...fin,
|
|
263
340
|
})
|
|
264
341
|
}
|
|
265
342
|
|
|
@@ -286,12 +363,13 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
|
|
|
286
363
|
* @param {String} [opt.key=''] 輸入API key字串,以Bearer置於Authorization標頭,預設''代表不帶認證標頭
|
|
287
364
|
* @param {String} [opt.system=''] 輸入system提示詞字串,將置於instructions欄位(Responses API之system管道),預設''代表不帶
|
|
288
365
|
* @param {Object} [opt.body={}] 輸入額外請求本體物件(如temperature、max_output_tokens、reasoning),將併入預設body(同名鍵以此為準),預設{}。注意輸出上限欄位名為max_output_tokens而非max_tokens;本轉接器不支援工具,帶入tools而模型回function_call時一律以TOOL_CALLS_UNSUPPORTED回報失敗
|
|
289
|
-
* @param {Object} [opt.headers={}]
|
|
366
|
+
* @param {Object} [opt.headers={}] 輸入額外請求標頭物件,同名鍵覆寫預設標頭;預設標頭含'Accept-Encoding: identity'(防伺服器壓縮卻漏標Content-Encoding,見檔頭),預設{}
|
|
290
367
|
* @param {Number} [opt.timeoutMs=300000] 輸入逾時毫秒正整數,逾時將中止請求(含回應串流讀取),全套件統一預設300000
|
|
291
|
-
* @param {String|Function} [opt.validate=undefined] 輸入回覆內容驗證規則字串或自訂驗證函數,規則字串支援'nonempty'、'json'、'min:100'
|
|
292
|
-
* @param {
|
|
368
|
+
* @param {String|Function} [opt.validate=undefined] 輸入回覆內容驗證規則字串或自訂驗證函數,規則字串支援'nonempty'、'json'、'min:100',多規則可用逗號串接,自訂函數拋錯視同驗證失敗,預設undefined代表不驗證
|
|
369
|
+
* @param {Boolean} [opt.acceptTruncated=false] 輸入是否接受被截斷(status為incomplete且reason為max_output_tokens)之內容布林值,true代表交validate裁決(無validate則直接接受)且結果標truncated:true;content_filter與可見輸出為空者一律失敗,預設false代表截斷一律失敗
|
|
370
|
+
* @param {Number} [opt.maxRetries=0] 輸入失敗後最大重試次數非負整數,4xx(429除外)與截斷不重試,預設0
|
|
293
371
|
* @param {Number} [opt.retryDelayMs=5000] 輸入重試間隔毫秒正整數,實際間隔為retryDelayMs乘以重試次數且上限15000ms,預設5000
|
|
294
|
-
* @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否成功布林值)、stdout(回覆內容字串)、stderr(失敗時之原始回應本體)、code(HTTP狀態碼,網路錯誤與逾時為null)、error(錯誤訊息字串,成功時為空字串)、errorType(僅失敗時,機器可讀錯誤類別字串,一覽見getErrorType.mjs檔頭)、durationMs(耗時毫秒)、attempts(實際嘗試次數)、usage(原始回應之token用量物件原樣透傳,欄位名為input_tokens/output_tokens/total_tokens,無則null),本函數不會reject
|
|
372
|
+
* @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否成功布林值)、stdout(回覆內容字串)、stderr(失敗時之原始回應本體)、code(HTTP狀態碼,網路錯誤與逾時為null)、error(錯誤訊息字串,成功時為空字串)、errorType(僅失敗時,機器可讀錯誤類別字串,一覽見getErrorType.mjs檔頭)、durationMs(耗時毫秒)、attempts(實際嘗試次數)、usage(原始回應之token用量物件原樣透傳,欄位名為input_tokens/output_tokens/total_tokens,無則null)、finishReason(已取得回應時之正規化終止原因字串,completed為'stop'、max_output_tokens為'length')、truncated(已取得回應時是否被截斷布林值,僅status為incomplete時為true),本函數不會reject
|
|
295
373
|
* @example
|
|
296
374
|
* //need network, no cli required
|
|
297
375
|
*
|
|
@@ -374,6 +452,9 @@ async function dispatchApiOpenaiResponses(prompt, opt = {}) {
|
|
|
374
452
|
//validator
|
|
375
453
|
let validator = buildValidator(get(opt, 'validate', null))
|
|
376
454
|
|
|
455
|
+
//acceptTruncated, 僅明示true才放行截斷內容(見檔頭)
|
|
456
|
+
let acceptTruncated = get(opt, 'acceptTruncated', null) === true
|
|
457
|
+
|
|
377
458
|
//url, baseURL尾端斜線正規化後接上端點
|
|
378
459
|
let url = baseURL.replace(/\/+$/, '') + '/responses'
|
|
379
460
|
|
|
@@ -384,8 +465,8 @@ async function dispatchApiOpenaiResponses(prompt, opt = {}) {
|
|
|
384
465
|
}
|
|
385
466
|
body = { ...body, ...bodyExtra }
|
|
386
467
|
|
|
387
|
-
//headers
|
|
388
|
-
let headers = { 'Content-Type': 'application/json', ...headersExtra }
|
|
468
|
+
//headers, Accept-Encoding預設identity(防伺服器壓縮卻漏標Content-Encoding, 見檔頭), headersExtra同名鍵可覆寫
|
|
469
|
+
let headers = { 'Content-Type': 'application/json', 'Accept-Encoding': 'identity', ...headersExtra }
|
|
389
470
|
if (isestr(key)) {
|
|
390
471
|
headers['Authorization'] = `Bearer ${key}`
|
|
391
472
|
}
|
|
@@ -400,7 +481,7 @@ async function dispatchApiOpenaiResponses(prompt, opt = {}) {
|
|
|
400
481
|
await delay(Math.min(retryDelayMs * attempt, MAX_RETRY_DELAY_MS))
|
|
401
482
|
}
|
|
402
483
|
|
|
403
|
-
lastResult = await callOnce(url, headers, body, timeoutMs, validator)
|
|
484
|
+
lastResult = await callOnce(url, headers, body, timeoutMs, validator, acceptTruncated)
|
|
404
485
|
totalAttempts = attempt + 1
|
|
405
486
|
|
|
406
487
|
if (lastResult.ok) {
|
|
@@ -408,6 +489,11 @@ async function dispatchApiOpenaiResponses(prompt, opt = {}) {
|
|
|
408
489
|
return lastResult
|
|
409
490
|
}
|
|
410
491
|
|
|
492
|
+
//不可重試: 截斷為決定性(同一請求必然再截斷, 重試只會再燒一次輸出上限)
|
|
493
|
+
if (lastResult.truncated === true) {
|
|
494
|
+
break
|
|
495
|
+
}
|
|
496
|
+
|
|
411
497
|
//不可重試: 4xx(429除外)為客戶端錯誤, 重試無意義
|
|
412
498
|
let c = lastResult.code
|
|
413
499
|
if (isnum(c) && c >= 400 && c < 500 && c !== 429) {
|