w-dispatch-ai 1.0.3 → 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 +69 -36
- package/dist/w-dispatch-ai.umd.js +2 -2
- package/dist/w-dispatch-ai.umd.js.map +1 -1
- package/docs/WDispatchAi.mjs.html +1 -1
- package/docs/adapters.mjs.html +35 -1
- package/docs/dispatchAi.mjs.html +1 -1
- package/docs/dispatchAiFallback.mjs.html +41 -7
- package/docs/dispatchAiWkf.mjs.html +32 -7
- package/docs/dispatchAntigravity.mjs.html +1 -1
- package/docs/dispatchApiOpenaiCompat.mjs.html +36 -3
- package/docs/dispatchClaude.mjs.html +1 -1
- package/docs/dispatchCodex.mjs.html +1 -1
- package/docs/dispatchOpencode.mjs.html +1 -1
- package/docs/getCliArgs.mjs.html +1 -1
- package/docs/getErrorResult.mjs.html +1 -1
- package/docs/global.html +59 -40
- package/docs/index.html +1 -1
- package/docs/wkf_callAiWithFallback.mjs.html +6 -5
- package/docs/wkf_extractJsonLoose.mjs.html +1 -1
- package/docs/wkf_runFanout.mjs.html +6 -6
- package/docs/wkf_runFanoutPipeline.mjs.html +6 -6
- package/docs/wkf_runRolePipeline.mjs.html +5 -5
- package/g.mjs +30 -23
- package/package.json +1 -1
- package/src/adapters.mjs +34 -0
- package/src/dispatchAiFallback.mjs +40 -6
- package/src/dispatchAiWkf.mjs +31 -6
- package/src/dispatchApiOpenaiCompat.mjs +35 -2
- package/src/wkf/callAiWithFallback.mjs +5 -4
- package/src/wkf/runFanout.mjs +5 -5
- package/src/wkf/runFanoutPipeline.mjs +5 -5
- package/src/wkf/runRolePipeline.mjs +4 -4
- package/test/tools/fakeServerForApiTest.mjs +14 -0
- package/test/unit-dispatchApiOpenaiCompat.test.mjs +31 -0
|
@@ -95,8 +95,8 @@ let STAGE_KEYS = ['id', 'use', 'fallback', 'prompt', 'check']
|
|
|
95
95
|
* import runRolePipeline from './src/wkf/runRolePipeline.mjs'
|
|
96
96
|
*
|
|
97
97
|
* let providers = {
|
|
98
|
-
* 'sonnet': { kind: 'claude', model: 'sonnet' },
|
|
99
|
-
* 'luna': { kind: 'codex', model: 'gpt-5.6-luna' },
|
|
98
|
+
* 'claude:sonnet': { kind: 'claude', model: 'sonnet' },
|
|
99
|
+
* 'codex:gpt-5.6-luna': { kind: 'codex', model: 'gpt-5.6-luna' },
|
|
100
100
|
* }
|
|
101
101
|
*
|
|
102
102
|
* let test = async () => {
|
|
@@ -105,8 +105,8 @@ let STAGE_KEYS = ['id', 'use', 'fallback', 'prompt', 'check']
|
|
|
105
105
|
* providers,
|
|
106
106
|
* input: '原始任務',
|
|
107
107
|
* stages: [
|
|
108
|
-
* { id: 'draft', use: 'sonnet', prompt: (ctx) => `就「${ctx.input}」寫初稿, 只回覆JSON: {"text":"..."}` },
|
|
109
|
-
* { id: 'review', use: 'luna', prompt: (ctx) => `審閱並修訂, 只回覆同格式JSON: ${JSON.stringify(ctx.prev)}` },
|
|
108
|
+
* { id: 'draft', use: 'claude:sonnet', prompt: (ctx) => `就「${ctx.input}」寫初稿, 只回覆JSON: {"text":"..."}` },
|
|
109
|
+
* { id: 'review', use: 'codex:gpt-5.6-luna', prompt: (ctx) => `審閱並修訂, 只回覆同格式JSON: ${JSON.stringify(ctx.prev)}` },
|
|
110
110
|
* ],
|
|
111
111
|
* })
|
|
112
112
|
* console.log(r.ok, r.order, r.failedStage)
|
|
@@ -182,7 +182,7 @@ export default runRolePipeline
|
|
|
182
182
|
<br class="clear">
|
|
183
183
|
|
|
184
184
|
<footer>
|
|
185
|
-
Documentation generated by <a href="https://github.com/jsdoc3/jsdoc">JSDoc 4.0.5</a> on
|
|
185
|
+
Documentation generated by <a href="https://github.com/jsdoc3/jsdoc">JSDoc 4.0.5</a> on Thu Aug 13 2026 21:57:54 GMT+0800 (台北標準時間) using the <a href="https://github.com/clenemt/docdash">docdash</a> theme.
|
|
186
186
|
</footer>
|
|
187
187
|
|
|
188
188
|
<script>prettyPrint();</script>
|
package/g.mjs
CHANGED
|
@@ -91,29 +91,36 @@ let test = async () => {
|
|
|
91
91
|
console.log('invalid prompt:', r5.ok, r5.error)
|
|
92
92
|
// => invalid prompt: false prompt must be a non-empty string
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
94
|
+
//執行失敗時, 由ok、code、error與stderr判斷原因
|
|
95
|
+
//REST路徑之錯誤依HTTP狀態碼分流(401金鑰無效、429限流、5xx服務端), 判別比CLI之stderr字串可靠
|
|
96
|
+
let r6 = await wdi.dispatchApiOpenaiCompat(prompt, {
|
|
97
|
+
baseURL: 'https://apihub.agnes-ai.com/v1',
|
|
98
|
+
model: 'agnes-2.0-flash',
|
|
98
99
|
key: 'sk-invalid-key',
|
|
99
100
|
})
|
|
100
|
-
console.log('invalid key:', r6.ok, r6.code, r6.error, r6.stderr.includes('
|
|
101
|
-
// => invalid key: false
|
|
101
|
+
console.log('invalid key:', r6.ok, r6.code, r6.error, r6.stderr.includes('无效的令牌'))
|
|
102
|
+
// => invalid key: false 401 HTTP 401 true
|
|
102
103
|
|
|
103
104
|
//多供應商自動遞補: providers順序即優先序, 組內keys以游標輪替
|
|
104
|
-
//此例第1把金鑰無效 → 自動換組內下一把成功;
|
|
105
|
+
//此例第1把金鑰無效 → 自動換組內下一把成功; 若整組用盡會遞補下一組, 依序往下
|
|
106
|
+
//
|
|
107
|
+
//【id命名】id為游標鍵與日誌標籤, 須區分到「模型」而非只到「廠商」——
|
|
108
|
+
// 取'claude'則日後無法同時掛sonnet與opus, 且日誌看不出實際用了哪個模型;
|
|
109
|
+
// 同一模型經不同路徑(REST/CLI/不同閘道)取得時額度池與故障域各自獨立,
|
|
110
|
+
// 屬不同供應商, 故id須帶上路徑前綴加以區分
|
|
105
111
|
let r7 = await wdi.dispatchAiFallback(prompt, {
|
|
106
112
|
providers: [
|
|
113
|
+
//REST版排前面: 免CLI、快3~5倍, 純文字任務優先走此路
|
|
107
114
|
{
|
|
108
|
-
id: '
|
|
109
|
-
kind: '
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
keys: ['sk-invalid-key-demo',
|
|
113
|
-
timeoutMs: 180000,
|
|
115
|
+
id: 'api:agnes-2.0-flash',
|
|
116
|
+
kind: 'api-openai-compat',
|
|
117
|
+
baseURL: 'https://apihub.agnes-ai.com/v1',
|
|
118
|
+
model: 'agnes-2.0-flash',
|
|
119
|
+
keys: ['sk-invalid-key-demo', agnesKeys[0]], //第1把無效, 示範組內輪替
|
|
114
120
|
},
|
|
121
|
+
//同一個agnes模型之CLI版: 有工具能力但較慢, 額度池亦不同, 屬另一個供應商
|
|
115
122
|
{
|
|
116
|
-
id: 'agnes',
|
|
123
|
+
id: 'oc:agnes-ai/agnes-2.0-flash',
|
|
117
124
|
kind: 'opencode',
|
|
118
125
|
model: 'agnes-ai/agnes-2.0-flash',
|
|
119
126
|
provider: 'agnes-ai',
|
|
@@ -121,21 +128,21 @@ let test = async () => {
|
|
|
121
128
|
config: configAgnes, //第三方provider須另給定義
|
|
122
129
|
timeoutMs: 180000,
|
|
123
130
|
},
|
|
124
|
-
{ id: 'claude', kind: 'claude', model: 'sonnet' },
|
|
125
|
-
{ id: 'codex', kind: 'codex', model: 'gpt-5.6-luna', sandbox: 'read-only' },
|
|
126
|
-
{ id: '
|
|
131
|
+
{ id: 'claude:sonnet', kind: 'claude', model: 'sonnet' },
|
|
132
|
+
{ id: 'codex:gpt-5.6-luna', kind: 'codex', model: 'gpt-5.6-luna', sandbox: 'read-only' },
|
|
133
|
+
{ id: 'agy:gemini-3.6-flash-low', kind: 'antigravity', model: 'gemini-3.6-flash-low' },
|
|
127
134
|
],
|
|
128
135
|
budgetMs: 600000,
|
|
129
136
|
onEvent: (ev) => console.log(' event:', ev.type, ev.keyId, ev.error || ''),
|
|
130
137
|
})
|
|
131
138
|
console.log('fallback:', r7.ok, r7.providerId, r7.keyIndex, r7.stdout.trim())
|
|
132
139
|
console.log('tried:', r7.tried.map((x) => `${x.keyId}:${x.outcome}`).join(', '))
|
|
133
|
-
// => event: try
|
|
134
|
-
// => event: next-key
|
|
135
|
-
// => event: try
|
|
136
|
-
// => event: ok
|
|
137
|
-
// => fallback: true
|
|
138
|
-
// => tried:
|
|
140
|
+
// => event: try api:agnes-2.0-flash#0
|
|
141
|
+
// => event: next-key api:agnes-2.0-flash#0 HTTP 401
|
|
142
|
+
// => event: try api:agnes-2.0-flash#1
|
|
143
|
+
// => event: ok api:agnes-2.0-flash#1
|
|
144
|
+
// => fallback: true api:agnes-2.0-flash 1 完成
|
|
145
|
+
// => tried: api:agnes-2.0-flash#0:next-key, api:agnes-2.0-flash#1:ok
|
|
139
146
|
|
|
140
147
|
}
|
|
141
148
|
await test()
|
package/package.json
CHANGED
package/src/adapters.mjs
CHANGED
|
@@ -5,6 +5,40 @@ import dispatchAntigravity from './dispatchAntigravity.mjs'
|
|
|
5
5
|
import dispatchApiOpenaiCompat from './dispatchApiOpenaiCompat.mjs'
|
|
6
6
|
|
|
7
7
|
|
|
8
|
+
// adapters.mjs — kind對照表, 亦為「CLI或API」之選型判準所在
|
|
9
|
+
//
|
|
10
|
+
// ══ 選 kind 的唯一判準: 這次呼叫需不需要「工具」? ══
|
|
11
|
+
//
|
|
12
|
+
// 需要工具 → 用CLI類kind(opencode/claude/codex/antigravity)
|
|
13
|
+
// 所謂工具即: 讀取本機檔案、grep搜尋、執行shell指令、抓取網頁、寫檔。
|
|
14
|
+
// CLI本身是agentic harness, 自帶完整工具迴圈, 呼叫端什麼都不必做。
|
|
15
|
+
//
|
|
16
|
+
// 純文字生成 → 用API類kind(api-openai-compat)
|
|
17
|
+
// 所謂純文字即: 摘要、分析、改寫、翻譯、產出JSON——所有素材都已在prompt內,
|
|
18
|
+
// 模型只需讀prompt再輸出文字, 全程不需要碰外部世界。
|
|
19
|
+
// 免安裝CLI、免預先登入, 且實測比CLI快(agnes: API 1~2.5s vs CLI 4~6s)。
|
|
20
|
+
//
|
|
21
|
+
// ══ 為何API類不自建工具迴圈(2026-08-11實測後之決策, 勿再自行推翻) ══
|
|
22
|
+
//
|
|
23
|
+
// 1. 閘道端零內建工具: 實測Zen與Agnes皆然, 連web_search都沒有——
|
|
24
|
+
// 對`tools:[{type:'web_search'}]`回400並要求function.parameters,
|
|
25
|
+
// 即端點只接受「呼叫端自行定義且自行執行」的function工具。
|
|
26
|
+
// 2. 協定雖支援function calling(Zen之nemotron與Agnes皆實測回tool_calls),
|
|
27
|
+
// 但工具的定義、執行、錯誤處理、安全邊界全部得由本套件實作與維護,
|
|
28
|
+
// 等同重造CLI已經提供的harness; 工作流日後仍會持續擴充,
|
|
29
|
+
// 自建工具集之維護成本只會擴大, 故一律不走此路。
|
|
30
|
+
// 3. tool_calls有會話束縛(tool_call_id須於同一條messages串回填),
|
|
31
|
+
// 無法跨行程外傳給上層agent(如hermes)代為執行——
|
|
32
|
+
// 工作流是被上層阻塞呼叫的函式, 沒有反向請求工具的通道。
|
|
33
|
+
// 詳見dispatchApiOpenaiCompat.mjs檔頭。
|
|
34
|
+
//
|
|
35
|
+
// ══ 混用才是常態 ══
|
|
36
|
+
//
|
|
37
|
+
// 同一條dispatchAiFallback鏈可逐條目混搭kind, 工作流各階段亦然:
|
|
38
|
+
// 產生候選、整合收斂等純文字階段用API(快且省), 需要讀專案檔案或grep的
|
|
39
|
+
// 階段換CLI。判準永遠是「這一步需不需要碰外部世界」, 而非整條鏈二選一。
|
|
40
|
+
|
|
41
|
+
|
|
8
42
|
/**
|
|
9
43
|
* 各AI供應商種類(kind)對CLI轉接器函數之對照表
|
|
10
44
|
*
|
|
@@ -28,6 +28,32 @@ import getErrorResult from './getErrorResult.mjs'
|
|
|
28
28
|
//
|
|
29
29
|
// 【時間預算】budgetMs限制整輪遞補的總時長, 剩餘預算會壓進每次呼叫的timeoutMs,
|
|
30
30
|
// 防止多家連續卡逾時而撞破外部排程的執行上限。
|
|
31
|
+
//
|
|
32
|
+
// ══ 條目id之設計規則(呼叫端負責, 本套件不解讀其內容) ══
|
|
33
|
+
//
|
|
34
|
+
// id於本套件內只有兩個用途: 游標的物件鍵(state.cursors[id])與日誌標籤
|
|
35
|
+
// (providerId、keyId=`${id}#${keyIndex}`)。不查表、不比對、無格式要求,
|
|
36
|
+
// 純粹是呼叫端的命名空間——故「什麼算同一個供應商」由呼叫端定義, 本套件不猜。
|
|
37
|
+
//
|
|
38
|
+
// ① id須能區分到「模型」而非只到「廠商」
|
|
39
|
+
// ✗ id:'claude' —— 日後要同時掛sonnet與opus就無法並存, 且日誌看不出用了哪個模型
|
|
40
|
+
// ✓ id:'claude:sonnet' / id:'claude:opus'
|
|
41
|
+
//
|
|
42
|
+
// ② 同一模型經不同路徑取得時, id須帶上路徑
|
|
43
|
+
// 同一個laguna可經Poolside官方REST、OpenRouter、opencode CLI三條路,
|
|
44
|
+
// 三者額度池與故障域各自獨立, 屬三個供應商:
|
|
45
|
+
// ✓ 'poolside:laguna-s-2.1' / 'or:poolside/laguna-s-2.1:free' / 'oc:poolside/poolside/laguna-s-2.1'
|
|
46
|
+
//
|
|
47
|
+
// ③ id務必給且務必唯一
|
|
48
|
+
// 未給時本套件回退為「陣列索引字串」——索引是位置不是身分, 日後於鏈中插入條目
|
|
49
|
+
// 會讓後續條目繼承他人的游標進度(輪替張冠李戴), 故正式設定一律明給。
|
|
50
|
+
// 兩個條目同id則共用同一游標且日誌無法區分, 屬設定錯誤。
|
|
51
|
+
//
|
|
52
|
+
// ④ 同一組金鑰用於多個條目時, 各條目游標獨立
|
|
53
|
+
// 例如agnes的CLI版與REST版共用同一批金鑰時, 兩者各自從游標起點輪替,
|
|
54
|
+
// 同一把金鑰可能被連續使用而另一把閒置(帳號額度未均攤)。
|
|
55
|
+
// 要讓它們共享輪替進度就給相同id(代價: 日誌無法區分兩者);
|
|
56
|
+
// 要能區分就分開命名(代價: 額度不均攤)。此取捨由呼叫端依實際需求決定。
|
|
31
57
|
|
|
32
58
|
|
|
33
59
|
//fallback層自用之設定鍵, 其餘鍵作為各attempt之共用預設原樣轉傳
|
|
@@ -106,7 +132,7 @@ function isKeyIndependentFail(r) {
|
|
|
106
132
|
* @param {String} prompt 輸入提示詞字串,一律以stdin傳入子進程
|
|
107
133
|
* @param {Object} [opt={}] 輸入設定物件,預設{}
|
|
108
134
|
* @param {Array} opt.providers 輸入供應商條目物件陣列,順序即優先序。各條目除下列鍵外,其餘鍵(kind、model、exe、provider、config、sandbox、timeoutMs等)即該條目之opt原樣透傳對應轉接器
|
|
109
|
-
* @param {String} [opt.providers[].id=條目索引字串]
|
|
135
|
+
* @param {String} [opt.providers[].id=條目索引字串] 輸入群組識別字串,游標以此為鍵、亦為日誌標籤,本套件不解讀其內容。須區分到「模型」而非只到「廠商」(如'claude:sonnet'而非'claude'),同一模型經不同路徑取得時須帶上路徑(如'poolside:laguna-s-2.1'與'or:poolside/laguna-s-2.1:free'),且務必唯一。省略時回退為陣列索引字串——索引是位置不是身分,日後插入條目會令後續條目繼承他人游標進度,故正式設定一律明給。詳見本檔檔頭之id設計規則
|
|
110
136
|
* @param {Array} [opt.providers[].keys=[]] 輸入同一服務之多把API key字串陣列,逐次注入輪替(kind為opencode時須同時於條目給予provider),省略代表沿用CLI既有登入狀態之單一虛擬金鑰
|
|
111
137
|
* @param {Number} [opt.budgetMs=null] 輸入整輪遞補之時間上限毫秒正整數,剩餘預算會壓進每次呼叫之timeoutMs,預設null代表不限
|
|
112
138
|
* @param {Number} [opt.minAttemptMs=20000] 輸入單次嘗試之最低剩餘預算毫秒正整數,剩餘低於此值即停止嘗試回報budget exhausted,預設20000
|
|
@@ -126,21 +152,29 @@ function isKeyIndependentFail(r) {
|
|
|
126
152
|
* let r = await dispatchAiFallback('請只回覆兩個字:完成', {
|
|
127
153
|
* providers: [
|
|
128
154
|
* {
|
|
129
|
-
* id
|
|
155
|
+
* //id區分到模型且帶路徑: 同一模型經REST與CLI取得屬兩個供應商
|
|
156
|
+
* id: 'zen:deepseek-v4-flash-free',
|
|
157
|
+
* kind: 'api-openai-compat',
|
|
158
|
+
* baseURL: 'https://opencode.ai/zen/v1',
|
|
159
|
+
* model: 'deepseek-v4-flash-free',
|
|
160
|
+
* keys: ['sk-aaa', 'sk-bbb'], //多把金鑰, 某把失敗自動換下一把
|
|
161
|
+
* },
|
|
162
|
+
* {
|
|
163
|
+
* id: 'oc:opencode/deepseek-v4-flash-free', //同一模型之CLI版(有工具, 較慢)
|
|
130
164
|
* kind: 'opencode',
|
|
131
165
|
* model: 'opencode/deepseek-v4-flash-free',
|
|
132
166
|
* provider: 'opencode',
|
|
133
|
-
* keys: ['sk-aaa', 'sk-bbb'],
|
|
167
|
+
* keys: ['sk-aaa', 'sk-bbb'],
|
|
134
168
|
* timeoutMs: 180000,
|
|
135
169
|
* },
|
|
136
|
-
* { id: 'claude', kind: 'claude', model: 'sonnet' },
|
|
137
|
-
* { id: 'codex', kind: 'codex', model: 'gpt-5.6-luna', sandbox: 'read-only' },
|
|
170
|
+
* { id: 'claude:sonnet', kind: 'claude', model: 'sonnet' }, //以上全敗時遞補
|
|
171
|
+
* { id: 'codex:gpt-5.6-luna', kind: 'codex', model: 'gpt-5.6-luna', sandbox: 'read-only' },
|
|
138
172
|
* ],
|
|
139
173
|
* budgetMs: 600000,
|
|
140
174
|
* onEvent: (ev) => console.log(ev.type, ev.providerId, ev.keyIndex),
|
|
141
175
|
* })
|
|
142
176
|
* console.log(r.ok, r.providerId, r.keyIndex, r.tried.length)
|
|
143
|
-
* // => true 'deepseek' 0 1
|
|
177
|
+
* // => true 'zen:deepseek-v4-flash-free' 0 1
|
|
144
178
|
*
|
|
145
179
|
* }
|
|
146
180
|
* await test()
|
package/src/dispatchAiWkf.mjs
CHANGED
|
@@ -17,6 +17,23 @@ import runFanoutPipeline from './wkf/runFanoutPipeline.mjs'
|
|
|
17
17
|
//
|
|
18
18
|
// 【本函數為同步工廠會throw】providers無效屬設定錯誤, 應於啟動期即失敗(fail fast),
|
|
19
19
|
// 與各dispatch函數「不reject」之約定不衝突——後者是執行期呼叫, 前者是組裝期設定。
|
|
20
|
+
//
|
|
21
|
+
// 【定義表之鍵名即條目id】該鍵名會成為dispatchAiFallback之條目id(游標鍵與日誌標籤),
|
|
22
|
+
// 須區分到「模型」而非只到「廠商」——鍵名取'claude'則日後無法同時掛sonnet與opus,
|
|
23
|
+
// 且日誌看不出實際用了哪個模型; 同一模型經不同路徑(REST/CLI/不同閘道)取得時,
|
|
24
|
+
// 額度池與故障域各自獨立而屬不同供應商, 鍵名須帶上路徑加以區分。
|
|
25
|
+
// 命名規則與取捨詳見dispatchAiFallback.mjs檔頭之「條目id之設計規則」。
|
|
26
|
+
//
|
|
27
|
+
// 【定義providers時如何選kind: CLI或API】判準是「該階段需不需要工具」:
|
|
28
|
+
// 需要讀本機檔案、grep、執行指令、抓網頁 → CLI類kind(opencode/claude/codex/antigravity);
|
|
29
|
+
// 純文字生成(素材皆已在prompt內) → API類kind(api-openai-compat), 免安裝免登入且較快。
|
|
30
|
+
// 工作流常態是混用: runFanout之候選生成與整合、runRolePipeline之審計修訂等
|
|
31
|
+
// 多屬純文字階段可走API; 唯獨需要實際翻閱專案檔案的階段必須走CLI。
|
|
32
|
+
// API類不支援工具且不會自建工具迴圈, 理由詳見adapters.mjs檔頭之選型判準區塊。
|
|
33
|
+
//
|
|
34
|
+
// 【工具無法向上層轉送】工作流各名額(如runFanout之agents)只是同行程之async函數呼叫,
|
|
35
|
+
// 非獨立agent; 模型回傳之tool_calls受會話束縛而無法外傳給上層agent(如hermes)代跑,
|
|
36
|
+
// 故「讓外殼提供工具給工作流內的模型使用」在本架構下不成立——需要工具就選CLI類kind。
|
|
20
37
|
|
|
21
38
|
|
|
22
39
|
/**
|
|
@@ -36,11 +53,15 @@ import runFanoutPipeline from './wkf/runFanoutPipeline.mjs'
|
|
|
36
53
|
*
|
|
37
54
|
* import dispatchAiWkf from './src/dispatchAiWkf.mjs'
|
|
38
55
|
*
|
|
56
|
+
* //定義表之鍵名即dispatchAiFallback之條目id, 須區分到模型而非只到廠商,
|
|
57
|
+
* //同一模型經不同路徑取得時須帶上路徑(REST與CLI屬兩個供應商, 能力與速度皆不同)
|
|
39
58
|
* let wkf = dispatchAiWkf({
|
|
40
59
|
* providers: {
|
|
41
|
-
* 'deepseek': { kind: 'opencode', model: '
|
|
42
|
-
* '
|
|
43
|
-
* '
|
|
60
|
+
* 'zen:deepseek-v4-flash-free': { kind: 'api-openai-compat', baseURL: 'https://opencode.ai/zen/v1', model: 'deepseek-v4-flash-free', keys: ['sk-xxx'] },
|
|
61
|
+
* 'oc:opencode/deepseek-v4-flash-free': { kind: 'opencode', model: 'opencode/deepseek-v4-flash-free', provider: 'opencode', keys: ['sk-xxx'] },
|
|
62
|
+
* 'claude:sonnet': { kind: 'claude', model: 'sonnet' },
|
|
63
|
+
* 'claude:opus': { kind: 'claude', model: 'opus' },
|
|
64
|
+
* 'codex:gpt-5.6-luna': { kind: 'codex', model: 'gpt-5.6-luna' },
|
|
44
65
|
* },
|
|
45
66
|
* defaults: { timeoutMs: 300000 },
|
|
46
67
|
* })
|
|
@@ -48,15 +69,19 @@ import runFanoutPipeline from './wkf/runFanoutPipeline.mjs'
|
|
|
48
69
|
* let test = async () => {
|
|
49
70
|
*
|
|
50
71
|
* //單一名額: 主模型+遞補鏈
|
|
51
|
-
* let r1 = await wkf.callAi('只回覆JSON: {"a":1}', { spec: { use: 'deepseek', fallback: ['sonnet'] }, check: (j) => j.a === 1 })
|
|
72
|
+
* let r1 = await wkf.callAi('只回覆JSON: {"a":1}', { spec: { use: 'zen:deepseek-v4-flash-free', fallback: ['claude:sonnet'] }, check: (j) => j.a === 1 })
|
|
52
73
|
* console.log(r1.ok, r1.json)
|
|
53
74
|
* // => true { a: 1 }
|
|
54
75
|
*
|
|
55
76
|
* //Fanout工作流: 多開執行+單點整合
|
|
77
|
+
* //純文字階段用REST(快), 需要讀專案檔案之階段才用CLI(有工具)
|
|
56
78
|
* let r2 = await wkf.runFanout({
|
|
57
79
|
* task: '分析並只回覆JSON: {"essence":"..."}',
|
|
58
|
-
* agents: [
|
|
59
|
-
*
|
|
80
|
+
* agents: [
|
|
81
|
+
* { use: 'zen:deepseek-v4-flash-free', fallback: ['claude:sonnet'] },
|
|
82
|
+
* { use: 'claude:sonnet' },
|
|
83
|
+
* ],
|
|
84
|
+
* integrate: { use: 'codex:gpt-5.6-luna' },
|
|
60
85
|
* check: (j) => !!j.essence,
|
|
61
86
|
* })
|
|
62
87
|
* console.log(r2.ok, r2.integrated)
|
|
@@ -27,6 +27,19 @@ import getErrorResult from './getErrorResult.mjs'
|
|
|
27
27
|
// 能力(不讀檔不跑指令), 天然無寫檔風險。
|
|
28
28
|
// 注意: claude與codex走訂閱帳號登入態而非API金鑰, 無法比照, 仍須CLI轉接器。
|
|
29
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
|
+
//
|
|
30
43
|
// 【重試語意對齊execCli】4xx(429除外)為客戶端錯誤不可重試而立即中止;
|
|
31
44
|
// 429/5xx/網路錯誤/逾時依maxRetries線性退避重試(間隔retryDelayMs*次數, 上限15000ms)。
|
|
32
45
|
//
|
|
@@ -184,13 +197,31 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
|
|
|
184
197
|
}
|
|
185
198
|
}
|
|
186
199
|
|
|
187
|
-
//取出choices[0]
|
|
200
|
+
//取出choices[0]
|
|
188
201
|
let content = null
|
|
202
|
+
let finishReason = ''
|
|
203
|
+
let toolCalls = null
|
|
189
204
|
try {
|
|
190
205
|
let j = JSON.parse(txt)
|
|
191
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)
|
|
192
209
|
}
|
|
193
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
|
+
|
|
194
225
|
if (content === null || content === undefined) {
|
|
195
226
|
return {
|
|
196
227
|
ok: false,
|
|
@@ -236,6 +267,8 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
|
|
|
236
267
|
*
|
|
237
268
|
* 特點:
|
|
238
269
|
* 免安裝CLI、免預先登入,給baseURL+key+model即可呼叫(如OpenCode Zen、Agnes等OpenAI相容閘道);
|
|
270
|
+
* 僅供純文字生成(摘要、分析、改寫、產出JSON等素材已在prompt內之任務)——
|
|
271
|
+
* 需要讀本機檔案、grep、執行指令、抓網頁等工具能力時,請改用CLI類kind(opencode/claude/codex/antigravity);
|
|
239
272
|
* prompt走HTTP body,無命令列長度限制;
|
|
240
273
|
* 錯誤依HTTP狀態碼分流:4xx(429除外)為客戶端錯誤不重試,429/5xx/網路錯誤/逾時依maxRetries線性退避重試;
|
|
241
274
|
* 結果結構與逾時/驗證失敗之error字樣對齊execCli,可直接作為dispatchAi與dispatchAiFallback之kind('api-openai-compat')使用;
|
|
@@ -247,7 +280,7 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
|
|
|
247
280
|
* @param {String} opt.model 輸入模型ID字串,例如'deepseek-v4-flash-free'(Zen之模型名不帶opencode/前綴)、'agnes-2.0-flash'
|
|
248
281
|
* @param {String} [opt.key=''] 輸入API key字串,以Bearer置於Authorization標頭,預設''代表不帶認證標頭
|
|
249
282
|
* @param {String} [opt.system=''] 輸入system提示詞字串,將以system角色置於messages首位,預設''代表不帶
|
|
250
|
-
* @param {Object} [opt.body={}] 輸入額外請求本體物件(如temperature、max_tokens、response_format),將併入預設body(同名鍵以此為準),預設{}
|
|
283
|
+
* @param {Object} [opt.body={}] 輸入額外請求本體物件(如temperature、max_tokens、response_format),將併入預設body(同名鍵以此為準),預設{}。注意本轉接器不支援工具,帶入tools而模型回tool_calls時一律以TOOL_CALLS_UNSUPPORTED回報失敗,需要工具請改用CLI類kind
|
|
251
284
|
* @param {Object} [opt.headers={}] 輸入額外請求標頭物件,預設{}
|
|
252
285
|
* @param {Number} [opt.timeoutMs=120000] 輸入逾時毫秒正整數,逾時將中止請求(含回應串流讀取),預設120000
|
|
253
286
|
* @param {String|Function} [opt.validate=undefined] 輸入回覆內容驗證規則字串或自訂驗證函數,規則字串支援'nonempty'、'json'、'min:100',多規則可用逗號串接,預設undefined代表不驗證
|
|
@@ -99,20 +99,21 @@ function buildChain(providers, spec) {
|
|
|
99
99
|
*
|
|
100
100
|
* import callAiWithFallback from './src/wkf/callAiWithFallback.mjs'
|
|
101
101
|
*
|
|
102
|
+
* //鍵名須區分到模型並帶上路徑, 詳見dispatchAiFallback.mjs檔頭之id設計規則
|
|
102
103
|
* let providers = {
|
|
103
|
-
* 'deepseek': { kind: 'opencode', model: '
|
|
104
|
-
* 'sonnet': { kind: 'claude', model: 'sonnet' },
|
|
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' },
|
|
105
106
|
* }
|
|
106
107
|
*
|
|
107
108
|
* let test = async () => {
|
|
108
109
|
*
|
|
109
110
|
* let r = await callAiWithFallback('只回覆JSON: {"a":1}', {
|
|
110
111
|
* providers,
|
|
111
|
-
* spec: { use: 'deepseek', fallback: ['sonnet'] },
|
|
112
|
+
* spec: { use: 'zen:deepseek-v4-flash-free', fallback: ['claude:sonnet'] },
|
|
112
113
|
* check: (j) => j.a === 1,
|
|
113
114
|
* })
|
|
114
115
|
* console.log(r.ok, r.json, r.providerId)
|
|
115
|
-
* // => true { a: 1 } 'deepseek'
|
|
116
|
+
* // => true { a: 1 } 'zen:deepseek-v4-flash-free'
|
|
116
117
|
*
|
|
117
118
|
* }
|
|
118
119
|
* await test()
|
package/src/wkf/runFanout.mjs
CHANGED
|
@@ -63,8 +63,8 @@ ${candidates.map((c, i) => `【候選 ${i + 1}】\n${JSON.stringify(c)}`).join('
|
|
|
63
63
|
* import runFanout from './src/wkf/runFanout.mjs'
|
|
64
64
|
*
|
|
65
65
|
* let providers = {
|
|
66
|
-
* 'deepseek': { kind: 'opencode', model: '
|
|
67
|
-
* 'sonnet': { kind: 'claude', model: 'sonnet' },
|
|
66
|
+
* 'zen:deepseek-v4-flash-free': { kind: 'api-openai-compat', baseURL: 'https://opencode.ai/zen/v1', model: 'deepseek-v4-flash-free', keys: ['sk-xxx'] },
|
|
67
|
+
* 'claude:sonnet': { kind: 'claude', model: 'sonnet' },
|
|
68
68
|
* }
|
|
69
69
|
*
|
|
70
70
|
* let test = async () => {
|
|
@@ -73,10 +73,10 @@ ${candidates.map((c, i) => `【候選 ${i + 1}】\n${JSON.stringify(c)}`).join('
|
|
|
73
73
|
* providers,
|
|
74
74
|
* task: '分析並只回覆JSON: {"essence":"..."}',
|
|
75
75
|
* agents: [
|
|
76
|
-
* { use: 'deepseek', fallback: ['sonnet'] },
|
|
77
|
-
* { use: 'sonnet' },
|
|
76
|
+
* { use: 'zen:deepseek-v4-flash-free', fallback: ['claude:sonnet'] },
|
|
77
|
+
* { use: 'claude:sonnet' },
|
|
78
78
|
* ],
|
|
79
|
-
* integrate: { use: 'sonnet' },
|
|
79
|
+
* integrate: { use: 'claude:sonnet' },
|
|
80
80
|
* check: (j) => !!j.essence,
|
|
81
81
|
* })
|
|
82
82
|
* console.log(r.ok, r.integrated, r.candidates.length)
|
|
@@ -40,8 +40,8 @@ import runRolePipeline from './runRolePipeline.mjs'
|
|
|
40
40
|
* import runFanoutPipeline from './src/wkf/runFanoutPipeline.mjs'
|
|
41
41
|
*
|
|
42
42
|
* let providers = {
|
|
43
|
-
* 'sonnet': { kind: 'claude', model: 'sonnet' },
|
|
44
|
-
* 'luna': { kind: 'codex', model: 'gpt-5.6-luna' },
|
|
43
|
+
* 'claude:sonnet': { kind: 'claude', model: 'sonnet' },
|
|
44
|
+
* 'codex:gpt-5.6-luna': { kind: 'codex', model: 'gpt-5.6-luna' },
|
|
45
45
|
* }
|
|
46
46
|
*
|
|
47
47
|
* let test = async () => {
|
|
@@ -49,10 +49,10 @@ import runRolePipeline from './runRolePipeline.mjs'
|
|
|
49
49
|
* let r = await runFanoutPipeline({
|
|
50
50
|
* providers,
|
|
51
51
|
* task: '分析並只回覆JSON: {"essence":"..."}',
|
|
52
|
-
* agents: [{ use: 'sonnet' }, { use: 'luna' }],
|
|
53
|
-
* integrate: { use: 'sonnet' },
|
|
52
|
+
* agents: [{ use: 'claude:sonnet' }, { use: 'codex:gpt-5.6-luna' }],
|
|
53
|
+
* integrate: { use: 'claude:sonnet' },
|
|
54
54
|
* stages: [
|
|
55
|
-
* { id: 'audit', use: 'luna', prompt: (ctx) => `審計此稿並修訂, 只回覆同格式JSON: ${JSON.stringify(ctx.input)}` },
|
|
55
|
+
* { id: 'audit', use: 'codex:gpt-5.6-luna', prompt: (ctx) => `審計此稿並修訂, 只回覆同格式JSON: ${JSON.stringify(ctx.input)}` },
|
|
56
56
|
* ],
|
|
57
57
|
* check: (j) => !!j.essence,
|
|
58
58
|
* })
|
|
@@ -48,8 +48,8 @@ let STAGE_KEYS = ['id', 'use', 'fallback', 'prompt', 'check']
|
|
|
48
48
|
* import runRolePipeline from './src/wkf/runRolePipeline.mjs'
|
|
49
49
|
*
|
|
50
50
|
* let providers = {
|
|
51
|
-
* 'sonnet': { kind: 'claude', model: 'sonnet' },
|
|
52
|
-
* 'luna': { kind: 'codex', model: 'gpt-5.6-luna' },
|
|
51
|
+
* 'claude:sonnet': { kind: 'claude', model: 'sonnet' },
|
|
52
|
+
* 'codex:gpt-5.6-luna': { kind: 'codex', model: 'gpt-5.6-luna' },
|
|
53
53
|
* }
|
|
54
54
|
*
|
|
55
55
|
* let test = async () => {
|
|
@@ -58,8 +58,8 @@ let STAGE_KEYS = ['id', 'use', 'fallback', 'prompt', 'check']
|
|
|
58
58
|
* providers,
|
|
59
59
|
* input: '原始任務',
|
|
60
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)}` },
|
|
61
|
+
* { id: 'draft', use: 'claude:sonnet', prompt: (ctx) => `就「${ctx.input}」寫初稿, 只回覆JSON: {"text":"..."}` },
|
|
62
|
+
* { id: 'review', use: 'codex:gpt-5.6-luna', prompt: (ctx) => `審閱並修訂, 只回覆同格式JSON: ${JSON.stringify(ctx.prev)}` },
|
|
63
63
|
* ],
|
|
64
64
|
* })
|
|
65
65
|
* console.log(r.ok, r.order, r.failedStage)
|
|
@@ -15,6 +15,7 @@ import http from 'http'
|
|
|
15
15
|
// flaky-429 — 同一Authorization首次429, 之後200(重試路徑用)
|
|
16
16
|
// no-choices — 200但無choices(畸形回應路徑用)
|
|
17
17
|
// not-json — 200但本體非JSON(畸形回應路徑用)
|
|
18
|
+
// tool-calls — 200但finish_reason為tool_calls(工具不支援路徑用)
|
|
18
19
|
// 其他 — 404
|
|
19
20
|
// 【金鑰規則】Authorization含'sk-bad'一律401(優先於model路由), 模擬無效金鑰。
|
|
20
21
|
|
|
@@ -94,6 +95,19 @@ async function fakeServerForApiTest() {
|
|
|
94
95
|
ok(JSON.stringify({ attempt: flakyCount[auth] }))
|
|
95
96
|
}
|
|
96
97
|
}
|
|
98
|
+
else if (model === 'tool-calls') {
|
|
99
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
100
|
+
res.end(JSON.stringify({
|
|
101
|
+
choices: [{
|
|
102
|
+
finish_reason: 'tool_calls',
|
|
103
|
+
message: {
|
|
104
|
+
role: 'assistant',
|
|
105
|
+
content: '\n\n', //Agnes實測形態: 非null而是空白, 不攔截會靜默成功
|
|
106
|
+
tool_calls: [{ id: 'call-1', type: 'function', function: { name: 'get_weather', arguments: '{"city":"台北"}' } }],
|
|
107
|
+
},
|
|
108
|
+
}],
|
|
109
|
+
}))
|
|
110
|
+
}
|
|
97
111
|
else if (model === 'no-choices') {
|
|
98
112
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
99
113
|
res.end(JSON.stringify({ id: 'x', object: 'chat.completion' }))
|
|
@@ -178,6 +178,37 @@ describe('dispatchApiOpenaiCompat', function() {
|
|
|
178
178
|
assert.strict.deepEqual(r, rr)
|
|
179
179
|
})
|
|
180
180
|
|
|
181
|
+
it('模型回tool_calls時明確回報不支援而非靜默成功', async function() {
|
|
182
|
+
let t = await dispatchApiOpenaiCompat('台北天氣如何', {
|
|
183
|
+
baseURL: svr.url,
|
|
184
|
+
key: 'sk-good-1',
|
|
185
|
+
model: 'tool-calls',
|
|
186
|
+
body: { tools: [{ type: 'function', function: { name: 'get_weather', parameters: {} } }] },
|
|
187
|
+
})
|
|
188
|
+
let r = [
|
|
189
|
+
t.ok,
|
|
190
|
+
t.code,
|
|
191
|
+
t.stdout,
|
|
192
|
+
t.error.indexOf('TOOL_CALLS_UNSUPPORTED') === 0,
|
|
193
|
+
t.error.includes('cli kind'),
|
|
194
|
+
t.stderr.includes('get_weather'), //原始回應保留供除錯
|
|
195
|
+
]
|
|
196
|
+
let rr = [false, 200, '', true, true, true]
|
|
197
|
+
assert.strict.deepEqual(r, rr)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('tool_calls為與金鑰無關之失敗, 於fallback鏈中不逐把空耗而遞補下一組', async function() {
|
|
201
|
+
let t = await dispatchAiFallback('abc', {
|
|
202
|
+
providers: [
|
|
203
|
+
{ id: 'g-tool', kind: 'api-openai-compat', baseURL: svr.url, model: 'tool-calls', keys: ['sk-t0', 'sk-t1'] },
|
|
204
|
+
{ id: 'g-text', kind: 'api-openai-compat', baseURL: svr.url, model: 'echo', keys: ['sk-x0'] },
|
|
205
|
+
],
|
|
206
|
+
})
|
|
207
|
+
let r = [t.ok, t.providerId, t.tried.map((x) => [x.keyId, x.outcome])]
|
|
208
|
+
let rr = [true, 'g-text', [['g-tool#0', 'next-key'], ['g-tool#1', 'next-key'], ['g-text#0', 'ok']]]
|
|
209
|
+
assert.strict.deepEqual(r, rr)
|
|
210
|
+
})
|
|
211
|
+
|
|
181
212
|
it('可經dispatchAi以kind api-openai-compat分派', async function() {
|
|
182
213
|
let t = await dispatchAi('api-openai-compat', 'abc', { baseURL: svr.url, key: 'sk-good-1', model: 'echo' })
|
|
183
214
|
let o = JSON.parse(t.stdout)
|