w-dispatch-ai 1.0.1 → 1.0.3

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.
Files changed (46) hide show
  1. package/README.md +83 -5
  2. package/dist/w-dispatch-ai.umd.js +2 -2
  3. package/dist/w-dispatch-ai.umd.js.map +1 -1
  4. package/docs/WDispatchAi.mjs.html +10 -4
  5. package/docs/adapters.mjs.html +10 -6
  6. package/docs/dispatchAi.mjs.html +2 -2
  7. package/docs/dispatchAiFallback.mjs.html +2 -2
  8. package/docs/dispatchAiWkf.mjs.html +172 -0
  9. package/docs/dispatchAntigravity.mjs.html +264 -0
  10. package/docs/dispatchApiOpenaiCompat.mjs.html +483 -0
  11. package/docs/dispatchClaude.mjs.html +2 -2
  12. package/docs/dispatchCodex.mjs.html +2 -2
  13. package/docs/dispatchOpencode.mjs.html +2 -2
  14. package/docs/getCliArgs.mjs.html +2 -2
  15. package/docs/getErrorResult.mjs.html +2 -2
  16. package/docs/global.html +6095 -1092
  17. package/docs/index.html +2 -2
  18. package/docs/wkf_callAiWithFallback.mjs.html +281 -0
  19. package/docs/wkf_extractJsonLoose.mjs.html +180 -0
  20. package/docs/wkf_runFanout.mjs.html +227 -0
  21. package/docs/wkf_runFanoutPipeline.mjs.html +178 -0
  22. package/docs/wkf_runRolePipeline.mjs.html +195 -0
  23. package/g.mjs +19 -2
  24. package/package.json +1 -1
  25. package/src/WDispatchAi.mjs +8 -2
  26. package/src/adapters.mjs +8 -4
  27. package/src/dispatchAiWkf.mjs +100 -0
  28. package/src/dispatchAntigravity.mjs +192 -0
  29. package/src/dispatchApiOpenaiCompat.mjs +411 -0
  30. package/src/wkf/callAiWithFallback.mjs +209 -0
  31. package/src/wkf/extractJsonLoose.mjs +108 -0
  32. package/src/wkf/runFanout.mjs +155 -0
  33. package/src/wkf/runFanoutPipeline.mjs +106 -0
  34. package/src/wkf/runRolePipeline.mjs +123 -0
  35. package/test/tools/fakeServerForApiTest.mjs +137 -0
  36. package/test/unit-WDispatchAi.test.mjs +17 -6
  37. package/test/unit-adapters.test.mjs +7 -3
  38. package/test/unit-callAiWithFallback.test.mjs +146 -0
  39. package/test/unit-dispatchAi.test.mjs +7 -3
  40. package/test/unit-dispatchAiWkf.test.mjs +118 -0
  41. package/test/unit-dispatchAntigravity.test.mjs +173 -0
  42. package/test/unit-dispatchApiOpenaiCompat.test.mjs +233 -0
  43. package/test/unit-extractJsonLoose.test.mjs +78 -0
  44. package/test/unit-runFanout.test.mjs +166 -0
  45. package/test/unit-runFanoutPipeline.test.mjs +86 -0
  46. package/test/unit-runRolePipeline.test.mjs +163 -0
