w-dispatch-ai 1.0.2 → 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.
- package/README.md +60 -5
- 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 +9 -7
- package/docs/dispatchAi.mjs.html +2 -2
- package/docs/dispatchAiFallback.mjs.html +2 -2
- package/docs/dispatchAiWkf.mjs.html +172 -0
- package/docs/dispatchAntigravity.mjs.html +2 -2
- package/docs/dispatchApiOpenaiCompat.mjs.html +483 -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 +5747 -1467
- package/docs/index.html +2 -2
- package/docs/wkf_callAiWithFallback.mjs.html +281 -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 +11 -2
- package/package.json +1 -1
- package/src/WDispatchAi.mjs +6 -2
- package/src/adapters.mjs +7 -5
- package/src/dispatchAiWkf.mjs +100 -0
- package/src/dispatchApiOpenaiCompat.mjs +411 -0
- package/src/wkf/callAiWithFallback.mjs +209 -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 +137 -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 +233 -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,123 @@
|
|
|
1
|
+
import get from 'lodash-es/get.js'
|
|
2
|
+
import omit from 'lodash-es/omit.js'
|
|
3
|
+
import isearr from 'wsemi/src/isearr.mjs'
|
|
4
|
+
import isestr from 'wsemi/src/isestr.mjs'
|
|
5
|
+
import isfun from 'wsemi/src/isfun.mjs'
|
|
6
|
+
import callAiWithFallback from './callAiWithFallback.mjs'
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
// runRolePipeline.mjs — RolePipeline工作流: 多角色串行鏈(stage1 → … → stageN)
|
|
10
|
+
//
|
|
11
|
+
// 【結構】各階段可各自指定AI(use)、遞補鏈(fallback)、提示詞(角色與任務)與檢核;
|
|
12
|
+
// 前一階段的成果傳給下一階段、一路傳到最末階段, 其成果即工作流成果。
|
|
13
|
+
//
|
|
14
|
+
// 【提示詞為函數】stage.prompt收ctx = { input, prev, results, index }:
|
|
15
|
+
// input=工作流輸入(如原始任務或Fanout的整合稿)、prev=上一階段成果、
|
|
16
|
+
// results=已完成各階段成果(依id查詢, 例如修訂階段要同時引用初稿與審計意見)。
|
|
17
|
+
//
|
|
18
|
+
// 【部分接受】某階段(含遞補全敗)失敗即中止後續, 但已完成階段之成果完整回傳
|
|
19
|
+
// (failedStage標明斷點), 呼叫端可據此只重跑失敗段而非整條鏈。
|
|
20
|
+
//
|
|
21
|
+
// 【實測依據(2026-08-10評比)】審計類角色鏈曾出現「審計刪過頭」——
|
|
22
|
+
// 修訂/終審提示詞應包含護欄「意見未涉及的內容不可刪除」; 此屬提示詞設計,
|
|
23
|
+
// 本函數不代寫角色提示詞, 由呼叫端(或上層預設模板)自理。
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
//各階段規格自用之鍵, 其餘鍵覆寫該階段之呼叫設定
|
|
27
|
+
let STAGE_KEYS = ['id', 'use', 'fallback', 'prompt', 'check']
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 執行RolePipeline工作流:多角色串行鏈
|
|
32
|
+
*
|
|
33
|
+
* 特點:
|
|
34
|
+
* 各階段可各自指定use/fallback/prompt/check(含rawText純文字階段);
|
|
35
|
+
* 階段成果依序傳遞,最末階段成果即工作流成果;
|
|
36
|
+
* 失敗即止但已完成成果完整回傳(部分接受、便於接續重跑失敗段);
|
|
37
|
+
* 本函數不會reject
|
|
38
|
+
*
|
|
39
|
+
* @param {Object} [opt={}] 輸入設定物件,預設{}
|
|
40
|
+
* @param {Object} opt.providers 輸入provider定義表物件(名稱 → 條目)
|
|
41
|
+
* @param {*} [opt.input=null] 輸入工作流輸入(原始任務字串或前一工作流之成果物件),提供給各階段ctx.input,預設null
|
|
42
|
+
* @param {Array} opt.stages 輸入階段規格陣列,各元素{ id, use, fallback, prompt:(ctx)=>String, check?, rawText?, maxRetries?, timeoutMs? }等
|
|
43
|
+
* @param {Object} [opt.callOpt={}] 輸入透傳callAiWithFallback之共用設定,預設{}
|
|
44
|
+
* @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(布林值)、result(最末階段成果)、stages(id對階段完整呼叫結果之物件)、results(id對階段成果之物件)、order(階段id順序陣列)、failedStage(失敗階段id,無失敗為null)、totalMs(總耗時毫秒)、error(錯誤訊息字串),本函數不會reject
|
|
45
|
+
* @example
|
|
46
|
+
* //need cli in system PATH
|
|
47
|
+
*
|
|
48
|
+
* import runRolePipeline from './src/wkf/runRolePipeline.mjs'
|
|
49
|
+
*
|
|
50
|
+
* let providers = {
|
|
51
|
+
* 'sonnet': { kind: 'claude', model: 'sonnet' },
|
|
52
|
+
* 'luna': { kind: 'codex', model: 'gpt-5.6-luna' },
|
|
53
|
+
* }
|
|
54
|
+
*
|
|
55
|
+
* let test = async () => {
|
|
56
|
+
*
|
|
57
|
+
* let r = await runRolePipeline({
|
|
58
|
+
* providers,
|
|
59
|
+
* input: '原始任務',
|
|
60
|
+
* stages: [
|
|
61
|
+
* { id: 'draft', use: 'sonnet', prompt: (ctx) => `就「${ctx.input}」寫初稿, 只回覆JSON: {"text":"..."}` },
|
|
62
|
+
* { id: 'review', use: 'luna', prompt: (ctx) => `審閱並修訂, 只回覆同格式JSON: ${JSON.stringify(ctx.prev)}` },
|
|
63
|
+
* ],
|
|
64
|
+
* })
|
|
65
|
+
* console.log(r.ok, r.order, r.failedStage)
|
|
66
|
+
* // => true [ 'draft', 'review' ] null
|
|
67
|
+
*
|
|
68
|
+
* }
|
|
69
|
+
* await test()
|
|
70
|
+
* .catch((err) => {
|
|
71
|
+
* console.log(err)
|
|
72
|
+
* })
|
|
73
|
+
*
|
|
74
|
+
*/
|
|
75
|
+
async function runRolePipeline(opt = {}) {
|
|
76
|
+
let t0 = Date.now()
|
|
77
|
+
let providers = get(opt, 'providers', null)
|
|
78
|
+
let input = get(opt, 'input', null)
|
|
79
|
+
let stages = get(opt, 'stages', null)
|
|
80
|
+
let callOpt = get(opt, 'callOpt', {})
|
|
81
|
+
|
|
82
|
+
if (!isearr(stages)) {
|
|
83
|
+
return { ok: false, result: null, stages: {}, results: {}, order: [], failedStage: null, totalMs: 0, error: 'stages must be a non-empty array' }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let results = {} //id → 成果(json或文字)
|
|
87
|
+
let details = {} //id → 完整呼叫結果
|
|
88
|
+
let order = []
|
|
89
|
+
let prev = null
|
|
90
|
+
|
|
91
|
+
for (let i = 0; i < stages.length; i++) {
|
|
92
|
+
let stage = stages[i]
|
|
93
|
+
let id = isestr(get(stage, 'id', '')) ? stage.id : `stage${i + 1}`
|
|
94
|
+
order.push(id)
|
|
95
|
+
|
|
96
|
+
let promptFn = get(stage, 'prompt', null)
|
|
97
|
+
if (!isfun(promptFn)) {
|
|
98
|
+
details[id] = { ok: false, error: `stage[${id}].prompt must be a function` }
|
|
99
|
+
return { ok: false, result: null, stages: details, results, order, failedStage: id, totalMs: Date.now() - t0, error: details[id].error }
|
|
100
|
+
}
|
|
101
|
+
let prompt = promptFn({ input, prev, results, index: i })
|
|
102
|
+
if (!isestr(prompt)) {
|
|
103
|
+
details[id] = { ok: false, error: `stage[${id}].prompt returned empty` }
|
|
104
|
+
return { ok: false, result: null, stages: details, results, order, failedStage: id, totalMs: Date.now() - t0, error: details[id].error }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
//overrides, 剔除階段自用鍵後其餘鍵覆寫該階段呼叫設定(rawText、maxRetries、timeoutMs等)
|
|
108
|
+
let overrides = omit(stage, STAGE_KEYS)
|
|
109
|
+
let r = await callAiWithFallback(prompt, { ...callOpt, ...overrides, providers, spec: { use: get(stage, 'use', ''), fallback: get(stage, 'fallback', null) }, check: get(stage, 'check', null) })
|
|
110
|
+
details[id] = r
|
|
111
|
+
if (!r.ok) {
|
|
112
|
+
//失敗即止: 已完成階段成果保留於results, 供呼叫端接續重跑
|
|
113
|
+
return { ok: false, result: null, stages: details, results, order, failedStage: id, totalMs: Date.now() - t0, error: `stage[${id}] failed: ${r.error}` }
|
|
114
|
+
}
|
|
115
|
+
results[id] = r.json
|
|
116
|
+
prev = r.json
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { ok: true, result: prev, stages: details, results, order, failedStage: null, totalMs: Date.now() - t0, error: '' }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
export default runRolePipeline
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import http from 'http'
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
// fakeServerForApiTest.mjs — 測試用的OpenAI相容假伺服器
|
|
5
|
+
//
|
|
6
|
+
// dispatchApiOpenaiCompat之可觀察行為為「送出什麼HTTP請求、如何處理各種回應」,
|
|
7
|
+
// 故起一個本機http伺服器, 依請求之model與Authorization決定回應行為,
|
|
8
|
+
// 即可對成功/401/429/500/逾時/畸形回應逐條斷言, 而無須真的呼叫外部API。
|
|
9
|
+
//
|
|
10
|
+
// 【行為路由(依body.model)】
|
|
11
|
+
// echo — 200, content為JSON字串{ auth, body }, 供斷言請求組成
|
|
12
|
+
// empty-content — 200, content為空字串(驗證路徑用)
|
|
13
|
+
// slow — 延遲10秒才回應(逾時路徑用)
|
|
14
|
+
// err-500 — 500
|
|
15
|
+
// flaky-429 — 同一Authorization首次429, 之後200(重試路徑用)
|
|
16
|
+
// no-choices — 200但無choices(畸形回應路徑用)
|
|
17
|
+
// not-json — 200但本體非JSON(畸形回應路徑用)
|
|
18
|
+
// 其他 — 404
|
|
19
|
+
// 【金鑰規則】Authorization含'sk-bad'一律401(優先於model路由), 模擬無效金鑰。
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 啟動測試用OpenAI相容假伺服器
|
|
24
|
+
*
|
|
25
|
+
* @returns {Promise} 回傳Promise,resolve回傳物件,內含port(埠號)、url(基底網址字串,等同baseURL)、close(關閉伺服器之async函數)
|
|
26
|
+
*/
|
|
27
|
+
async function fakeServerForApiTest() {
|
|
28
|
+
|
|
29
|
+
//flakyCount, 記錄flaky-429各金鑰之呼叫次數
|
|
30
|
+
let flakyCount = {}
|
|
31
|
+
|
|
32
|
+
//sockets, 追蹤連線供close時強制斷開(避免keep-alive令close懸置)
|
|
33
|
+
let sockets = new Set()
|
|
34
|
+
|
|
35
|
+
let server = http.createServer((req, res) => {
|
|
36
|
+
|
|
37
|
+
//僅受理POST /v1/chat/completions
|
|
38
|
+
if (req.method !== 'POST' || !req.url.endsWith('/chat/completions')) {
|
|
39
|
+
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
40
|
+
res.end(JSON.stringify({ error: { message: 'not found' } }))
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let chunks = []
|
|
45
|
+
req.on('data', (c) => chunks.push(c))
|
|
46
|
+
req.on('end', () => {
|
|
47
|
+
|
|
48
|
+
let auth = req.headers['authorization'] || ''
|
|
49
|
+
|
|
50
|
+
//body非JSON → 400
|
|
51
|
+
let body = null
|
|
52
|
+
try {
|
|
53
|
+
body = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
57
|
+
res.end(JSON.stringify({ error: { message: 'invalid json body' } }))
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
//無效金鑰, 模擬Zen之401形態
|
|
62
|
+
if (auth.includes('sk-bad')) {
|
|
63
|
+
res.writeHead(401, { 'Content-Type': 'application/json' })
|
|
64
|
+
res.end(JSON.stringify({ type: 'error', error: { type: 'AuthError', message: 'Invalid API key.' } }))
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let model = body.model || ''
|
|
69
|
+
let ok = (content) => {
|
|
70
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
71
|
+
res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content } }] }))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (model === 'echo') {
|
|
75
|
+
ok(JSON.stringify({ auth, body }))
|
|
76
|
+
}
|
|
77
|
+
else if (model === 'empty-content') {
|
|
78
|
+
ok('')
|
|
79
|
+
}
|
|
80
|
+
else if (model === 'slow') {
|
|
81
|
+
setTimeout(() => ok('too late'), 10000)
|
|
82
|
+
}
|
|
83
|
+
else if (model === 'err-500') {
|
|
84
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
85
|
+
res.end(JSON.stringify({ error: { message: 'internal error' } }))
|
|
86
|
+
}
|
|
87
|
+
else if (model === 'flaky-429') {
|
|
88
|
+
flakyCount[auth] = (flakyCount[auth] || 0) + 1
|
|
89
|
+
if (flakyCount[auth] === 1) {
|
|
90
|
+
res.writeHead(429, { 'Content-Type': 'application/json' })
|
|
91
|
+
res.end(JSON.stringify({ error: { message: 'rate limited' } }))
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
ok(JSON.stringify({ attempt: flakyCount[auth] }))
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
else if (model === 'no-choices') {
|
|
98
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
99
|
+
res.end(JSON.stringify({ id: 'x', object: 'chat.completion' }))
|
|
100
|
+
}
|
|
101
|
+
else if (model === 'not-json') {
|
|
102
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' })
|
|
103
|
+
res.end('plain text body')
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
107
|
+
res.end(JSON.stringify({ error: { message: `model ${model} not found` } }))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
server.on('connection', (s) => {
|
|
114
|
+
sockets.add(s)
|
|
115
|
+
s.on('close', () => sockets.delete(s))
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
//listen於127.0.0.1動態埠
|
|
119
|
+
await new Promise((resolve) => {
|
|
120
|
+
server.listen(0, '127.0.0.1', resolve)
|
|
121
|
+
})
|
|
122
|
+
let port = server.address().port
|
|
123
|
+
|
|
124
|
+
let close = async () => {
|
|
125
|
+
for (let s of sockets) {
|
|
126
|
+
s.destroy()
|
|
127
|
+
}
|
|
128
|
+
await new Promise((resolve) => {
|
|
129
|
+
server.close(resolve)
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { port, url: `http://127.0.0.1:${port}/v1`, close }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
export default fakeServerForApiTest
|
|
@@ -4,10 +4,12 @@ import map from 'lodash-es/map.js'
|
|
|
4
4
|
import wi from '../src/WDispatchAi.mjs'
|
|
5
5
|
import dispatchAi from '../src/dispatchAi.mjs'
|
|
6
6
|
import dispatchAiFallback from '../src/dispatchAiFallback.mjs'
|
|
7
|
+
import dispatchAiWkf from '../src/dispatchAiWkf.mjs'
|
|
7
8
|
import dispatchOpencode from '../src/dispatchOpencode.mjs'
|
|
8
9
|
import dispatchClaude from '../src/dispatchClaude.mjs'
|
|
9
10
|
import dispatchCodex from '../src/dispatchCodex.mjs'
|
|
10
11
|
import dispatchAntigravity from '../src/dispatchAntigravity.mjs'
|
|
12
|
+
import dispatchApiOpenaiCompat from '../src/dispatchApiOpenaiCompat.mjs'
|
|
11
13
|
|
|
12
14
|
|
|
13
15
|
describe('WDispatchAi', function() {
|
|
@@ -18,23 +20,25 @@ describe('WDispatchAi', function() {
|
|
|
18
20
|
'KINDS',
|
|
19
21
|
'dispatchAi',
|
|
20
22
|
'dispatchAiFallback',
|
|
23
|
+
'dispatchAiWkf',
|
|
21
24
|
'dispatchOpencode',
|
|
22
25
|
'dispatchClaude',
|
|
23
26
|
'dispatchCodex',
|
|
24
27
|
'dispatchAntigravity',
|
|
28
|
+
'dispatchApiOpenaiCompat',
|
|
25
29
|
]
|
|
26
30
|
assert.strict.deepEqual(r, rr)
|
|
27
31
|
})
|
|
28
32
|
|
|
29
33
|
it('KINDS為可用之供應商種類字串陣列', function() {
|
|
30
34
|
let r = wi.KINDS
|
|
31
|
-
let rr = ['opencode', 'claude', 'codex', 'antigravity']
|
|
35
|
+
let rr = ['opencode', 'claude', 'codex', 'antigravity', 'api-openai-compat']
|
|
32
36
|
assert.strict.deepEqual(r, rr)
|
|
33
37
|
})
|
|
34
38
|
|
|
35
39
|
it('除KINDS外各鍵值皆為函數', function() {
|
|
36
40
|
let r = map(keys(wi), (k) => typeof wi[k])
|
|
37
|
-
let rr = ['object', 'function', 'function', 'function', 'function', 'function', 'function']
|
|
41
|
+
let rr = ['object', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'function']
|
|
38
42
|
assert.strict.deepEqual(r, rr)
|
|
39
43
|
})
|
|
40
44
|
|
|
@@ -42,12 +46,14 @@ describe('WDispatchAi', function() {
|
|
|
42
46
|
let r = [
|
|
43
47
|
wi.dispatchAi === dispatchAi,
|
|
44
48
|
wi.dispatchAiFallback === dispatchAiFallback,
|
|
49
|
+
wi.dispatchAiWkf === dispatchAiWkf,
|
|
45
50
|
wi.dispatchOpencode === dispatchOpencode,
|
|
46
51
|
wi.dispatchClaude === dispatchClaude,
|
|
47
52
|
wi.dispatchCodex === dispatchCodex,
|
|
48
53
|
wi.dispatchAntigravity === dispatchAntigravity,
|
|
54
|
+
wi.dispatchApiOpenaiCompat === dispatchApiOpenaiCompat,
|
|
49
55
|
]
|
|
50
|
-
let rr = [true, true, true, true, true, true]
|
|
56
|
+
let rr = [true, true, true, true, true, true, true, true]
|
|
51
57
|
assert.strict.deepEqual(r, rr)
|
|
52
58
|
})
|
|
53
59
|
|
|
@@ -57,20 +63,20 @@ describe('WDispatchAi', function() {
|
|
|
57
63
|
let t = await wi.dispatchAi(kind, '')
|
|
58
64
|
r.push(t.error)
|
|
59
65
|
}
|
|
60
|
-
let rr = ['prompt must be a non-empty string', 'prompt must be a non-empty string', 'prompt must be a non-empty string', 'prompt must be a non-empty string']
|
|
66
|
+
let rr = ['prompt must be a non-empty string', 'prompt must be a non-empty string', 'prompt must be a non-empty string', 'prompt must be a non-empty string', 'prompt must be a non-empty string']
|
|
61
67
|
assert.strict.deepEqual(r, rr)
|
|
62
68
|
})
|
|
63
69
|
|
|
64
70
|
it('可由WDispatchAi呼叫dispatchAi', async function() {
|
|
65
71
|
let t = await wi.dispatchAi('gemini', 'abc')
|
|
66
72
|
let r = [t.ok, t.error]
|
|
67
|
-
let rr = [false, 'unknown ai kind: "gemini" (available: opencode, claude, codex, antigravity)']
|
|
73
|
+
let rr = [false, 'unknown ai kind: "gemini" (available: opencode, claude, codex, antigravity, api-openai-compat)']
|
|
68
74
|
assert.strict.deepEqual(r, rr)
|
|
69
75
|
})
|
|
70
76
|
|
|
71
77
|
it('可由WDispatchAi呼叫各轉接器', async function() {
|
|
72
78
|
let r = []
|
|
73
|
-
for (let fn of [wi.dispatchOpencode, wi.dispatchClaude, wi.dispatchCodex, wi.dispatchAntigravity]) {
|
|
79
|
+
for (let fn of [wi.dispatchOpencode, wi.dispatchClaude, wi.dispatchCodex, wi.dispatchAntigravity, wi.dispatchApiOpenaiCompat]) {
|
|
74
80
|
let t = await fn('')
|
|
75
81
|
r.push([t.ok, t.error])
|
|
76
82
|
}
|
|
@@ -79,6 +85,7 @@ describe('WDispatchAi', function() {
|
|
|
79
85
|
[false, 'prompt must be a non-empty string'],
|
|
80
86
|
[false, 'prompt must be a non-empty string'],
|
|
81
87
|
[false, 'prompt must be a non-empty string'],
|
|
88
|
+
[false, 'prompt must be a non-empty string'],
|
|
82
89
|
]
|
|
83
90
|
assert.strict.deepEqual(r, rr)
|
|
84
91
|
})
|
|
@@ -6,19 +6,20 @@ import dispatchOpencode from '../src/dispatchOpencode.mjs'
|
|
|
6
6
|
import dispatchClaude from '../src/dispatchClaude.mjs'
|
|
7
7
|
import dispatchCodex from '../src/dispatchCodex.mjs'
|
|
8
8
|
import dispatchAntigravity from '../src/dispatchAntigravity.mjs'
|
|
9
|
+
import dispatchApiOpenaiCompat from '../src/dispatchApiOpenaiCompat.mjs'
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
describe('adapters', function() {
|
|
12
13
|
|
|
13
14
|
it('對照表鍵名即為可用之供應商種類', function() {
|
|
14
15
|
let r = keys(adapters)
|
|
15
|
-
let rr = ['opencode', 'claude', 'codex', 'antigravity']
|
|
16
|
+
let rr = ['opencode', 'claude', 'codex', 'antigravity', 'api-openai-compat']
|
|
16
17
|
assert.strict.deepEqual(r, rr)
|
|
17
18
|
})
|
|
18
19
|
|
|
19
20
|
it('各鍵值皆為函數', function() {
|
|
20
21
|
let r = map(keys(adapters), (k) => typeof adapters[k])
|
|
21
|
-
let rr = ['function', 'function', 'function', 'function']
|
|
22
|
+
let rr = ['function', 'function', 'function', 'function', 'function']
|
|
22
23
|
assert.strict.deepEqual(r, rr)
|
|
23
24
|
})
|
|
24
25
|
|
|
@@ -28,8 +29,9 @@ describe('adapters', function() {
|
|
|
28
29
|
adapters.claude === dispatchClaude,
|
|
29
30
|
adapters.codex === dispatchCodex,
|
|
30
31
|
adapters.antigravity === dispatchAntigravity,
|
|
32
|
+
adapters['api-openai-compat'] === dispatchApiOpenaiCompat,
|
|
31
33
|
]
|
|
32
|
-
let rr = [true, true, true, true]
|
|
34
|
+
let rr = [true, true, true, true, true]
|
|
33
35
|
assert.strict.deepEqual(r, rr)
|
|
34
36
|
})
|
|
35
37
|
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import callAiWithFallback, { buildChain, NO_SIDE_EFFECT } from '../src/wkf/callAiWithFallback.mjs'
|
|
3
|
+
import createFakeCli from './tools/fakeCliForTest.mjs'
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
describe('callAiWithFallback', function() {
|
|
7
|
+
|
|
8
|
+
let fake = null
|
|
9
|
+
let providers = null
|
|
10
|
+
|
|
11
|
+
before(function() {
|
|
12
|
+
fake = createFakeCli('fake-wkf-call')
|
|
13
|
+
providers = {
|
|
14
|
+
'p-claude': { kind: 'claude', exe: fake.exe, model: 'sonnet' },
|
|
15
|
+
'p-codex': { kind: 'codex', exe: fake.exe, model: 'gpt-5.6-luna' },
|
|
16
|
+
'p-oc': { kind: 'opencode', exe: fake.exe, model: 'opencode/free', provider: 'opencode', keys: ['sk-c0', 'sk-c1'] },
|
|
17
|
+
'p-dead': { kind: 'claude', exe: fake.exe, model: 'sonnet', extraArgs: ['--fake-exit=1'] },
|
|
18
|
+
'p-noexe': { kind: 'claude', exe: 'wkf-no-such-cli' },
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
after(function() {
|
|
23
|
+
if (fake) {
|
|
24
|
+
fake.clean()
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('buildChain以名稱展開條目並回報查無定義之名稱', function() {
|
|
29
|
+
let { chain, missing } = buildChain(providers, { use: 'p-claude', fallback: ['p-codex', 'p-typo'] })
|
|
30
|
+
let r = [chain.map((x) => x.id), missing]
|
|
31
|
+
let rr = [['p-claude', 'p-codex'], ['p-typo']]
|
|
32
|
+
assert.strict.deepEqual(r, rr)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('prompt非有效字串時回傳錯誤結果物件', async function() {
|
|
36
|
+
let t = await callAiWithFallback('', { providers, spec: { use: 'p-claude' } })
|
|
37
|
+
let r = [t.ok, t.json, t.error, t.tried]
|
|
38
|
+
let rr = [false, null, 'prompt must be a non-empty string', []]
|
|
39
|
+
assert.strict.deepEqual(r, rr)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('providers或spec無效時回傳錯誤結果物件', async function() {
|
|
43
|
+
let r = []
|
|
44
|
+
for (let [p, s] of [[null, { use: 'x' }], [providers, null], [providers, 'abc']]) {
|
|
45
|
+
let t = await callAiWithFallback('abc', { providers: p, spec: s })
|
|
46
|
+
r.push([t.ok, t.error.indexOf('no valid provider') === 0])
|
|
47
|
+
}
|
|
48
|
+
let rr = [[false, true], [false, true], [false, true]]
|
|
49
|
+
assert.strict.deepEqual(r, rr)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('名稱查無定義時fail fast回報, 不靜默略過', async function() {
|
|
53
|
+
let t = await callAiWithFallback('abc', { providers, spec: { use: 'p-claude', fallback: ['p-typo1', 'p-codex', 'p-typo2'] } })
|
|
54
|
+
let r = [t.ok, t.error]
|
|
55
|
+
let rr = [false, 'unknown provider name(s): p-typo1, p-typo2']
|
|
56
|
+
assert.strict.deepEqual(r, rr)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('成功時回傳解析後json與名稱式providerId', async function() {
|
|
60
|
+
let t = await callAiWithFallback('abc', { providers, spec: { use: 'p-claude' }, promptPrefix: '' })
|
|
61
|
+
let r = [t.ok, t.providerId, t.keyIndex, t.keyId, t.json.args, t.json.stdin]
|
|
62
|
+
let rr = [true, 'p-claude', null, 'p-claude', ['-p', '--dangerously-skip-permissions', '--model', 'sonnet'], 'abc']
|
|
63
|
+
assert.strict.deepEqual(r, rr)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('預設掛防寫檔前綴, promptPrefix傳空字串可關閉, 自訂前綴可覆寫', async function() {
|
|
67
|
+
let t1 = await callAiWithFallback('abc', { providers, spec: { use: 'p-claude' } })
|
|
68
|
+
let t2 = await callAiWithFallback('abc', { providers, spec: { use: 'p-claude' }, promptPrefix: '' })
|
|
69
|
+
let t3 = await callAiWithFallback('abc', { providers, spec: { use: 'p-claude' }, promptPrefix: 'PREFIX:' })
|
|
70
|
+
let r = [t1.json.stdin, t2.json.stdin, t3.json.stdin]
|
|
71
|
+
let rr = [NO_SIDE_EFFECT + 'abc', 'abc', 'PREFIX:abc']
|
|
72
|
+
assert.strict.deepEqual(r, rr)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('check未過視為該家失敗, 遞補層自動換下一家', async function() {
|
|
76
|
+
//check要求args含opencode之run旗標 → p-claude不合(換家) → p-oc合
|
|
77
|
+
let t = await callAiWithFallback('abc', {
|
|
78
|
+
providers,
|
|
79
|
+
spec: { use: 'p-claude', fallback: ['p-oc'] },
|
|
80
|
+
check: (j) => j.args[0] === 'run',
|
|
81
|
+
promptPrefix: '',
|
|
82
|
+
})
|
|
83
|
+
let r = [t.ok, t.providerId, t.keyIndex, t.keyId, t.tried.map((x) => [x.providerId, x.outcome])]
|
|
84
|
+
let rr = [true, 'p-oc', 0, 'p-oc#0', [['p-claude', 'skip-group'], ['p-oc', 'ok']]]
|
|
85
|
+
assert.strict.deepEqual(r, rr)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('CLI失敗(exit 1)時遞補至下一家', async function() {
|
|
89
|
+
let t = await callAiWithFallback('abc', { providers, spec: { use: 'p-dead', fallback: ['p-codex'] }, promptPrefix: '' })
|
|
90
|
+
let r = [t.ok, t.providerId, t.tried.map((x) => [x.providerId, x.outcome])]
|
|
91
|
+
let rr = [true, 'p-codex', [['p-dead', 'next-key'], ['p-codex', 'ok']]]
|
|
92
|
+
assert.strict.deepEqual(r, rr)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('全數失敗時回傳error與完整tried', async function() {
|
|
96
|
+
let t = await callAiWithFallback('abc', { providers, spec: { use: 'p-dead', fallback: ['p-noexe'] }, promptPrefix: '' })
|
|
97
|
+
let r = [t.ok, t.json, t.error !== '', t.tried.length]
|
|
98
|
+
let rr = [false, null, true, 2]
|
|
99
|
+
assert.strict.deepEqual(r, rr)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('rawText模式不解析JSON, json欄為修剪後文字', async function() {
|
|
103
|
+
let t = await callAiWithFallback('abc', { providers, spec: { use: 'p-claude' }, rawText: true, promptPrefix: '' })
|
|
104
|
+
let r = [t.ok, typeof t.json, JSON.parse(t.json).stdin]
|
|
105
|
+
let rr = [true, 'string', 'abc']
|
|
106
|
+
assert.strict.deepEqual(r, rr)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('rawText模式check收文字', async function() {
|
|
110
|
+
let seen = []
|
|
111
|
+
let t = await callAiWithFallback('abc', {
|
|
112
|
+
providers,
|
|
113
|
+
spec: { use: 'p-claude' },
|
|
114
|
+
rawText: true,
|
|
115
|
+
check: (s) => {
|
|
116
|
+
seen.push(typeof s)
|
|
117
|
+
return s.includes('"stdin"')
|
|
118
|
+
},
|
|
119
|
+
promptPrefix: '',
|
|
120
|
+
})
|
|
121
|
+
let r = [t.ok, seen.includes('string')]
|
|
122
|
+
let rr = [true, true]
|
|
123
|
+
assert.strict.deepEqual(r, rr)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('自訂parse可覆寫預設解析器', async function() {
|
|
127
|
+
let t = await callAiWithFallback('abc', {
|
|
128
|
+
providers,
|
|
129
|
+
spec: { use: 'p-claude' },
|
|
130
|
+
parse: (stdout) => ({ wrapped: JSON.parse(stdout).stdin }),
|
|
131
|
+
promptPrefix: '',
|
|
132
|
+
})
|
|
133
|
+
let r = t.json
|
|
134
|
+
let rr = { wrapped: 'abc' }
|
|
135
|
+
assert.strict.deepEqual(r, rr)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('多金鑰條目經由keys輪替且keyId帶索引', async function() {
|
|
139
|
+
let t = await callAiWithFallback('abc', { providers, spec: { use: 'p-oc' }, promptPrefix: '' })
|
|
140
|
+
let auth = JSON.parse(t.json.env.OPENCODE_AUTH_CONTENT)
|
|
141
|
+
let r = [t.ok, t.providerId, typeof t.keyIndex, t.keyId.indexOf('p-oc#') === 0, ['sk-c0', 'sk-c1'].includes(auth.opencode.key)]
|
|
142
|
+
let rr = [true, 'p-oc', 'number', true, true]
|
|
143
|
+
assert.strict.deepEqual(r, rr)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
})
|
|
@@ -20,7 +20,7 @@ describe('dispatchAi', function() {
|
|
|
20
20
|
it('未知kind時回傳錯誤結果物件且不reject', async function() {
|
|
21
21
|
let t = await dispatchAi('gemini', 'abc')
|
|
22
22
|
let r = [t.ok, t.error, t.attempts]
|
|
23
|
-
let rr = [false, 'unknown ai kind: "gemini" (available: opencode, claude, codex, antigravity)', 0]
|
|
23
|
+
let rr = [false, 'unknown ai kind: "gemini" (available: opencode, claude, codex, antigravity, api-openai-compat)', 0]
|
|
24
24
|
assert.strict.deepEqual(r, rr)
|
|
25
25
|
})
|
|
26
26
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import keys from 'lodash-es/keys.js'
|
|
3
|
+
import dispatchAiWkf from '../src/dispatchAiWkf.mjs'
|
|
4
|
+
import { NO_SIDE_EFFECT } from '../src/wkf/callAiWithFallback.mjs'
|
|
5
|
+
import createFakeCli from './tools/fakeCliForTest.mjs'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
describe('dispatchAiWkf', function() {
|
|
9
|
+
|
|
10
|
+
let fake = null
|
|
11
|
+
let providers = null
|
|
12
|
+
|
|
13
|
+
before(function() {
|
|
14
|
+
fake = createFakeCli('fake-wkf-factory')
|
|
15
|
+
providers = {
|
|
16
|
+
'p-a': { kind: 'claude', exe: fake.exe, model: 'sonnet' },
|
|
17
|
+
'p-b': { kind: 'codex', exe: fake.exe, model: 'gpt-5.6-luna' },
|
|
18
|
+
'p-int': { kind: 'opencode', exe: fake.exe, model: 'opencode/free' },
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
after(function() {
|
|
23
|
+
if (fake) {
|
|
24
|
+
fake.clean()
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('providers無效時throw(設定錯誤於啟動期即失敗)', function() {
|
|
29
|
+
let r = []
|
|
30
|
+
for (let providers of [undefined, null, 'abc', 123, []]) {
|
|
31
|
+
try {
|
|
32
|
+
dispatchAiWkf({ providers })
|
|
33
|
+
r.push('no-throw')
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
r.push(err.message.indexOf('dispatchAiWkf:') === 0)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
let rr = [true, true, true, true, true]
|
|
40
|
+
assert.strict.deepEqual(r, rr)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('回傳綁定版API物件', function() {
|
|
44
|
+
let wkf = dispatchAiWkf({ providers })
|
|
45
|
+
let r = [
|
|
46
|
+
keys(wkf),
|
|
47
|
+
wkf.providers === providers,
|
|
48
|
+
typeof wkf.callAi,
|
|
49
|
+
typeof wkf.runFanout,
|
|
50
|
+
typeof wkf.runRolePipeline,
|
|
51
|
+
typeof wkf.runFanoutPipeline,
|
|
52
|
+
]
|
|
53
|
+
let rr = [
|
|
54
|
+
['providers', 'callAi', 'runFanout', 'runRolePipeline', 'runFanoutPipeline'],
|
|
55
|
+
true,
|
|
56
|
+
'function',
|
|
57
|
+
'function',
|
|
58
|
+
'function',
|
|
59
|
+
'function',
|
|
60
|
+
]
|
|
61
|
+
assert.strict.deepEqual(r, rr)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('callAi綁定providers, 以名稱宣告即可呼叫', async function() {
|
|
65
|
+
let wkf = dispatchAiWkf({ providers, defaults: { promptPrefix: '' } })
|
|
66
|
+
let t = await wkf.callAi('abc', { spec: { use: 'p-a' } })
|
|
67
|
+
let r = [t.ok, t.providerId, t.json.stdin]
|
|
68
|
+
let rr = [true, 'p-a', 'abc']
|
|
69
|
+
assert.strict.deepEqual(r, rr)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('defaults為共用預設且呼叫時可逐項覆寫', async function() {
|
|
73
|
+
let wkf = dispatchAiWkf({ providers, defaults: { promptPrefix: 'DEF:' } })
|
|
74
|
+
let t1 = await wkf.callAi('abc', { spec: { use: 'p-a' } })
|
|
75
|
+
let t2 = await wkf.callAi('abc', { spec: { use: 'p-a' }, promptPrefix: 'OVR:' })
|
|
76
|
+
let r = [t1.json.stdin, t2.json.stdin]
|
|
77
|
+
let rr = ['DEF:abc', 'OVR:abc']
|
|
78
|
+
assert.strict.deepEqual(r, rr)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('未給defaults時預設掛防寫檔前綴', async function() {
|
|
82
|
+
let wkf = dispatchAiWkf({ providers })
|
|
83
|
+
let t = await wkf.callAi('abc', { spec: { use: 'p-a' } })
|
|
84
|
+
let r = t.json.stdin
|
|
85
|
+
let rr = NO_SIDE_EFFECT + 'abc'
|
|
86
|
+
assert.strict.deepEqual(r, rr)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('runFanout綁定providers與defaults(defaults併入callOpt)', async function() {
|
|
90
|
+
let wkf = dispatchAiWkf({ providers, defaults: { promptPrefix: '' } })
|
|
91
|
+
let t = await wkf.runFanout({
|
|
92
|
+
task: 'abc',
|
|
93
|
+
agents: [{ use: 'p-a' }, { use: 'p-b' }],
|
|
94
|
+
integrate: { use: 'p-int' },
|
|
95
|
+
})
|
|
96
|
+
let r = [t.ok, t.integrated, t.result.stdin.includes('【候選 1】')]
|
|
97
|
+
let rr = [true, true, true]
|
|
98
|
+
assert.strict.deepEqual(r, rr)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('runRolePipeline與runFanoutPipeline皆已綁定可用', async function() {
|
|
102
|
+
let wkf = dispatchAiWkf({ providers, defaults: { promptPrefix: '' } })
|
|
103
|
+
let t1 = await wkf.runRolePipeline({
|
|
104
|
+
input: 'x',
|
|
105
|
+
stages: [{ id: 's1', use: 'p-a', prompt: (ctx) => `on ${ctx.input}` }],
|
|
106
|
+
})
|
|
107
|
+
let t2 = await wkf.runFanoutPipeline({
|
|
108
|
+
task: 'abc',
|
|
109
|
+
agents: [{ use: 'p-a' }, { use: 'p-b' }],
|
|
110
|
+
integrate: { use: 'p-int' },
|
|
111
|
+
stages: [{ id: 's1', use: 'p-a', prompt: () => 'y' }],
|
|
112
|
+
})
|
|
113
|
+
let r = [t1.ok, t1.results.s1.stdin, t2.ok, t2.B.order]
|
|
114
|
+
let rr = [true, 'on x', true, ['s1']]
|
|
115
|
+
assert.strict.deepEqual(r, rr)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
})
|