w-dispatch-ai 1.0.2 → 1.0.4
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 +123 -35
- package/dist/w-dispatch-ai.umd.js +2 -2
- package/dist/w-dispatch-ai.umd.js.map +1 -1
- package/docs/WDispatchAi.mjs.html +8 -4
- package/docs/adapters.mjs.html +43 -7
- package/docs/dispatchAi.mjs.html +2 -2
- package/docs/dispatchAiFallback.mjs.html +42 -8
- package/docs/dispatchAiWkf.mjs.html +197 -0
- package/docs/dispatchAntigravity.mjs.html +2 -2
- package/docs/dispatchApiOpenaiCompat.mjs.html +516 -0
- 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/global.html +5768 -1469
- package/docs/index.html +2 -2
- package/docs/wkf_callAiWithFallback.mjs.html +282 -0
- package/docs/wkf_extractJsonLoose.mjs.html +180 -0
- package/docs/wkf_runFanout.mjs.html +227 -0
- package/docs/wkf_runFanoutPipeline.mjs.html +178 -0
- package/docs/wkf_runRolePipeline.mjs.html +195 -0
- package/g.mjs +41 -25
- package/package.json +1 -1
- package/src/WDispatchAi.mjs +6 -2
- package/src/adapters.mjs +41 -5
- package/src/dispatchAiFallback.mjs +40 -6
- package/src/dispatchAiWkf.mjs +125 -0
- package/src/dispatchApiOpenaiCompat.mjs +444 -0
- package/src/wkf/callAiWithFallback.mjs +210 -0
- package/src/wkf/extractJsonLoose.mjs +108 -0
- package/src/wkf/runFanout.mjs +155 -0
- package/src/wkf/runFanoutPipeline.mjs +106 -0
- package/src/wkf/runRolePipeline.mjs +123 -0
- package/test/tools/fakeServerForApiTest.mjs +151 -0
- package/test/unit-WDispatchAi.test.mjs +13 -6
- package/test/unit-adapters.test.mjs +5 -3
- package/test/unit-callAiWithFallback.test.mjs +146 -0
- package/test/unit-dispatchAi.test.mjs +1 -1
- package/test/unit-dispatchAiWkf.test.mjs +118 -0
- package/test/unit-dispatchApiOpenaiCompat.test.mjs +264 -0
- package/test/unit-extractJsonLoose.test.mjs +78 -0
- package/test/unit-runFanout.test.mjs +166 -0
- package/test/unit-runFanoutPipeline.test.mjs +86 -0
- package/test/unit-runRolePipeline.test.mjs +163 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import get from 'lodash-es/get.js'
|
|
2
|
+
import isobj from 'wsemi/src/isobj.mjs'
|
|
3
|
+
import isfun from 'wsemi/src/isfun.mjs'
|
|
4
|
+
import isnum from 'wsemi/src/isnum.mjs'
|
|
5
|
+
import isestr from 'wsemi/src/isestr.mjs'
|
|
6
|
+
import ispint from 'wsemi/src/ispint.mjs'
|
|
7
|
+
import isp0int from 'wsemi/src/isp0int.mjs'
|
|
8
|
+
import cint from 'wsemi/src/cint.mjs'
|
|
9
|
+
import delay from 'wsemi/src/delay.mjs'
|
|
10
|
+
import strleft from 'wsemi/src/strleft.mjs'
|
|
11
|
+
import strdelleft from 'wsemi/src/strdelleft.mjs'
|
|
12
|
+
import strTruncate from 'wsemi/src/strTruncate.mjs'
|
|
13
|
+
import getErrorResult from './getErrorResult.mjs'
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
// dispatchApiOpenaiCompat.mjs — 以fetch直呼OpenAI相容API(chat/completions)
|
|
17
|
+
//
|
|
18
|
+
// 【為何需要】opencode CLI調用的deepseek(OpenCode Zen閘道)與agnes-ai本體都是
|
|
19
|
+
// OpenAI相容REST API, 直呼即可免安裝CLI、免預先登入(2026-08-11於本機實測):
|
|
20
|
+
// OpenCode Zen — https://opencode.ai/zen/v1 (金鑰同auth.json之sk-..., 模型名去掉opencode/前綴)
|
|
21
|
+
// Agnes — https://apihub.agnes-ai.com/v1
|
|
22
|
+
// 實測四把金鑰直呼皆200; 壞金鑰回401(Zen: `{"type":"AuthError","message":"Invalid API key."}`,
|
|
23
|
+
// Agnes: `{"message":"无效的令牌...","type":"AgnesAI_error"}`)。
|
|
24
|
+
//
|
|
25
|
+
// 【與CLI轉接器之差異】prompt走HTTP body無命令列長度限制; 錯誤依HTTP狀態碼精確分流
|
|
26
|
+
// (401/403金鑰、429限流、5xx服務端), 不再依賴stderr字串猜測; 純completion無agentic
|
|
27
|
+
// 能力(不讀檔不跑指令), 天然無寫檔風險。
|
|
28
|
+
// 注意: claude與codex走訂閱帳號登入態而非API金鑰, 無法比照, 仍須CLI轉接器。
|
|
29
|
+
//
|
|
30
|
+
// 【本轉接器不支援工具, 需要工具請改用CLI類kind(2026-08-11實測後之決策)】
|
|
31
|
+
// 閘道端零內建工具: 實測Zen與Agnes皆對`tools:[{type:'web_search'}]`回400並要求
|
|
32
|
+
// function.parameters, 即只接受「呼叫端自行定義且自行執行」之function工具;
|
|
33
|
+
// 協定層雖支援function calling(Zen之nemotron-3-ultra-free與Agnes皆實測回
|
|
34
|
+
// finish_reason:'tool_calls'且tool_calls格式標準), 但工具之定義、執行、錯誤處理與
|
|
35
|
+
// 安全邊界全須本套件自行實作與維護, 等同重造CLI已提供之harness, 故不做。
|
|
36
|
+
// 又tool_calls有會話束縛(tool_call_id須於同一條messages串內回填, 且須保留前文),
|
|
37
|
+
// 該messages串活在本函數單次呼叫之生命週期內, 無法暫停後跨行程外傳給上層agent代跑
|
|
38
|
+
// ——工作流是被上層阻塞呼叫的函式, 沒有反向請求工具的通道; 同理工作流(如runFanout)
|
|
39
|
+
// 之各名額亦只是同行程之async函數呼叫, 無法把tool_calls往上層轉送。
|
|
40
|
+
// 故呼叫端若於body帶入tools, 本函數一律以TOOL_CALLS_UNSUPPORTED回報失敗而不假裝成功
|
|
41
|
+
// (實測Agnes於tool_calls時content為"\n\n"而非null, 不特別處理會靜默回傳空白內容)。
|
|
42
|
+
//
|
|
43
|
+
// 【重試語意對齊execCli】4xx(429除外)為客戶端錯誤不可重試而立即中止;
|
|
44
|
+
// 429/5xx/網路錯誤/逾時依maxRetries線性退避重試(間隔retryDelayMs*次數, 上限15000ms)。
|
|
45
|
+
//
|
|
46
|
+
// 【結果結構對齊execCli】{ ok, stdout, stderr, code, error, durationMs, attempts },
|
|
47
|
+
// stdout為回覆內容、code為HTTP狀態碼(網路錯誤與逾時為null)、逾時error以TIMEOUT開頭、
|
|
48
|
+
// 驗證失敗error為OUTPUT_VALIDATION_FAILED——故dispatchAiFallback之失敗分流
|
|
49
|
+
// (TIMEOUT/驗證失敗跳組, 其餘換金鑰)對本轉接器同樣成立, 無須任何修改。
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
//預設值
|
|
53
|
+
let DEFAULT_TIMEOUT_MS = 120000
|
|
54
|
+
let DEFAULT_RETRY_DELAY_MS = 5000
|
|
55
|
+
let MAX_RETRY_DELAY_MS = 15000
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
//optTruncate, 裁切失敗結果之內容時於刪節號後標註原始總長度(同execCli)
|
|
59
|
+
let optTruncate = {
|
|
60
|
+
funWithMsg: (str) => `(truncated, total ${str.length} chars)`,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 建立驗證函式(規則語法同execCli之validate)
|
|
66
|
+
*
|
|
67
|
+
* @param {String|Function} rule 輸入驗證規則字串('nonempty'、'json'、'min:100', 逗號可串接)或自訂函式
|
|
68
|
+
* @returns {Function|null} 回傳驗證函式,無有效規則回傳null
|
|
69
|
+
*/
|
|
70
|
+
function buildValidator(rule) {
|
|
71
|
+
|
|
72
|
+
//自訂函式直接使用
|
|
73
|
+
if (isfun(rule)) {
|
|
74
|
+
return rule
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
//check
|
|
78
|
+
if (!isestr(rule)) {
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
//checks
|
|
83
|
+
let checks = rule.split(',').map((r) => r.trim()).filter(Boolean)
|
|
84
|
+
if (checks.length === 0) {
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (stdout) => {
|
|
89
|
+
for (let check of checks) {
|
|
90
|
+
|
|
91
|
+
if (check === 'nonempty') {
|
|
92
|
+
if (!isestr(stdout) || stdout.trim() === '') {
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
else if (check === 'json') {
|
|
98
|
+
try {
|
|
99
|
+
JSON.parse(stdout)
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
else if (strleft(check, 4) === 'min:') {
|
|
107
|
+
|
|
108
|
+
//規則本身無效(如min:abc) → 視為驗證失敗, 不靜默跳過
|
|
109
|
+
let smin = strdelleft(check, 4)
|
|
110
|
+
if (!isnum(smin)) {
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let min = cint(smin)
|
|
115
|
+
if (!isestr(stdout) || stdout.length < min) {
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
}
|
|
121
|
+
return true
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 單次HTTP呼叫(內部使用, 不含重試邏輯)
|
|
128
|
+
*
|
|
129
|
+
* @param {String} url 輸入完整端點網址字串
|
|
130
|
+
* @param {Object} headers 輸入請求標頭物件
|
|
131
|
+
* @param {Object} body 輸入請求本體物件
|
|
132
|
+
* @param {Number} timeoutMs 輸入逾時毫秒
|
|
133
|
+
* @param {Function|null} validator 輸入驗證函式
|
|
134
|
+
* @returns {Promise} 回傳Promise,resolve回傳結果物件
|
|
135
|
+
*/
|
|
136
|
+
async function callOnce(url, headers, body, timeoutMs, validator) {
|
|
137
|
+
|
|
138
|
+
let t0 = Date.now()
|
|
139
|
+
|
|
140
|
+
//AbortController, 逾時中止(含回應本體之串流讀取)
|
|
141
|
+
let controller = new AbortController()
|
|
142
|
+
let timer = setTimeout(() => {
|
|
143
|
+
controller.abort()
|
|
144
|
+
}, timeoutMs)
|
|
145
|
+
|
|
146
|
+
let res = null
|
|
147
|
+
let txt = ''
|
|
148
|
+
try {
|
|
149
|
+
res = await fetch(url, {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers,
|
|
152
|
+
body: JSON.stringify(body),
|
|
153
|
+
signal: controller.signal,
|
|
154
|
+
})
|
|
155
|
+
txt = await res.text()
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
clearTimeout(timer)
|
|
159
|
+
let durationMs = Date.now() - t0
|
|
160
|
+
|
|
161
|
+
//逾時, error以TIMEOUT開頭令dispatchAiFallback視為與金鑰無關而跳組
|
|
162
|
+
if (err.name === 'AbortError') {
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
stdout: '',
|
|
166
|
+
stderr: '',
|
|
167
|
+
code: null,
|
|
168
|
+
error: `TIMEOUT after ${timeoutMs / 1000}s`,
|
|
169
|
+
durationMs,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
//網路層錯誤(DNS/連線拒絕等)
|
|
174
|
+
let cause = get(err, 'cause.code', '') || err.message
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
stdout: '',
|
|
178
|
+
stderr: '',
|
|
179
|
+
code: null,
|
|
180
|
+
error: `FETCH_ERROR: ${cause}`,
|
|
181
|
+
durationMs,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
clearTimeout(timer)
|
|
185
|
+
|
|
186
|
+
let durationMs = Date.now() - t0
|
|
187
|
+
|
|
188
|
+
//HTTP非2xx, 原始回應本體放stderr供除錯與分類
|
|
189
|
+
if (!res.ok) {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
stdout: '',
|
|
193
|
+
stderr: strTruncate(txt, 1000, optTruncate),
|
|
194
|
+
code: res.status,
|
|
195
|
+
error: `HTTP ${res.status}`,
|
|
196
|
+
durationMs,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
//取出choices[0]
|
|
201
|
+
let content = null
|
|
202
|
+
let finishReason = ''
|
|
203
|
+
let toolCalls = null
|
|
204
|
+
try {
|
|
205
|
+
let j = JSON.parse(txt)
|
|
206
|
+
content = get(j, 'choices.0.message.content', null)
|
|
207
|
+
finishReason = get(j, 'choices.0.finish_reason', '')
|
|
208
|
+
toolCalls = get(j, 'choices.0.message.tool_calls', null)
|
|
209
|
+
}
|
|
210
|
+
catch {}
|
|
211
|
+
|
|
212
|
+
//tool_calls, 本轉接器不支援工具迴圈(見檔頭), 明確回報而不假裝成功
|
|
213
|
+
//(Agnes於tool_calls時content為"\n\n"非null, 不攔截會靜默回傳空白內容)
|
|
214
|
+
if (finishReason === 'tool_calls' || (toolCalls !== null && toolCalls !== undefined)) {
|
|
215
|
+
return {
|
|
216
|
+
ok: false,
|
|
217
|
+
stdout: '',
|
|
218
|
+
stderr: strTruncate(txt, 1000, optTruncate),
|
|
219
|
+
code: res.status,
|
|
220
|
+
error: 'TOOL_CALLS_UNSUPPORTED: use a cli kind (opencode/claude/codex/antigravity) when tools are needed',
|
|
221
|
+
durationMs,
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (content === null || content === undefined) {
|
|
226
|
+
return {
|
|
227
|
+
ok: false,
|
|
228
|
+
stdout: '',
|
|
229
|
+
stderr: strTruncate(txt, 500, optTruncate),
|
|
230
|
+
code: res.status,
|
|
231
|
+
error: 'INVALID_RESPONSE: missing choices[0].message.content',
|
|
232
|
+
durationMs,
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (typeof content !== 'string') {
|
|
236
|
+
content = JSON.stringify(content) //少數閘道回array形態
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
//validator, error與execCli一致令dispatchAiFallback可統一分流
|
|
240
|
+
if (validator && !validator(content)) {
|
|
241
|
+
return {
|
|
242
|
+
ok: false,
|
|
243
|
+
stdout: strTruncate(content, 500, optTruncate),
|
|
244
|
+
stderr: '',
|
|
245
|
+
code: res.status,
|
|
246
|
+
error: 'OUTPUT_VALIDATION_FAILED',
|
|
247
|
+
durationMs,
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
ok: true,
|
|
253
|
+
stdout: content,
|
|
254
|
+
stderr: '',
|
|
255
|
+
code: res.status,
|
|
256
|
+
error: '',
|
|
257
|
+
durationMs,
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
//本轉接器不使用execCli, 全部設定鍵自理, 未知鍵一律忽略
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* 以fetch直呼OpenAI相容API(chat/completions)呼叫AI模型
|
|
267
|
+
*
|
|
268
|
+
* 特點:
|
|
269
|
+
* 免安裝CLI、免預先登入,給baseURL+key+model即可呼叫(如OpenCode Zen、Agnes等OpenAI相容閘道);
|
|
270
|
+
* 僅供純文字生成(摘要、分析、改寫、產出JSON等素材已在prompt內之任務)——
|
|
271
|
+
* 需要讀本機檔案、grep、執行指令、抓網頁等工具能力時,請改用CLI類kind(opencode/claude/codex/antigravity);
|
|
272
|
+
* prompt走HTTP body,無命令列長度限制;
|
|
273
|
+
* 錯誤依HTTP狀態碼分流:4xx(429除外)為客戶端錯誤不重試,429/5xx/網路錯誤/逾時依maxRetries線性退避重試;
|
|
274
|
+
* 結果結構與逾時/驗證失敗之error字樣對齊execCli,可直接作為dispatchAi與dispatchAiFallback之kind('api-openai-compat')使用;
|
|
275
|
+
* 本函數不會reject,一律以結果物件之ok與error欄位回報成敗
|
|
276
|
+
*
|
|
277
|
+
* @param {String} prompt 輸入提示詞字串,作為user訊息置於HTTP body
|
|
278
|
+
* @param {Object} [opt={}] 輸入設定物件,預設{}
|
|
279
|
+
* @param {String} opt.baseURL 輸入API基底網址字串,例如'https://opencode.ai/zen/v1'、'https://apihub.agnes-ai.com/v1',將於尾端接上/chat/completions
|
|
280
|
+
* @param {String} opt.model 輸入模型ID字串,例如'deepseek-v4-flash-free'(Zen之模型名不帶opencode/前綴)、'agnes-2.0-flash'
|
|
281
|
+
* @param {String} [opt.key=''] 輸入API key字串,以Bearer置於Authorization標頭,預設''代表不帶認證標頭
|
|
282
|
+
* @param {String} [opt.system=''] 輸入system提示詞字串,將以system角色置於messages首位,預設''代表不帶
|
|
283
|
+
* @param {Object} [opt.body={}] 輸入額外請求本體物件(如temperature、max_tokens、response_format),將併入預設body(同名鍵以此為準),預設{}。注意本轉接器不支援工具,帶入tools而模型回tool_calls時一律以TOOL_CALLS_UNSUPPORTED回報失敗,需要工具請改用CLI類kind
|
|
284
|
+
* @param {Object} [opt.headers={}] 輸入額外請求標頭物件,預設{}
|
|
285
|
+
* @param {Number} [opt.timeoutMs=120000] 輸入逾時毫秒正整數,逾時將中止請求(含回應串流讀取),預設120000
|
|
286
|
+
* @param {String|Function} [opt.validate=undefined] 輸入回覆內容驗證規則字串或自訂驗證函數,規則字串支援'nonempty'、'json'、'min:100',多規則可用逗號串接,預設undefined代表不驗證
|
|
287
|
+
* @param {Number} [opt.maxRetries=0] 輸入失敗後最大重試次數非負整數,4xx(429除外)不重試,預設0
|
|
288
|
+
* @param {Number} [opt.retryDelayMs=5000] 輸入重試間隔毫秒正整數,實際間隔為retryDelayMs乘以重試次數且上限15000ms,預設5000
|
|
289
|
+
* @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否成功布林值)、stdout(回覆內容字串)、stderr(失敗時之原始回應本體)、code(HTTP狀態碼,網路錯誤與逾時為null)、error(錯誤訊息字串,成功時為空字串)、durationMs(耗時毫秒)、attempts(實際嘗試次數),本函數不會reject
|
|
290
|
+
* @example
|
|
291
|
+
* //need network, no cli required
|
|
292
|
+
*
|
|
293
|
+
* import dispatchApiOpenaiCompat from './src/dispatchApiOpenaiCompat.mjs'
|
|
294
|
+
*
|
|
295
|
+
* let test = async () => {
|
|
296
|
+
*
|
|
297
|
+
* //OpenCode Zen(即opencode CLI之自家閘道), 模型名不帶opencode/前綴
|
|
298
|
+
* let r1 = await dispatchApiOpenaiCompat('請只回覆兩個字:完成', {
|
|
299
|
+
* baseURL: 'https://opencode.ai/zen/v1',
|
|
300
|
+
* key: 'sk-xxxxxx',
|
|
301
|
+
* model: 'deepseek-v4-flash-free',
|
|
302
|
+
* })
|
|
303
|
+
* console.log(r1.ok, r1.stdout.trim())
|
|
304
|
+
* // => true 完成
|
|
305
|
+
*
|
|
306
|
+
* //Agnes
|
|
307
|
+
* let r2 = await dispatchApiOpenaiCompat('請只回覆兩個字:完成', {
|
|
308
|
+
* baseURL: 'https://apihub.agnes-ai.com/v1',
|
|
309
|
+
* key: 'sk-xxxxxx',
|
|
310
|
+
* model: 'agnes-2.0-flash',
|
|
311
|
+
* })
|
|
312
|
+
* console.log(r2.ok, r2.stdout.trim())
|
|
313
|
+
* // => true 完成
|
|
314
|
+
*
|
|
315
|
+
* let re = await dispatchApiOpenaiCompat('abc', { baseURL: 'https://opencode.ai/zen/v1', key: 'sk-bad', model: 'deepseek-v4-flash-free' })
|
|
316
|
+
* console.log(re.ok, re.code, re.error)
|
|
317
|
+
* // => false 401 HTTP 401
|
|
318
|
+
*
|
|
319
|
+
* }
|
|
320
|
+
* await test()
|
|
321
|
+
* .catch((err) => {
|
|
322
|
+
* console.log(err)
|
|
323
|
+
* })
|
|
324
|
+
*
|
|
325
|
+
*/
|
|
326
|
+
async function dispatchApiOpenaiCompat(prompt, opt = {}) {
|
|
327
|
+
|
|
328
|
+
//check prompt, 不reject故以錯誤結果物件回報
|
|
329
|
+
if (!isestr(prompt)) {
|
|
330
|
+
return getErrorResult('prompt must be a non-empty string')
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
//baseURL必填, API無CLI可回退
|
|
334
|
+
let baseURL = get(opt, 'baseURL', null)
|
|
335
|
+
if (!isestr(baseURL)) {
|
|
336
|
+
return getErrorResult('baseURL must be a non-empty string')
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
//model必填, chat/completions無預設模型
|
|
340
|
+
let model = get(opt, 'model', null)
|
|
341
|
+
if (!isestr(model)) {
|
|
342
|
+
return getErrorResult('model must be a non-empty string')
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
//key, 無效代表不帶認證標頭(部分閘道免認證)
|
|
346
|
+
let key = get(opt, 'key', null)
|
|
347
|
+
|
|
348
|
+
//system
|
|
349
|
+
let system = get(opt, 'system', null)
|
|
350
|
+
|
|
351
|
+
//bodyExtra
|
|
352
|
+
let bodyExtra = get(opt, 'body', null)
|
|
353
|
+
if (!isobj(bodyExtra)) {
|
|
354
|
+
bodyExtra = {}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
//headersExtra
|
|
358
|
+
let headersExtra = get(opt, 'headers', null)
|
|
359
|
+
if (!isobj(headersExtra)) {
|
|
360
|
+
headersExtra = {}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
//timeoutMs
|
|
364
|
+
let timeoutMs = get(opt, 'timeoutMs', null)
|
|
365
|
+
if (!ispint(timeoutMs)) {
|
|
366
|
+
timeoutMs = DEFAULT_TIMEOUT_MS
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
timeoutMs = cint(timeoutMs)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
//maxRetries
|
|
373
|
+
let maxRetries = get(opt, 'maxRetries', null)
|
|
374
|
+
if (!isp0int(maxRetries)) {
|
|
375
|
+
maxRetries = 0
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
maxRetries = cint(maxRetries)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
//retryDelayMs
|
|
382
|
+
let retryDelayMs = get(opt, 'retryDelayMs', null)
|
|
383
|
+
if (!ispint(retryDelayMs)) {
|
|
384
|
+
retryDelayMs = DEFAULT_RETRY_DELAY_MS
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
retryDelayMs = cint(retryDelayMs)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
//validator
|
|
391
|
+
let validator = buildValidator(get(opt, 'validate', null))
|
|
392
|
+
|
|
393
|
+
//url, baseURL尾端斜線正規化後接上端點
|
|
394
|
+
let url = baseURL.replace(/\/+$/, '') + '/chat/completions'
|
|
395
|
+
|
|
396
|
+
//messages
|
|
397
|
+
let messages = []
|
|
398
|
+
if (isestr(system)) {
|
|
399
|
+
messages.push({ role: 'system', content: system })
|
|
400
|
+
}
|
|
401
|
+
messages.push({ role: 'user', content: prompt })
|
|
402
|
+
|
|
403
|
+
//body, 額外鍵以bodyExtra為準(可覆寫temperature等, 覆寫messages屬進階用法)
|
|
404
|
+
let body = { model, messages, ...bodyExtra }
|
|
405
|
+
|
|
406
|
+
//headers
|
|
407
|
+
let headers = { 'Content-Type': 'application/json', ...headersExtra }
|
|
408
|
+
if (isestr(key)) {
|
|
409
|
+
headers['Authorization'] = `Bearer ${key}`
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
let lastResult = null
|
|
413
|
+
let totalAttempts = 0
|
|
414
|
+
|
|
415
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
416
|
+
|
|
417
|
+
//delay, 重試間隔隨次數遞增, 上限15000ms(同execCli)
|
|
418
|
+
if (attempt > 0) {
|
|
419
|
+
await delay(Math.min(retryDelayMs * attempt, MAX_RETRY_DELAY_MS))
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
lastResult = await callOnce(url, headers, body, timeoutMs, validator)
|
|
423
|
+
totalAttempts = attempt + 1
|
|
424
|
+
|
|
425
|
+
if (lastResult.ok) {
|
|
426
|
+
lastResult.attempts = totalAttempts
|
|
427
|
+
return lastResult
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
//不可重試: 4xx(429除外)為客戶端錯誤, 重試無意義
|
|
431
|
+
let c = lastResult.code
|
|
432
|
+
if (isnum(c) && c >= 400 && c < 500 && c !== 429) {
|
|
433
|
+
break
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
lastResult.attempts = totalAttempts
|
|
439
|
+
|
|
440
|
+
return lastResult
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
export default dispatchApiOpenaiCompat
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import get from 'lodash-es/get.js'
|
|
2
|
+
import isearr from 'wsemi/src/isearr.mjs'
|
|
3
|
+
import isestr from 'wsemi/src/isestr.mjs'
|
|
4
|
+
import isfun from 'wsemi/src/isfun.mjs'
|
|
5
|
+
import isobj from 'wsemi/src/isobj.mjs'
|
|
6
|
+
import dispatchAiFallback from '../dispatchAiFallback.mjs'
|
|
7
|
+
import extractJsonLoose from './extractJsonLoose.mjs'
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
// callAiWithFallback.mjs — 工作流的最小呼叫單元: 一個「AI名額」=主模型+自帶遞補鏈
|
|
11
|
+
//
|
|
12
|
+
// 【設計】呼叫端以名稱宣告主模型與遞補(use:'deepseek', fallback:['agnes-ai','sonnet']),
|
|
13
|
+
// 本函數依providers定義表把名稱展開成dispatchAiFallback的providers陣列
|
|
14
|
+
// (順序即優先序), 故「各AI名額可各自指定fallback」天然成立。
|
|
15
|
+
// 名稱查無定義時視為設定錯誤直接回報(fail fast), 不靜默略過——
|
|
16
|
+
// 否則fallback名稱打錯字只會讓遞補鏈無感知地短一截, 事後無從察覺。
|
|
17
|
+
//
|
|
18
|
+
// 【JSON驗證接進遞補層】parse+check包成dispatchAiFallback的validate:
|
|
19
|
+
// 回覆非法(空回、截斷、缺欄位)時為OUTPUT_VALIDATION_FAILED, 遞補層視為
|
|
20
|
+
// 與金鑰無關之失敗而「整組跳過換下一家」(不換組內金鑰——同模型換金鑰仍是
|
|
21
|
+
// 同樣的產出習慣); 端點不穩而偶發空回的模型, 以maxRetries調高令同鍵重試。
|
|
22
|
+
//
|
|
23
|
+
// 【防寫檔前綴】agentic CLI對cwd隔離免疫(會自行解析專案根目錄寫檔),
|
|
24
|
+
// 故預設在prompt前掛「禁止建檔」約束(實測有效); 不需要時傳promptPrefix:''關閉。
|
|
25
|
+
// 殷鑑: 2026-08-10執行任務歷史.md遭AI覆寫、評比腳本繞過前綴又產生根目錄孤兒檔。
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
//預設防寫檔前綴
|
|
29
|
+
let NO_SIDE_EFFECT = [
|
|
30
|
+
'【執行約束】你只需把結果輸出在回覆內容中。',
|
|
31
|
+
'禁止建立、修改或刪除任何檔案,禁止執行任何指令——呼叫端只讀取你的回覆文字,',
|
|
32
|
+
'任何寫入磁碟的動作都不會被採用,只會製造無人讀取的垃圾檔。',
|
|
33
|
+
'', '',
|
|
34
|
+
].join('\n')
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 依providers定義表把「名稱規格」展開成dispatchAiFallback的providers陣列
|
|
39
|
+
*
|
|
40
|
+
* @param {Object} providers 輸入定義表物件(名稱 → 條目)
|
|
41
|
+
* @param {Object} spec 輸入名額規格物件{ use, fallback }
|
|
42
|
+
* @returns {Object} 回傳物件,內含chain(條目物件陣列,id一律用名稱)與missing(查無定義之名稱字串陣列)
|
|
43
|
+
* @example
|
|
44
|
+
*
|
|
45
|
+
* import { buildChain } from './src/wkf/callAiWithFallback.mjs'
|
|
46
|
+
*
|
|
47
|
+
* let providers = { a: { kind: 'claude' }, b: { kind: 'codex' } }
|
|
48
|
+
* console.log(buildChain(providers, { use: 'a', fallback: ['b', 'c'] }))
|
|
49
|
+
* // => { chain: [ { id: 'a', kind: 'claude' }, { id: 'b', kind: 'codex' } ], missing: [ 'c' ] }
|
|
50
|
+
*
|
|
51
|
+
*/
|
|
52
|
+
function buildChain(providers, spec) {
|
|
53
|
+
let names = [get(spec, 'use', '')]
|
|
54
|
+
let fallback = get(spec, 'fallback', null)
|
|
55
|
+
if (isearr(fallback)) {
|
|
56
|
+
names = [...names, ...fallback]
|
|
57
|
+
}
|
|
58
|
+
let chain = []
|
|
59
|
+
let missing = []
|
|
60
|
+
for (let name of names) {
|
|
61
|
+
let entry = get(providers, name, null)
|
|
62
|
+
if (isobj(entry)) {
|
|
63
|
+
chain.push({ id: name, ...entry }) //id一律用名稱, 令游標與事件可讀
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
missing.push(String(name))
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { chain, missing }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 呼叫一個AI名額(主模型+自帶遞補鏈),回覆經parse+check驗證後回傳結構化結果
|
|
75
|
+
*
|
|
76
|
+
* 特點:
|
|
77
|
+
* spec.use為主模型名稱、spec.fallback為遞補名稱陣列,依序展開為遞補鏈,名稱查無定義即回報錯誤(fail fast);
|
|
78
|
+
* parse+check接進遞補層之validate——非法回覆視為該家失敗而自動換下一家,不把壞結果帶回來;
|
|
79
|
+
* 預設掛防寫檔前綴(promptPrefix傳空字串可關閉);
|
|
80
|
+
* 本函數不會reject,一律以結果物件之ok與error欄位回報成敗
|
|
81
|
+
*
|
|
82
|
+
* @param {String} prompt 輸入提示詞字串
|
|
83
|
+
* @param {Object} [opt={}] 輸入設定物件,預設{}
|
|
84
|
+
* @param {Object} opt.providers 輸入provider定義表物件(名稱 → dispatchAiFallback條目,條目內含kind、model、keys、exe、provider、config等)
|
|
85
|
+
* @param {Object} opt.spec 輸入名額規格物件{ use:'主模型名稱', fallback:['遞補名稱', ...] }
|
|
86
|
+
* @param {Function} [opt.check=null] 輸入結果檢核函數(json)=>Boolean,預設null代表只要能解析出JSON即通過
|
|
87
|
+
* @param {Function} [opt.parse=extractJsonLoose] 輸入回覆解析函數(stdout)=>Object|null,預設寬鬆JSON抽取
|
|
88
|
+
* @param {Boolean} [opt.rawText=false] 輸入是否以純文字模式運作布林值,true代表不解析JSON(json欄位為修剪後文字、check收文字),預設false
|
|
89
|
+
* @param {String} [opt.promptPrefix=防寫檔約束] 輸入prompt前綴字串,預設為防寫檔約束,傳''關閉
|
|
90
|
+
* @param {Number} [opt.timeoutMs=300000] 輸入單次嘗試逾時毫秒正整數,預設300000
|
|
91
|
+
* @param {Number} [opt.budgetMs=null] 輸入整條遞補鏈之時間預算毫秒正整數,預設null代表不限
|
|
92
|
+
* @param {Number} [opt.maxRetries=0] 輸入同家重試次數非負整數,預設0(韌性交給遞補;端點不穩偶發空回之模型可調高令同鍵重試)
|
|
93
|
+
* @param {String} [opt.cwd=process.cwd()] 輸入子進程工作目錄字串,預設process.cwd()
|
|
94
|
+
* @param {Object} [opt.store=null] 輸入游標持久化物件{get,set},預設null代表用行程內記憶體
|
|
95
|
+
* @param {Function} [opt.onEvent=null] 輸入遞補層事件回調函數,預設null
|
|
96
|
+
* @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否取得可用結果布林值)、json(解析後物件,rawText模式下為文字)、providerId(實際使用之名稱)、keyIndex、keyId、ms(總耗時毫秒)、tried(遞補嘗試歷程陣列)、error(錯誤訊息字串),本函數不會reject
|
|
97
|
+
* @example
|
|
98
|
+
* //need cli in system PATH
|
|
99
|
+
*
|
|
100
|
+
* import callAiWithFallback from './src/wkf/callAiWithFallback.mjs'
|
|
101
|
+
*
|
|
102
|
+
* //鍵名須區分到模型並帶上路徑, 詳見dispatchAiFallback.mjs檔頭之id設計規則
|
|
103
|
+
* let providers = {
|
|
104
|
+
* 'zen:deepseek-v4-flash-free': { kind: 'api-openai-compat', baseURL: 'https://opencode.ai/zen/v1', model: 'deepseek-v4-flash-free', keys: ['sk-xxx'] },
|
|
105
|
+
* 'claude:sonnet': { kind: 'claude', model: 'sonnet' },
|
|
106
|
+
* }
|
|
107
|
+
*
|
|
108
|
+
* let test = async () => {
|
|
109
|
+
*
|
|
110
|
+
* let r = await callAiWithFallback('只回覆JSON: {"a":1}', {
|
|
111
|
+
* providers,
|
|
112
|
+
* spec: { use: 'zen:deepseek-v4-flash-free', fallback: ['claude:sonnet'] },
|
|
113
|
+
* check: (j) => j.a === 1,
|
|
114
|
+
* })
|
|
115
|
+
* console.log(r.ok, r.json, r.providerId)
|
|
116
|
+
* // => true { a: 1 } 'zen:deepseek-v4-flash-free'
|
|
117
|
+
*
|
|
118
|
+
* }
|
|
119
|
+
* await test()
|
|
120
|
+
* .catch((err) => {
|
|
121
|
+
* console.log(err)
|
|
122
|
+
* })
|
|
123
|
+
*
|
|
124
|
+
*/
|
|
125
|
+
async function callAiWithFallback(prompt, opt = {}) {
|
|
126
|
+
let t0 = Date.now()
|
|
127
|
+
|
|
128
|
+
if (!isestr(prompt)) {
|
|
129
|
+
return { ok: false, json: null, error: 'prompt must be a non-empty string', ms: 0, tried: [] }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let providers = get(opt, 'providers', null)
|
|
133
|
+
let spec = get(opt, 'spec', null)
|
|
134
|
+
if (!isobj(providers) || !isobj(spec)) {
|
|
135
|
+
return { ok: false, json: null, error: `no valid provider for spec: ${JSON.stringify(spec)}`, ms: 0, tried: [] }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
//buildChain, 名稱查無定義即回報(fail fast), 不讓打錯字的fallback靜默消失
|
|
139
|
+
let { chain, missing } = buildChain(providers, spec)
|
|
140
|
+
if (missing.length > 0) {
|
|
141
|
+
return { ok: false, json: null, error: `unknown provider name(s): ${missing.join(', ')}`, ms: 0, tried: [] }
|
|
142
|
+
}
|
|
143
|
+
if (chain.length === 0) {
|
|
144
|
+
return { ok: false, json: null, error: `no valid provider for spec: ${JSON.stringify(spec)}`, ms: 0, tried: [] }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let rawText = get(opt, 'rawText', false) === true
|
|
148
|
+
let parse = get(opt, 'parse', null)
|
|
149
|
+
if (!isfun(parse)) {
|
|
150
|
+
parse = extractJsonLoose
|
|
151
|
+
}
|
|
152
|
+
let check = get(opt, 'check', null)
|
|
153
|
+
if (!isfun(check)) {
|
|
154
|
+
check = null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let promptPrefix = get(opt, 'promptPrefix', null)
|
|
158
|
+
if (!isestr(promptPrefix)) {
|
|
159
|
+
promptPrefix = (promptPrefix === '') ? '' : NO_SIDE_EFFECT
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
//validate接進遞補層: 非法回覆=這一家失敗, 遞補層換下一家
|
|
163
|
+
let validate = (stdout) => {
|
|
164
|
+
if (rawText) {
|
|
165
|
+
let s = String(stdout || '').trim()
|
|
166
|
+
if (s === '') {
|
|
167
|
+
return false
|
|
168
|
+
}
|
|
169
|
+
return check ? check(s) === true : true
|
|
170
|
+
}
|
|
171
|
+
let j = parse(stdout)
|
|
172
|
+
if (j === null) {
|
|
173
|
+
return false
|
|
174
|
+
}
|
|
175
|
+
return check ? check(j) === true : true
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let r = await dispatchAiFallback(promptPrefix + prompt, {
|
|
179
|
+
providers: chain,
|
|
180
|
+
validate,
|
|
181
|
+
timeoutMs: get(opt, 'timeoutMs', null) || 300000,
|
|
182
|
+
budgetMs: get(opt, 'budgetMs', null) || undefined,
|
|
183
|
+
maxRetries: get(opt, 'maxRetries', null) || 0,
|
|
184
|
+
cwd: get(opt, 'cwd', null) || process.cwd(),
|
|
185
|
+
store: get(opt, 'store', null) || undefined,
|
|
186
|
+
onEvent: get(opt, 'onEvent', null) || undefined,
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
//result, 已過validate故此處parse必然成功(同一解析器), 重解析僅為取出物件
|
|
190
|
+
let result = null
|
|
191
|
+
if (r.ok) {
|
|
192
|
+
result = rawText ? String(r.stdout || '').trim() : parse(r.stdout)
|
|
193
|
+
}
|
|
194
|
+
let providerId = get(r, 'providerId', null)
|
|
195
|
+
let keyIndex = get(r, 'keyIndex', null)
|
|
196
|
+
return {
|
|
197
|
+
ok: r.ok && result !== null,
|
|
198
|
+
json: result, //rawText模式下此欄為文字
|
|
199
|
+
providerId,
|
|
200
|
+
keyIndex,
|
|
201
|
+
keyId: (keyIndex === null) ? providerId : `${providerId}#${keyIndex}`,
|
|
202
|
+
ms: Date.now() - t0,
|
|
203
|
+
tried: get(r, 'tried', []),
|
|
204
|
+
error: r.ok ? '' : get(r, 'error', 'unknown error'),
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
export default callAiWithFallback
|
|
210
|
+
export { buildChain, NO_SIDE_EFFECT }
|