@@ -0,0 +1,411 @@
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
+ // 【重試語意對齊execCli】4xx(429除外)為客戶端錯誤不可重試而立即中止;
31
+ // 429/5xx/網路錯誤/逾時依maxRetries線性退避重試(間隔retryDelayMs*次數, 上限15000ms)。
32
+ //
33
+ // 【結果結構對齊execCli】{ ok, stdout, stderr, code, error, durationMs, attempts },
34
+ // stdout為回覆內容、code為HTTP狀態碼(網路錯誤與逾時為null)、逾時error以TIMEOUT開頭、
35
+ // 驗證失敗error為OUTPUT_VALIDATION_FAILED——故dispatchAiFallback之失敗分流
36
+ // (TIMEOUT/驗證失敗跳組, 其餘換金鑰)對本轉接器同樣成立, 無須任何修改。
37
+
38
+
39
+ //預設值
40
+ let DEFAULT_TIMEOUT_MS = 120000
41
+ let DEFAULT_RETRY_DELAY_MS = 5000
42
+ let MAX_RETRY_DELAY_MS = 15000
43
+
44
+
45
+ //optTruncate, 裁切失敗結果之內容時於刪節號後標註原始總長度(同execCli)
46
+ let optTruncate = {
47
+ funWithMsg: (str) => `(truncated, total ${str.length} chars)`,
48
+ }
49
+
50
+
51
+ /**
52
+ * 建立驗證函式(規則語法同execCli之validate)
53
+ *
54
+ * @param {String|Function} rule 輸入驗證規則字串('nonempty'、'json'、'min:100', 逗號可串接)或自訂函式
55
+ * @returns {Function|null} 回傳驗證函式,無有效規則回傳null
56
+ */
57
+ function buildValidator(rule) {
58
+
59
+ //自訂函式直接使用
60
+ if (isfun(rule)) {
61
+ return rule
62
+ }
63
+
64
+ //check
65
+ if (!isestr(rule)) {
66
+ return null
67
+ }
68
+
69
+ //checks
70
+ let checks = rule.split(',').map((r) => r.trim()).filter(Boolean)
71
+ if (checks.length === 0) {
72
+ return null
73
+ }
74
+
75
+ return (stdout) => {
76
+ for (let check of checks) {
77
+
78
+ if (check === 'nonempty') {
79
+ if (!isestr(stdout) || stdout.trim() === '') {
80
+ return false
81
+ }
82
+ }
83
+
84
+ else if (check === 'json') {
85
+ try {
86
+ JSON.parse(stdout)
87
+ }
88
+ catch {
89
+ return false
90
+ }
91
+ }
92
+
93
+ else if (strleft(check, 4) === 'min:') {
94
+
95
+ //規則本身無效(如min:abc) → 視為驗證失敗, 不靜默跳過
96
+ let smin = strdelleft(check, 4)
97
+ if (!isnum(smin)) {
98
+ return false
99
+ }
100
+
101
+ let min = cint(smin)
102
+ if (!isestr(stdout) || stdout.length < min) {
103
+ return false
104
+ }
105
+ }
106
+
107
+ }
108
+ return true
109
+ }
110
+ }
111
+
112
+
113
+ /**
114
+ * 單次HTTP呼叫(內部使用, 不含重試邏輯)
115
+ *
116
+ * @param {String} url 輸入完整端點網址字串
117
+ * @param {Object} headers 輸入請求標頭物件
118
+ * @param {Object} body 輸入請求本體物件
119
+ * @param {Number} timeoutMs 輸入逾時毫秒
120
+ * @param {Function|null} validator 輸入驗證函式
121
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件
122
+ */
123
+ async function callOnce(url, headers, body, timeoutMs, validator) {
124
+
125
+ let t0 = Date.now()
126
+
127
+ //AbortController, 逾時中止(含回應本體之串流讀取)
128
+ let controller = new AbortController()
129
+ let timer = setTimeout(() => {
130
+ controller.abort()
131
+ }, timeoutMs)
132
+
133
+ let res = null
134
+ let txt = ''
135
+ try {
136
+ res = await fetch(url, {
137
+ method: 'POST',
138
+ headers,
139
+ body: JSON.stringify(body),
140
+ signal: controller.signal,
141
+ })
142
+ txt = await res.text()
143
+ }
144
+ catch (err) {
145
+ clearTimeout(timer)
146
+ let durationMs = Date.now() - t0
147
+
148
+ //逾時, error以TIMEOUT開頭令dispatchAiFallback視為與金鑰無關而跳組
149
+ if (err.name === 'AbortError') {
150
+ return {
151
+ ok: false,
152
+ stdout: '',
153
+ stderr: '',
154
+ code: null,
155
+ error: `TIMEOUT after ${timeoutMs / 1000}s`,
156
+ durationMs,
157
+ }
158
+ }
159
+
160
+ //網路層錯誤(DNS/連線拒絕等)
161
+ let cause = get(err, 'cause.code', '') || err.message
162
+ return {
163
+ ok: false,
164
+ stdout: '',
165
+ stderr: '',
166
+ code: null,
167
+ error: `FETCH_ERROR: ${cause}`,
168
+ durationMs,
169
+ }
170
+ }
171
+ clearTimeout(timer)
172
+
173
+ let durationMs = Date.now() - t0
174
+
175
+ //HTTP非2xx, 原始回應本體放stderr供除錯與分類
176
+ if (!res.ok) {
177
+ return {
178
+ ok: false,
179
+ stdout: '',
180
+ stderr: strTruncate(txt, 1000, optTruncate),
181
+ code: res.status,
182
+ error: `HTTP ${res.status}`,
183
+ durationMs,
184
+ }
185
+ }
186
+
187
+ //取出choices[0].message.content
188
+ let content = null
189
+ try {
190
+ let j = JSON.parse(txt)
191
+ content = get(j, 'choices.0.message.content', null)
192
+ }
193
+ catch {}
194
+ if (content === null || content === undefined) {
195
+ return {
196
+ ok: false,
197
+ stdout: '',
198
+ stderr: strTruncate(txt, 500, optTruncate),
199
+ code: res.status,
200
+ error: 'INVALID_RESPONSE: missing choices[0].message.content',
201
+ durationMs,
202
+ }
203
+ }
204
+ if (typeof content !== 'string') {
205
+ content = JSON.stringify(content) //少數閘道回array形態
206
+ }
207
+
208
+ //validator, error與execCli一致令dispatchAiFallback可統一分流
209
+ if (validator && !validator(content)) {
210
+ return {
211
+ ok: false,
212
+ stdout: strTruncate(content, 500, optTruncate),
213
+ stderr: '',
214
+ code: res.status,
215
+ error: 'OUTPUT_VALIDATION_FAILED',
216
+ durationMs,
217
+ }
218
+ }
219
+
220
+ return {
221
+ ok: true,
222
+ stdout: content,
223
+ stderr: '',
224
+ code: res.status,
225
+ error: '',
226
+ durationMs,
227
+ }
228
+ }
229
+
230
+
231
+ //本轉接器不使用execCli, 全部設定鍵自理, 未知鍵一律忽略
232
+
233
+
234
+ /**
235
+ * 以fetch直呼OpenAI相容API(chat/completions)呼叫AI模型
236
+ *
237
+ * 特點:
238
+ * 免安裝CLI、免預先登入,給baseURL+key+model即可呼叫(如OpenCode Zen、Agnes等OpenAI相容閘道);
239
+ * prompt走HTTP body,無命令列長度限制;
240
+ * 錯誤依HTTP狀態碼分流:4xx(429除外)為客戶端錯誤不重試,429/5xx/網路錯誤/逾時依maxRetries線性退避重試;
241
+ * 結果結構與逾時/驗證失敗之error字樣對齊execCli,可直接作為dispatchAi與dispatchAiFallback之kind('api-openai-compat')使用;
242
+ * 本函數不會reject,一律以結果物件之ok與error欄位回報成敗
243
+ *
244
+ * @param {String} prompt 輸入提示詞字串,作為user訊息置於HTTP body
245
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
246
+ * @param {String} opt.baseURL 輸入API基底網址字串,例如'https://opencode.ai/zen/v1'、'https://apihub.agnes-ai.com/v1',將於尾端接上/chat/completions
247
+ * @param {String} opt.model 輸入模型ID字串,例如'deepseek-v4-flash-free'(Zen之模型名不帶opencode/前綴)、'agnes-2.0-flash'
248
+ * @param {String} [opt.key=''] 輸入API key字串,以Bearer置於Authorization標頭,預設''代表不帶認證標頭
249
+ * @param {String} [opt.system=''] 輸入system提示詞字串,將以system角色置於messages首位,預設''代表不帶
250
+ * @param {Object} [opt.body={}] 輸入額外請求本體物件(如temperature、max_tokens、response_format),將併入預設body(同名鍵以此為準),預設{}
251
+ * @param {Object} [opt.headers={}] 輸入額外請求標頭物件,預設{}
252
+ * @param {Number} [opt.timeoutMs=120000] 輸入逾時毫秒正整數,逾時將中止請求(含回應串流讀取),預設120000
253
+ * @param {String|Function} [opt.validate=undefined] 輸入回覆內容驗證規則字串或自訂驗證函數,規則字串支援'nonempty'、'json'、'min:100',多規則可用逗號串接,預設undefined代表不驗證
254
+ * @param {Number} [opt.maxRetries=0] 輸入失敗後最大重試次數非負整數,4xx(429除外)不重試,預設0
255
+ * @param {Number} [opt.retryDelayMs=5000] 輸入重試間隔毫秒正整數,實際間隔為retryDelayMs乘以重試次數且上限15000ms,預設5000
256
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否成功布林值)、stdout(回覆內容字串)、stderr(失敗時之原始回應本體)、code(HTTP狀態碼,網路錯誤與逾時為null)、error(錯誤訊息字串,成功時為空字串)、durationMs(耗時毫秒)、attempts(實際嘗試次數),本函數不會reject
257
+ * @example
258
+ * //need network, no cli required
259
+ *
260
+ * import dispatchApiOpenaiCompat from './src/dispatchApiOpenaiCompat.mjs'
261
+ *
262
+ * let test = async () => {
263
+ *
264
+ * //OpenCode Zen(即opencode CLI之自家閘道), 模型名不帶opencode/前綴
265
+ * let r1 = await dispatchApiOpenaiCompat('請只回覆兩個字:完成', {
266
+ * baseURL: 'https://opencode.ai/zen/v1',
267
+ * key: 'sk-xxxxxx',
268
+ * model: 'deepseek-v4-flash-free',
269
+ * })
270
+ * console.log(r1.ok, r1.stdout.trim())
271
+ * // => true 完成
272
+ *
273
+ * //Agnes
274
+ * let r2 = await dispatchApiOpenaiCompat('請只回覆兩個字:完成', {
275
+ * baseURL: 'https://apihub.agnes-ai.com/v1',
276
+ * key: 'sk-xxxxxx',
277
+ * model: 'agnes-2.0-flash',
278
+ * })
279
+ * console.log(r2.ok, r2.stdout.trim())
280
+ * // => true 完成
281
+ *
282
+ * let re = await dispatchApiOpenaiCompat('abc', { baseURL: 'https://opencode.ai/zen/v1', key: 'sk-bad', model: 'deepseek-v4-flash-free' })
283
+ * console.log(re.ok, re.code, re.error)
284
+ * // => false 401 HTTP 401
285
+ *
286
+ * }
287
+ * await test()
288
+ * .catch((err) => {
289
+ * console.log(err)
290
+ * })
291
+ *
292
+ */
293
+ async function dispatchApiOpenaiCompat(prompt, opt = {}) {
294
+
295
+ //check prompt, 不reject故以錯誤結果物件回報
296
+ if (!isestr(prompt)) {
297
+ return getErrorResult('prompt must be a non-empty string')
298
+ }
299
+
300
+ //baseURL必填, API無CLI可回退
301
+ let baseURL = get(opt, 'baseURL', null)
302
+ if (!isestr(baseURL)) {
303
+ return getErrorResult('baseURL must be a non-empty string')
304
+ }
305
+
306
+ //model必填, chat/completions無預設模型
307
+ let model = get(opt, 'model', null)
308
+ if (!isestr(model)) {
309
+ return getErrorResult('model must be a non-empty string')
310
+ }
311
+
312
+ //key, 無效代表不帶認證標頭(部分閘道免認證)
313
+ let key = get(opt, 'key', null)
314
+
315
+ //system
316
+ let system = get(opt, 'system', null)
317
+
318
+ //bodyExtra
319
+ let bodyExtra = get(opt, 'body', null)
320
+ if (!isobj(bodyExtra)) {
321
+ bodyExtra = {}
322
+ }
323
+
324
+ //headersExtra
325
+ let headersExtra = get(opt, 'headers', null)
326
+ if (!isobj(headersExtra)) {
327
+ headersExtra = {}
328
+ }
329
+
330
+ //timeoutMs
331
+ let timeoutMs = get(opt, 'timeoutMs', null)
332
+ if (!ispint(timeoutMs)) {
333
+ timeoutMs = DEFAULT_TIMEOUT_MS
334
+ }
335
+ else {
336
+ timeoutMs = cint(timeoutMs)
337
+ }
338
+
339
+ //maxRetries
340
+ let maxRetries = get(opt, 'maxRetries', null)
341
+ if (!isp0int(maxRetries)) {
342
+ maxRetries = 0
343
+ }
344
+ else {
345
+ maxRetries = cint(maxRetries)
346
+ }
347
+
348
+ //retryDelayMs
349
+ let retryDelayMs = get(opt, 'retryDelayMs', null)
350
+ if (!ispint(retryDelayMs)) {
351
+ retryDelayMs = DEFAULT_RETRY_DELAY_MS
352
+ }
353
+ else {
354
+ retryDelayMs = cint(retryDelayMs)
355
+ }
356
+
357
+ //validator
358
+ let validator = buildValidator(get(opt, 'validate', null))
359
+
360
+ //url, baseURL尾端斜線正規化後接上端點
361
+ let url = baseURL.replace(/\/+$/, '') + '/chat/completions'
362
+
363
+ //messages
364
+ let messages = []
365
+ if (isestr(system)) {
366
+ messages.push({ role: 'system', content: system })
367
+ }
368
+ messages.push({ role: 'user', content: prompt })
369
+
370
+ //body, 額外鍵以bodyExtra為準(可覆寫temperature等, 覆寫messages屬進階用法)
371
+ let body = { model, messages, ...bodyExtra }
372
+
373
+ //headers
374
+ let headers = { 'Content-Type': 'application/json', ...headersExtra }
375
+ if (isestr(key)) {
376
+ headers['Authorization'] = `Bearer ${key}`
377
+ }
378
+
379
+ let lastResult = null
380
+ let totalAttempts = 0
381
+
382
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
383
+
384
+ //delay, 重試間隔隨次數遞增, 上限15000ms(同execCli)
385
+ if (attempt > 0) {
386
+ await delay(Math.min(retryDelayMs * attempt, MAX_RETRY_DELAY_MS))
387
+ }
388
+
389
+ lastResult = await callOnce(url, headers, body, timeoutMs, validator)
390
+ totalAttempts = attempt + 1
391
+
392
+ if (lastResult.ok) {
393
+ lastResult.attempts = totalAttempts
394
+ return lastResult
395
+ }
396
+
397
+ //不可重試: 4xx(429除外)為客戶端錯誤, 重試無意義
398
+ let c = lastResult.code
399
+ if (isnum(c) && c >= 400 && c < 500 && c !== 429) {
400
+ break
401
+ }
402
+
403
+ }
404
+
405
+ lastResult.attempts = totalAttempts
406
+
407
+ return lastResult
408
+ }
409
+
410
+
411
+ export default dispatchApiOpenaiCompat
@@ -0,0 +1,209 @@
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
+ * let providers = {
103
+ * 'deepseek': { kind: 'opencode', model: 'opencode/deepseek-v4-flash-free', provider: 'opencode', keys: ['sk-xxx'] },
104
+ * 'sonnet': { kind: 'claude', model: 'sonnet' },
105
+ * }
106
+ *
107
+ * let test = async () => {
108
+ *
109
+ * let r = await callAiWithFallback('只回覆JSON: {"a":1}', {
110
+ * providers,
111
+ * spec: { use: 'deepseek', fallback: ['sonnet'] },
112
+ * check: (j) => j.a === 1,
113
+ * })
114
+ * console.log(r.ok, r.json, r.providerId)
115
+ * // => true { a: 1 } 'deepseek'
116
+ *
117
+ * }
118
+ * await test()
119
+ * .catch((err) => {
120
+ * console.log(err)
121
+ * })
122
+ *
123
+ */
124
+ async function callAiWithFallback(prompt, opt = {}) {
125
+ let t0 = Date.now()
126
+
127
+ if (!isestr(prompt)) {
128
+ return { ok: false, json: null, error: 'prompt must be a non-empty string', ms: 0, tried: [] }
129
+ }
130
+
131
+ let providers = get(opt, 'providers', null)
132
+ let spec = get(opt, 'spec', null)
133
+ if (!isobj(providers) || !isobj(spec)) {
134
+ return { ok: false, json: null, error: `no valid provider for spec: ${JSON.stringify(spec)}`, ms: 0, tried: [] }
135
+ }
136
+
137
+ //buildChain, 名稱查無定義即回報(fail fast), 不讓打錯字的fallback靜默消失
138
+ let { chain, missing } = buildChain(providers, spec)
139
+ if (missing.length > 0) {
140
+ return { ok: false, json: null, error: `unknown provider name(s): ${missing.join(', ')}`, ms: 0, tried: [] }
141
+ }
142
+ if (chain.length === 0) {
143
+ return { ok: false, json: null, error: `no valid provider for spec: ${JSON.stringify(spec)}`, ms: 0, tried: [] }
144
+ }
145
+
146
+ let rawText = get(opt, 'rawText', false) === true
147
+ let parse = get(opt, 'parse', null)
148
+ if (!isfun(parse)) {
149
+ parse = extractJsonLoose
150
+ }
151
+ let check = get(opt, 'check', null)
152
+ if (!isfun(check)) {
153
+ check = null
154
+ }
155
+
156
+ let promptPrefix = get(opt, 'promptPrefix', null)
157
+ if (!isestr(promptPrefix)) {
158
+ promptPrefix = (promptPrefix === '') ? '' : NO_SIDE_EFFECT
159
+ }
160
+
161
+ //validate接進遞補層: 非法回覆=這一家失敗, 遞補層換下一家
162
+ let validate = (stdout) => {
163
+ if (rawText) {
164
+ let s = String(stdout || '').trim()
165
+ if (s === '') {
166
+ return false
167
+ }
168
+ return check ? check(s) === true : true
169
+ }
170
+ let j = parse(stdout)
171
+ if (j === null) {
172
+ return false
173
+ }
174
+ return check ? check(j) === true : true
175
+ }
176
+
177
+ let r = await dispatchAiFallback(promptPrefix + prompt, {
178
+ providers: chain,
179
+ validate,
180
+ timeoutMs: get(opt, 'timeoutMs', null) || 300000,
181
+ budgetMs: get(opt, 'budgetMs', null) || undefined,
182
+ maxRetries: get(opt, 'maxRetries', null) || 0,
183
+ cwd: get(opt, 'cwd', null) || process.cwd(),
184
+ store: get(opt, 'store', null) || undefined,
185
+ onEvent: get(opt, 'onEvent', null) || undefined,
186
+ })
187
+
188
+ //result, 已過validate故此處parse必然成功(同一解析器), 重解析僅為取出物件
189
+ let result = null
190
+ if (r.ok) {
191
+ result = rawText ? String(r.stdout || '').trim() : parse(r.stdout)
192
+ }
193
+ let providerId = get(r, 'providerId', null)
194
+ let keyIndex = get(r, 'keyIndex', null)
195
+ return {
196
+ ok: r.ok && result !== null,
197
+ json: result, //rawText模式下此欄為文字
198
+ providerId,
199
+ keyIndex,
200
+ keyId: (keyIndex === null) ? providerId : `${providerId}#${keyIndex}`,
201
+ ms: Date.now() - t0,
202
+ tried: get(r, 'tried', []),
203
+ error: r.ok ? '' : get(r, 'error', 'unknown error'),
204
+ }
205
+ }
206
+
207
+
208
+ export default callAiWithFallback
209
+ export { buildChain, NO_SIDE_EFFECT }