w-dispatch-ai 1.0.3 → 1.0.5
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 +153 -43
- 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 +36 -2
- package/docs/dfTimeoutMs.mjs.html +98 -0
- package/docs/dispatchAi.mjs.html +2 -2
- package/docs/dispatchAiFallback.mjs.html +55 -16
- package/docs/dispatchAiWkf.mjs.html +42 -8
- package/docs/dispatchAntigravity.mjs.html +23 -12
- package/docs/dispatchApiOpenaiCompat.mjs.html +40 -6
- package/docs/dispatchClaude.mjs.html +17 -4
- package/docs/dispatchCodex.mjs.html +16 -3
- package/docs/dispatchOpencode.mjs.html +16 -3
- package/docs/getCliArgs.mjs.html +2 -2
- package/docs/getErrorResult.mjs.html +2 -2
- package/docs/global.html +511 -66
- package/docs/index.html +2 -2
- package/docs/resolveProviders.mjs.html +189 -0
- package/docs/wkf_callAiWithFallback.mjs.html +22 -11
- package/docs/wkf_extractJsonLoose.mjs.html +2 -2
- package/docs/wkf_runFanout.mjs.html +7 -7
- package/docs/wkf_runFanoutPipeline.mjs.html +7 -7
- package/docs/wkf_runRolePipeline.mjs.html +6 -6
- package/g.mjs +30 -23
- package/package.json +1 -1
- package/src/WDispatchAi.mjs +6 -2
- package/src/adapters.mjs +34 -0
- package/src/dfTimeoutMs.mjs +26 -0
- package/src/dispatchAiFallback.mjs +53 -14
- package/src/dispatchAiWkf.mjs +40 -6
- package/src/dispatchAntigravity.mjs +21 -10
- package/src/dispatchApiOpenaiCompat.mjs +38 -4
- package/src/dispatchClaude.mjs +15 -2
- package/src/dispatchCodex.mjs +14 -1
- package/src/dispatchOpencode.mjs +14 -1
- package/src/providers.mjs +133 -0
- package/src/resolveProviders.mjs +117 -0
- package/src/wkf/callAiWithFallback.mjs +20 -9
- 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-WDispatchAi.test.mjs +9 -3
- package/test/unit-dispatchAi.test.mjs +1 -1
- package/test/unit-dispatchAiFallback.test.mjs +25 -0
- package/test/unit-dispatchAntigravity.test.mjs +25 -5
- package/test/unit-dispatchApiOpenaiCompat.test.mjs +31 -0
- package/test/unit-providers.test.mjs +66 -0
- package/test/unit-resolveProviders.test.mjs +85 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// providers.mjs — 預設providers定義檔(實測可用之供應商條目, 開發端可直接全用或自選)
|
|
2
|
+
//
|
|
3
|
+
// 【性質】純資料檔, 不讀環境變數。金鑰以envVar間接引用(機密不落設定檔),
|
|
4
|
+
// 使用前須經resolveProviders展開(envVar → keys, 缺變數者自動停用並回報);
|
|
5
|
+
// 展開後之陣列可直接餵dispatchAiFallback, table可直接餵dispatchAiWkf。
|
|
6
|
+
//
|
|
7
|
+
// 【id命名】依「路徑:模型」規則(詳dispatchAiFallback.mjs檔頭之id設計規則):
|
|
8
|
+
// oc:=經opencode CLI(有工具、較慢) / agy:=antigravity CLI / claude:/codex:=訂閱登入態CLI
|
|
9
|
+
// zen:/agnes:/poolside:=REST直呼(免CLI免登入、快, 純文字生成)
|
|
10
|
+
// 同一模型之CLI版與REST版屬不同供應商(能力與額度池皆不同), 故各為一條。
|
|
11
|
+
//
|
|
12
|
+
// 【使用方式】
|
|
13
|
+
// import providers from 'w-dispatch-ai/src/providers.mjs'
|
|
14
|
+
// import resolveProviders from 'w-dispatch-ai/src/resolveProviders.mjs'
|
|
15
|
+
// process.loadEnvFile('./.env') //OPENCODE_KEYS/AGNES_KEYS/POOLSIDE_KEYS, 逗號分隔多把
|
|
16
|
+
// let { providers: ps, table, skipped } = resolveProviders(providers) //全取
|
|
17
|
+
// let r2 = resolveProviders(providers, { pick: ['agnes:agnes-2.5-flash', 'claude:sonnet'] }) //自選, pick順序即遞補優先序
|
|
18
|
+
//
|
|
19
|
+
// 【timeout規劃】各條目刻意不帶timeoutMs, 由上層依任務型態統一給定、條目僅於特例覆寫:
|
|
20
|
+
// 簡單任務(秒級~分鐘級): 沿用套件統一預設即可(全轉接器一律300000=5分鐘, 見dfTimeoutMs.mjs)。
|
|
21
|
+
// 複雜任務(單一AI工作約15分鐘、fallback須能走到最末):
|
|
22
|
+
// timeoutMs: 1200000 (20min=15min工作+33%餘裕, 單次嘗試上限)
|
|
23
|
+
// minAttemptMs: 1200000 (剩餘預算不足完整視窗即不開工——開了也不可能完成, 純浪費)
|
|
24
|
+
// budgetMs: 鏈長K×timeoutMs (逾時型失敗每組只燒一次timeout即跳組, 故K×20min
|
|
25
|
+
// 保證能走到最末; 例K=4 → 4800000=80min。無外部排程時限則可null=不限)
|
|
26
|
+
// agy之printTimeout由timeoutMs自動推導(−30s), 無須另設。
|
|
27
|
+
// 工作流層總時長: RolePipeline為各階段序列相加(M階段≈M×名額預算);
|
|
28
|
+
// Fanout各名額並行(≈單一名額預算+整合名額預算), 外部排程上限須據此預留。
|
|
29
|
+
// 外部調整四層(細者覆蓋粗者): dispatchAiWkf之defaults(全域) → 各工作流callOpt
|
|
30
|
+
// → 各階段/名額規格 → 各provider條目。
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
let providers = [
|
|
34
|
+
|
|
35
|
+
//cli版
|
|
36
|
+
{
|
|
37
|
+
id: 'oc:opencode/deepseek-v4-flash-free',
|
|
38
|
+
model: 'opencode/deepseek-v4-flash-free',
|
|
39
|
+
kind: 'opencode',
|
|
40
|
+
envVar: 'OPENCODE_KEYS',
|
|
41
|
+
provider: 'opencode',
|
|
42
|
+
config: {
|
|
43
|
+
permission: { edit: 'deny', write: 'deny', bash: 'deny' },
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'oc:agnes-ai/agnes-2.5-flash',
|
|
48
|
+
model: 'agnes-ai/agnes-2.5-flash',
|
|
49
|
+
kind: 'opencode',
|
|
50
|
+
envVar: 'AGNES_KEYS',
|
|
51
|
+
provider: 'agnes-ai',
|
|
52
|
+
config: {
|
|
53
|
+
provider: {
|
|
54
|
+
'agnes-ai': {
|
|
55
|
+
npm: '@ai-sdk/openai-compatible',
|
|
56
|
+
name: 'Agnes',
|
|
57
|
+
options: { baseURL: 'https://apihub.agnes-ai.com/v1' },
|
|
58
|
+
models: { 'agnes-2.5-flash': { name: 'Agnes 2.5 Flash' } },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
permission: { edit: 'deny', write: 'deny', bash: 'deny' },
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 'oc:poolside/poolside/laguna-s-2.1',
|
|
66
|
+
model: 'poolside/poolside/laguna-s-2.1',
|
|
67
|
+
kind: 'opencode',
|
|
68
|
+
envVar: 'POOLSIDE_KEYS',
|
|
69
|
+
provider: 'poolside',
|
|
70
|
+
config: {
|
|
71
|
+
provider: {
|
|
72
|
+
'poolside': {
|
|
73
|
+
npm: '@ai-sdk/openai-compatible',
|
|
74
|
+
name: 'Poolside',
|
|
75
|
+
options: { baseURL: 'https://inference.poolside.ai/v1' },
|
|
76
|
+
models: { 'poolside/laguna-s-2.1': { name: 'Laguna S 2.1' } },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
permission: { edit: 'deny', write: 'deny', bash: 'deny' },
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: 'agy:gemini-3.7-flash-high',
|
|
84
|
+
model: 'gemini-3.7-flash-high',
|
|
85
|
+
kind: 'antigravity',
|
|
86
|
+
addDirs: ['.'],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: 'claude:sonnet',
|
|
90
|
+
model: 'sonnet',
|
|
91
|
+
kind: 'claude',
|
|
92
|
+
extraArgs: ['--disallowedTools', 'Write,Edit,NotebookEdit,Bash'],
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: 'codex:gpt-5.6-luna',
|
|
96
|
+
model: 'gpt-5.6-luna',
|
|
97
|
+
kind: 'codex',
|
|
98
|
+
sandbox: 'read-only',
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
//api版
|
|
102
|
+
{
|
|
103
|
+
id: 'zen:deepseek-v4-flash-free',
|
|
104
|
+
model: 'deepseek-v4-flash-free',
|
|
105
|
+
kind: 'api-openai-compat',
|
|
106
|
+
envVar: 'OPENCODE_KEYS',
|
|
107
|
+
baseURL: 'https://opencode.ai/zen/v1',
|
|
108
|
+
body: { max_tokens: 8192 },
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: 'agnes:agnes-2.5-flash',
|
|
112
|
+
model: 'agnes-2.5-flash',
|
|
113
|
+
kind: 'api-openai-compat',
|
|
114
|
+
envVar: 'AGNES_KEYS',
|
|
115
|
+
baseURL: 'https://apihub.agnes-ai.com/v1',
|
|
116
|
+
body: { max_tokens: 8192 },
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: 'poolside:laguna-s-2.1',
|
|
120
|
+
model: 'poolside/laguna-s-2.1',
|
|
121
|
+
kind: 'api-openai-compat',
|
|
122
|
+
envVar: 'POOLSIDE_KEYS',
|
|
123
|
+
baseURL: 'https://inference.poolside.ai/v1',
|
|
124
|
+
body: {
|
|
125
|
+
max_tokens: 8192,
|
|
126
|
+
chat_template_kwargs: { enable_thinking: false },
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
export default providers
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import get from 'lodash-es/get.js'
|
|
2
|
+
import omit from 'lodash-es/omit.js'
|
|
3
|
+
import isarr from 'wsemi/src/isarr.mjs'
|
|
4
|
+
import isobj from 'wsemi/src/isobj.mjs'
|
|
5
|
+
import isestr from 'wsemi/src/isestr.mjs'
|
|
6
|
+
import isearr from 'wsemi/src/isearr.mjs'
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
// resolveProviders.mjs — 把providers定義檔展開為可直接使用的條目
|
|
10
|
+
//
|
|
11
|
+
// 【為何需要】providers.mjs之條目以envVar間接引用金鑰(機密不落設定檔, 只寫變數名),
|
|
12
|
+
// 而dispatchAiFallback只認keys陣列——本函數負責envVar → keys之展開,
|
|
13
|
+
// 缺對應環境變數者停用該條目並列入skipped回報(不中斷、不throw),
|
|
14
|
+
// 訂閱登入態條目(claude/codex等無envVar)原樣通過。
|
|
15
|
+
//
|
|
16
|
+
// 【自選】opt.pick給id陣列即可只取用部分條目, 且依pick之順序回傳
|
|
17
|
+
// (順序即dispatchAiFallback之優先序); 查無之id列入missing回報。
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 展開providers定義條目:envVar → keys,並可依id自選子集
|
|
22
|
+
*
|
|
23
|
+
* 特點:
|
|
24
|
+
* 條目之envVar依env來源(預設process.env)展開為keys陣列(變數值以逗號分隔多把),envVar欄位自輸出移除;
|
|
25
|
+
* 缺對應環境變數(或值為空)之條目停用並列入skipped,不中斷不throw;
|
|
26
|
+
* 無envVar之條目(訂閱登入態CLI)原樣通過;條目已自帶有效keys者以keys為準;
|
|
27
|
+
* opt.pick可依id自選子集並以pick順序回傳(順序即遞補優先序);
|
|
28
|
+
* 輸入陣列與條目皆不被改動(輸出為淺拷貝)
|
|
29
|
+
*
|
|
30
|
+
* @param {Array} providers 輸入providers條目物件陣列(如providers.mjs之預設匯出)
|
|
31
|
+
* @param {Object} [opt={}] 輸入設定物件,預設{}
|
|
32
|
+
* @param {Object} [opt.env=process.env] 輸入金鑰來源物件(變數名 → 逗號分隔之金鑰字串),預設process.env
|
|
33
|
+
* @param {Array} [opt.pick=null] 輸入自選id字串陣列,依此順序回傳對應條目,查無之id列入missing,預設null代表全取(依原順序)
|
|
34
|
+
* @returns {Object} 回傳物件,內含providers(可直接餵dispatchAiFallback之條目陣列)、table(id對條目之物件,可直接餵dispatchAiWkf)、skipped(缺環境變數而停用之{id,envVar}陣列)、missing(pick查無之id字串陣列)
|
|
35
|
+
* @example
|
|
36
|
+
*
|
|
37
|
+
* import resolveProviders from './src/resolveProviders.mjs'
|
|
38
|
+
* import providersAll from './src/providers.mjs'
|
|
39
|
+
*
|
|
40
|
+
* process.loadEnvFile('./.env') //金鑰放.env, 變數值以逗號分隔多把
|
|
41
|
+
*
|
|
42
|
+
* //全取: envVar展開為keys, 缺環境變數者列入skipped
|
|
43
|
+
* let { providers, table, skipped } = resolveProviders(providersAll)
|
|
44
|
+
* console.log(providers.length, skipped)
|
|
45
|
+
* // => 10 []
|
|
46
|
+
*
|
|
47
|
+
* //自選: 依pick順序回傳(順序即遞補優先序), 可直接餵dispatchAiFallback
|
|
48
|
+
* let r2 = resolveProviders(providersAll, { pick: ['agnes:agnes-2.5-flash', 'claude:sonnet'] })
|
|
49
|
+
* console.log(r2.providers.map((p) => p.id))
|
|
50
|
+
* // => [ 'agnes:agnes-2.5-flash', 'claude:sonnet' ]
|
|
51
|
+
*
|
|
52
|
+
* //table可直接餵dispatchAiWkf之providers定義表
|
|
53
|
+
* //let wkf = dispatchAiWkf({ providers: r2.table, defaults: { timeoutMs: 1200000 } })
|
|
54
|
+
*
|
|
55
|
+
*/
|
|
56
|
+
function resolveProviders(providers, opt = {}) {
|
|
57
|
+
|
|
58
|
+
//env, 無效回退process.env(瀏覽器環境無process則空物件)
|
|
59
|
+
let env = get(opt, 'env', null)
|
|
60
|
+
if (!isobj(env)) {
|
|
61
|
+
env = (typeof process !== 'undefined' && process && isobj(process.env)) ? process.env : {}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
//providersRaw
|
|
65
|
+
let providersRaw = isarr(providers) ? providers.filter(isobj) : []
|
|
66
|
+
|
|
67
|
+
//pick, 依id自選並依pick順序回傳
|
|
68
|
+
let pick = get(opt, 'pick', null)
|
|
69
|
+
let missing = []
|
|
70
|
+
let selected = providersRaw
|
|
71
|
+
if (isearr(pick)) {
|
|
72
|
+
selected = []
|
|
73
|
+
for (let id of pick) {
|
|
74
|
+
let entry = providersRaw.find((p) => get(p, 'id', null) === id)
|
|
75
|
+
if (entry) {
|
|
76
|
+
selected.push(entry)
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
missing.push(String(id))
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
//展開envVar → keys
|
|
85
|
+
let out = []
|
|
86
|
+
let skipped = []
|
|
87
|
+
for (let entry of selected) {
|
|
88
|
+
let envVar = get(entry, 'envVar', null)
|
|
89
|
+
|
|
90
|
+
//無envVar(訂閱登入態CLI)或已自帶有效keys, 原樣通過(僅移除envVar欄位)
|
|
91
|
+
if (!isestr(envVar) || isearr(get(entry, 'keys', null))) {
|
|
92
|
+
out.push(omit(entry, ['envVar']))
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
//envVar → keys, 變數值以逗號分隔多把
|
|
97
|
+
let keys = String(get(env, envVar, '') || '').split(',').map((k) => k.trim()).filter(Boolean)
|
|
98
|
+
if (keys.length === 0) {
|
|
99
|
+
|
|
100
|
+
//缺環境變數: 停用該條目並回報, 不中斷(設計providers原則§四.4)
|
|
101
|
+
skipped.push({ id: get(entry, 'id', ''), envVar })
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
out.push({ ...omit(entry, ['envVar']), keys })
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
//table, id → 條目, 可直接作dispatchAiWkf之providers定義表
|
|
108
|
+
let table = {}
|
|
109
|
+
for (let entry of out) {
|
|
110
|
+
table[entry.id] = entry
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { providers: out, table, skipped, missing }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
export default resolveProviders
|
|
@@ -4,6 +4,7 @@ import isestr from 'wsemi/src/isestr.mjs'
|
|
|
4
4
|
import isfun from 'wsemi/src/isfun.mjs'
|
|
5
5
|
import isobj from 'wsemi/src/isobj.mjs'
|
|
6
6
|
import dispatchAiFallback from '../dispatchAiFallback.mjs'
|
|
7
|
+
import dfTimeoutMs from '../dfTimeoutMs.mjs'
|
|
7
8
|
import extractJsonLoose from './extractJsonLoose.mjs'
|
|
8
9
|
|
|
9
10
|
|
|
@@ -23,13 +24,22 @@ import extractJsonLoose from './extractJsonLoose.mjs'
|
|
|
23
24
|
// 【防寫檔前綴】agentic CLI對cwd隔離免疫(會自行解析專案根目錄寫檔),
|
|
24
25
|
// 故預設在prompt前掛「禁止建檔」約束(實測有效); 不需要時傳promptPrefix:''關閉。
|
|
25
26
|
// 殷鑑: 2026-08-10執行任務歷史.md遭AI覆寫、評比腳本繞過前綴又產生根目錄孤兒檔。
|
|
27
|
+
//
|
|
28
|
+
// 措辭須豁免「唯讀查閱」(2026-08-13 A/B實測): 各CLI取得檔案內容的途徑不同——
|
|
29
|
+
// opencode與claude有獨立的read/grep工具, 不受「禁止執行指令」約束;
|
|
30
|
+
// 但codex讀檔即是執行shell指令, 舊措辭「禁止執行任何指令」對它等同「禁止讀檔」,
|
|
31
|
+
// 凡需讀專案的任務會得到一句「請貼上檔案內容」而非成果, 且該拒答是合法字串,
|
|
32
|
+
// 會通過驗證被遞補層當成「成功」, 整條fallback鏈就此停住——兜底地位被靜默廢掉。
|
|
33
|
+
// 實測: 舊措辭下codex回「無法讀取該檔案」, 改為下列措辭後正常讀檔作答(11.1s),
|
|
34
|
+
// 防副作用(建檔/改檔/刪檔/改動系統狀態)之本旨不變。
|
|
26
35
|
|
|
27
36
|
|
|
28
|
-
//預設防寫檔前綴
|
|
37
|
+
//預設防寫檔前綴(禁副作用, 但豁免唯讀查閱——codex以shell讀檔, 一律禁指令等同禁讀檔)
|
|
29
38
|
let NO_SIDE_EFFECT = [
|
|
30
39
|
'【執行約束】你只需把結果輸出在回覆內容中。',
|
|
31
|
-
'
|
|
32
|
-
'
|
|
40
|
+
'禁止建立、修改或刪除任何檔案,也不要執行任何會改動磁碟或系統狀態的指令——',
|
|
41
|
+
'呼叫端只讀取你的回覆文字,任何寫入磁碟的動作都不會被採用,只會製造無人讀取的垃圾檔。',
|
|
42
|
+
'唯讀查閱(讀取檔案、搜尋內容、列出目錄)不在此限,需要時請照常使用。',
|
|
33
43
|
'', '',
|
|
34
44
|
].join('\n')
|
|
35
45
|
|
|
@@ -87,7 +97,7 @@ function buildChain(providers, spec) {
|
|
|
87
97
|
* @param {Function} [opt.parse=extractJsonLoose] 輸入回覆解析函數(stdout)=>Object|null,預設寬鬆JSON抽取
|
|
88
98
|
* @param {Boolean} [opt.rawText=false] 輸入是否以純文字模式運作布林值,true代表不解析JSON(json欄位為修剪後文字、check收文字),預設false
|
|
89
99
|
* @param {String} [opt.promptPrefix=防寫檔約束] 輸入prompt前綴字串,預設為防寫檔約束,傳''關閉
|
|
90
|
-
* @param {Number} [opt.timeoutMs=300000]
|
|
100
|
+
* @param {Number} [opt.timeoutMs=300000] 輸入單次嘗試逾時毫秒正整數,全套件統一預設300000
|
|
91
101
|
* @param {Number} [opt.budgetMs=null] 輸入整條遞補鏈之時間預算毫秒正整數,預設null代表不限
|
|
92
102
|
* @param {Number} [opt.maxRetries=0] 輸入同家重試次數非負整數,預設0(韌性交給遞補;端點不穩偶發空回之模型可調高令同鍵重試)
|
|
93
103
|
* @param {String} [opt.cwd=process.cwd()] 輸入子進程工作目錄字串,預設process.cwd()
|
|
@@ -99,20 +109,21 @@ function buildChain(providers, spec) {
|
|
|
99
109
|
*
|
|
100
110
|
* import callAiWithFallback from './src/wkf/callAiWithFallback.mjs'
|
|
101
111
|
*
|
|
112
|
+
* //鍵名須區分到模型並帶上路徑, 詳見dispatchAiFallback.mjs檔頭之id設計規則
|
|
102
113
|
* let providers = {
|
|
103
|
-
* 'deepseek': { kind: 'opencode', model: '
|
|
104
|
-
* 'sonnet': { kind: 'claude', model: 'sonnet' },
|
|
114
|
+
* 'zen:deepseek-v4-flash-free': { kind: 'api-openai-compat', baseURL: 'https://opencode.ai/zen/v1', model: 'deepseek-v4-flash-free', keys: ['sk-xxx'] },
|
|
115
|
+
* 'claude:sonnet': { kind: 'claude', model: 'sonnet' },
|
|
105
116
|
* }
|
|
106
117
|
*
|
|
107
118
|
* let test = async () => {
|
|
108
119
|
*
|
|
109
120
|
* let r = await callAiWithFallback('只回覆JSON: {"a":1}', {
|
|
110
121
|
* providers,
|
|
111
|
-
* spec: { use: 'deepseek', fallback: ['sonnet'] },
|
|
122
|
+
* spec: { use: 'zen:deepseek-v4-flash-free', fallback: ['claude:sonnet'] },
|
|
112
123
|
* check: (j) => j.a === 1,
|
|
113
124
|
* })
|
|
114
125
|
* console.log(r.ok, r.json, r.providerId)
|
|
115
|
-
* // => true { a: 1 } 'deepseek'
|
|
126
|
+
* // => true { a: 1 } 'zen:deepseek-v4-flash-free'
|
|
116
127
|
*
|
|
117
128
|
* }
|
|
118
129
|
* await test()
|
|
@@ -177,7 +188,7 @@ async function callAiWithFallback(prompt, opt = {}) {
|
|
|
177
188
|
let r = await dispatchAiFallback(promptPrefix + prompt, {
|
|
178
189
|
providers: chain,
|
|
179
190
|
validate,
|
|
180
|
-
timeoutMs: get(opt, 'timeoutMs', null) || 300000
|
|
191
|
+
timeoutMs: get(opt, 'timeoutMs', null) || dfTimeoutMs, //全套件統一預設300000
|
|
181
192
|
budgetMs: get(opt, 'budgetMs', null) || undefined,
|
|
182
193
|
maxRetries: get(opt, 'maxRetries', null) || 0,
|
|
183
194
|
cwd: get(opt, 'cwd', null) || process.cwd(),
|
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' }))
|
|
@@ -10,6 +10,8 @@ import dispatchClaude from '../src/dispatchClaude.mjs'
|
|
|
10
10
|
import dispatchCodex from '../src/dispatchCodex.mjs'
|
|
11
11
|
import dispatchAntigravity from '../src/dispatchAntigravity.mjs'
|
|
12
12
|
import dispatchApiOpenaiCompat from '../src/dispatchApiOpenaiCompat.mjs'
|
|
13
|
+
import providers from '../src/providers.mjs'
|
|
14
|
+
import resolveProviders from '../src/resolveProviders.mjs'
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
describe('WDispatchAi', function() {
|
|
@@ -26,6 +28,8 @@ describe('WDispatchAi', function() {
|
|
|
26
28
|
'dispatchCodex',
|
|
27
29
|
'dispatchAntigravity',
|
|
28
30
|
'dispatchApiOpenaiCompat',
|
|
31
|
+
'providers',
|
|
32
|
+
'resolveProviders',
|
|
29
33
|
]
|
|
30
34
|
assert.strict.deepEqual(r, rr)
|
|
31
35
|
})
|
|
@@ -36,9 +40,9 @@ describe('WDispatchAi', function() {
|
|
|
36
40
|
assert.strict.deepEqual(r, rr)
|
|
37
41
|
})
|
|
38
42
|
|
|
39
|
-
it('
|
|
43
|
+
it('各鍵值型別正確(KINDS與providers為物件, 其餘為函數)', function() {
|
|
40
44
|
let r = map(keys(wi), (k) => typeof wi[k])
|
|
41
|
-
let rr = ['object', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'function']
|
|
45
|
+
let rr = ['object', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'function', 'object', 'function']
|
|
42
46
|
assert.strict.deepEqual(r, rr)
|
|
43
47
|
})
|
|
44
48
|
|
|
@@ -52,8 +56,10 @@ describe('WDispatchAi', function() {
|
|
|
52
56
|
wi.dispatchCodex === dispatchCodex,
|
|
53
57
|
wi.dispatchAntigravity === dispatchAntigravity,
|
|
54
58
|
wi.dispatchApiOpenaiCompat === dispatchApiOpenaiCompat,
|
|
59
|
+
wi.providers === providers,
|
|
60
|
+
wi.resolveProviders === resolveProviders,
|
|
55
61
|
]
|
|
56
|
-
let rr = [true, true, true, true, true, true, true, true]
|
|
62
|
+
let rr = [true, true, true, true, true, true, true, true, true, true]
|
|
57
63
|
assert.strict.deepEqual(r, rr)
|
|
58
64
|
})
|
|
59
65
|
|
|
@@ -70,7 +70,7 @@ describe('dispatchAi', function() {
|
|
|
70
70
|
[true, ['run', '--agent', 'build', '-m', 'mdl']],
|
|
71
71
|
[true, ['-p', '--dangerously-skip-permissions', '--model', 'mdl']],
|
|
72
72
|
[true, ['exec', '--sandbox', 'workspace-write', '--skip-git-repo-check', '-m', 'mdl']],
|
|
73
|
-
[true, ['--dangerously-skip-permissions', '--print-timeout', '270s', '--model', 'mdl', '--print', 'abc']],
|
|
73
|
+
[true, ['--dangerously-skip-permissions', '--print-timeout', '270s', '--model', 'mdl', '--add-dir', process.cwd(), '--print', 'abc']],
|
|
74
74
|
]
|
|
75
75
|
assert.strict.deepEqual(r, rr)
|
|
76
76
|
})
|
|
@@ -316,6 +316,31 @@ describe('dispatchAiFallback', function() {
|
|
|
316
316
|
assert.strict.deepEqual(r, rr)
|
|
317
317
|
})
|
|
318
318
|
|
|
319
|
+
it('失敗事件與tried帶被拒回覆(stdout)與錯誤輸出(stderr)供診斷', async function() {
|
|
320
|
+
let evs = []
|
|
321
|
+
let t = await dispatchAiFallback('abc', {
|
|
322
|
+
providers: [
|
|
323
|
+
//驗證失敗(skip-group): 被拒的回覆內容應可於事件與tried取得
|
|
324
|
+
{ id: 'g-diag-v', kind: 'claude', exe: fake.exe, validate: 'min:100000' },
|
|
325
|
+
//exit 1(next-key): stderr之錯誤訊息應可於事件與tried取得
|
|
326
|
+
{ id: 'g-diag-e', kind: 'claude', exe: fake.exe, extraArgs: ['--fake-exit=1', '--fake-stderr=Invalid API key.'] },
|
|
327
|
+
{ id: 'g-diag-ok', kind: 'claude', exe: fake.exe },
|
|
328
|
+
],
|
|
329
|
+
onEvent: (ev) => evs.push(ev),
|
|
330
|
+
})
|
|
331
|
+
let evV = evs.find((x) => x.type === 'skip-group')
|
|
332
|
+
let evE = evs.find((x) => x.type === 'next-key')
|
|
333
|
+
let r = [
|
|
334
|
+
t.ok,
|
|
335
|
+
evV.stdout.includes('"stdin"'), //被拒回覆即假CLI之echo JSON
|
|
336
|
+
t.tried[0].stdout.includes('"stdin"'),
|
|
337
|
+
evE.stderr,
|
|
338
|
+
t.tried[1].stderr,
|
|
339
|
+
]
|
|
340
|
+
let rr = [true, true, true, 'Invalid API key.', 'Invalid API key.']
|
|
341
|
+
assert.strict.deepEqual(r, rr)
|
|
342
|
+
})
|
|
343
|
+
|
|
319
344
|
it('onEvent依序回報事件, 回調拋出例外不影響主流程', async function() {
|
|
320
345
|
let evs = []
|
|
321
346
|
let t = await dispatchAiFallback('abc', {
|
|
@@ -62,7 +62,7 @@ describe('dispatchAntigravity', function() {
|
|
|
62
62
|
let t = await dispatchAntigravity('abc', { exe: fake.exe })
|
|
63
63
|
let o = JSON.parse(t.stdout)
|
|
64
64
|
let r = [t.ok, o.args]
|
|
65
|
-
let rr = [true, ['--dangerously-skip-permissions', '--print-timeout', '270s', '--print', 'abc']]
|
|
65
|
+
let rr = [true, ['--dangerously-skip-permissions', '--print-timeout', '270s', '--add-dir', process.cwd(), '--print', 'abc']]
|
|
66
66
|
assert.strict.deepEqual(r, rr)
|
|
67
67
|
})
|
|
68
68
|
|
|
@@ -78,7 +78,7 @@ describe('dispatchAntigravity', function() {
|
|
|
78
78
|
let t = await dispatchAntigravity('abc', { exe: fake.exe, model: 'gemini-3.6-flash-low' })
|
|
79
79
|
let o = JSON.parse(t.stdout)
|
|
80
80
|
let r = o.args
|
|
81
|
-
let rr = ['--dangerously-skip-permissions', '--print-timeout', '270s', '--model', 'gemini-3.6-flash-low', '--print', 'abc']
|
|
81
|
+
let rr = ['--dangerously-skip-permissions', '--print-timeout', '270s', '--model', 'gemini-3.6-flash-low', '--add-dir', process.cwd(), '--print', 'abc']
|
|
82
82
|
assert.strict.deepEqual(r, rr)
|
|
83
83
|
})
|
|
84
84
|
|
|
@@ -86,7 +86,7 @@ describe('dispatchAntigravity', function() {
|
|
|
86
86
|
let t = await dispatchAntigravity('abc', { exe: fake.exe, model: 'gemini-3.1-pro', effort: 'low' })
|
|
87
87
|
let o = JSON.parse(t.stdout)
|
|
88
88
|
let r = o.args
|
|
89
|
-
let rr = ['--dangerously-skip-permissions', '--print-timeout', '270s', '--model', 'gemini-3.1-pro', '--effort', 'low', '--print', 'abc']
|
|
89
|
+
let rr = ['--dangerously-skip-permissions', '--print-timeout', '270s', '--model', 'gemini-3.1-pro', '--effort', 'low', '--add-dir', process.cwd(), '--print', 'abc']
|
|
90
90
|
assert.strict.deepEqual(r, rr)
|
|
91
91
|
})
|
|
92
92
|
|
|
@@ -94,7 +94,7 @@ describe('dispatchAntigravity', function() {
|
|
|
94
94
|
let t = await dispatchAntigravity('abc', { exe: fake.exe, skipPermissions: false })
|
|
95
95
|
let o = JSON.parse(t.stdout)
|
|
96
96
|
let r = [t.ok, o.args]
|
|
97
|
-
let rr = [true, ['--print-timeout', '270s', '--print', 'abc']]
|
|
97
|
+
let rr = [true, ['--print-timeout', '270s', '--add-dir', process.cwd(), '--print', 'abc']]
|
|
98
98
|
assert.strict.deepEqual(r, rr)
|
|
99
99
|
})
|
|
100
100
|
|
|
@@ -123,6 +123,26 @@ describe('dispatchAntigravity', function() {
|
|
|
123
123
|
assert.strict.deepEqual(r, rr)
|
|
124
124
|
})
|
|
125
125
|
|
|
126
|
+
it('未給addDirs時自動納入有效cwd(明給的cwd優先)', async function() {
|
|
127
|
+
let t1 = await dispatchAntigravity('abc', { exe: fake.exe }) //cwd未給 → process.cwd()
|
|
128
|
+
let t2 = await dispatchAntigravity('abc', { exe: fake.exe, cwd: fake.fd }) //cwd明給
|
|
129
|
+
let o1 = JSON.parse(t1.stdout)
|
|
130
|
+
let o2 = JSON.parse(t2.stdout)
|
|
131
|
+
let i1 = o1.args.indexOf('--add-dir')
|
|
132
|
+
let i2 = o2.args.indexOf('--add-dir')
|
|
133
|
+
let r = [o1.args[i1 + 1], o2.args[i2 + 1]]
|
|
134
|
+
let rr = [process.cwd(), fake.fd]
|
|
135
|
+
assert.strict.deepEqual(r, rr)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('明示addDirs為空陣列時不自動加入(代表不揭露任何目錄)', async function() {
|
|
139
|
+
let t = await dispatchAntigravity('abc', { exe: fake.exe, addDirs: [] })
|
|
140
|
+
let o = JSON.parse(t.stdout)
|
|
141
|
+
let r = [t.ok, o.args.includes('--add-dir')]
|
|
142
|
+
let rr = [true, false]
|
|
143
|
+
assert.strict.deepEqual(r, rr)
|
|
144
|
+
})
|
|
145
|
+
|
|
126
146
|
it('addDirs逐項展開為--add-dir並濾除非有效字串', async function() {
|
|
127
147
|
let t = await dispatchAntigravity('abc', { exe: fake.exe, addDirs: ['c:/a', null, 'c:/b', 123] })
|
|
128
148
|
let o = JSON.parse(t.stdout)
|
|
@@ -135,7 +155,7 @@ describe('dispatchAntigravity', function() {
|
|
|
135
155
|
let t = await dispatchAntigravity('abc', { exe: fake.exe, extraArgs: ['--output-format', 'json'] })
|
|
136
156
|
let o = JSON.parse(t.stdout)
|
|
137
157
|
let r = o.args
|
|
138
|
-
let rr = ['--dangerously-skip-permissions', '--print-timeout', '270s', '--output-format', 'json', '--print', 'abc']
|
|
158
|
+
let rr = ['--dangerously-skip-permissions', '--print-timeout', '270s', '--add-dir', process.cwd(), '--output-format', 'json', '--print', 'abc']
|
|
139
159
|
assert.strict.deepEqual(r, rr)
|
|
140
160
|
})
|
|
141
161
|
|
|
@@ -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)
|