w-dispatch-ai 1.0.21 → 1.0.23

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 (82) hide show
  1. package/README.md +627 -569
  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 +12 -4
  5. package/docs/adapters.mjs.html +5 -3
  6. package/docs/budgetFor.mjs.html +2 -2
  7. package/docs/buildValidator.mjs.html +166 -0
  8. package/docs/castPintOr.mjs.html +2 -2
  9. package/docs/dfTimeoutMs.mjs.html +2 -2
  10. package/docs/dispatchAi.mjs.html +2 -2
  11. package/docs/dispatchAiFallback.mjs.html +2 -2
  12. package/docs/dispatchAiWkf.mjs.html +2 -2
  13. package/docs/dispatchAntigravity.mjs.html +2 -2
  14. package/docs/dispatchApiOpenaiCompat.mjs.html +4 -68
  15. package/docs/dispatchApiOpenaiResponses.mjs.html +498 -0
  16. package/docs/dispatchClaude.mjs.html +2 -2
  17. package/docs/dispatchCodex.mjs.html +2 -2
  18. package/docs/dispatchOpencode.mjs.html +2 -2
  19. package/docs/getCliArgs.mjs.html +2 -2
  20. package/docs/getErrorResult.mjs.html +2 -2
  21. package/docs/getErrorType.mjs.html +5 -3
  22. package/docs/global.html +11790 -3425
  23. package/docs/index.html +2 -2
  24. package/docs/quota_dfQuotaTimeoutMs.mjs.html +99 -0
  25. package/docs/quota_fetchQuotaJson.mjs.html +348 -0
  26. package/docs/quota_fromCodexUsageHttp.mjs.html +335 -0
  27. package/docs/quota_getQuotaAntigravity.mjs.html +463 -0
  28. package/docs/quota_getQuotaClaude.mjs.html +457 -0
  29. package/docs/quota_getQuotaCodex.mjs.html +515 -0
  30. package/docs/quota_readJsonOrNull.mjs.html +113 -0
  31. package/docs/quota_toQuotaLabel.mjs.html +146 -0
  32. package/docs/quota_toQuotaResult.mjs.html +214 -0
  33. package/docs/quota_toQuotaScopedLabel.mjs.html +117 -0
  34. package/docs/quota_toQuotaWindow.mjs.html +239 -0
  35. package/docs/readEnvFile.mjs.html +2 -2
  36. package/docs/resolveProviders.mjs.html +3 -3
  37. package/docs/wkf_callAiWithFallback.mjs.html +2 -2
  38. package/docs/wkf_createFileStore.mjs.html +2 -2
  39. package/docs/wkf_createUsageCounter.mjs.html +2 -2
  40. package/docs/wkf_extractJsonLoose.mjs.html +2 -2
  41. package/docs/wkf_noSideEffectPrefix.mjs.html +2 -2
  42. package/docs/wkf_runFanout.mjs.html +2 -2
  43. package/docs/wkf_runFanoutPipeline.mjs.html +2 -2
  44. package/docs/wkf_runRolePipeline.mjs.html +2 -2
  45. package/docs/wkf_salvageTruncatedArray.mjs.html +2 -2
  46. package/g.mjs +2 -2
  47. package/package.json +2 -2
  48. package/src/WDispatchAi.mjs +10 -2
  49. package/src/adapters.mjs +3 -1
  50. package/src/buildValidator.mjs +94 -0
  51. package/src/dispatchApiOpenaiCompat.mjs +2 -66
  52. package/src/dispatchApiOpenaiResponses.mjs +426 -0
  53. package/src/getErrorType.mjs +3 -1
  54. package/src/providers.mjs +303 -213
  55. package/src/quota/dfQuotaTimeoutMs.mjs +27 -0
  56. package/src/quota/fetchQuotaJson.mjs +276 -0
  57. package/src/quota/fromCodexUsageHttp.mjs +263 -0
  58. package/src/quota/getQuotaAntigravity.mjs +391 -0
  59. package/src/quota/getQuotaClaude.mjs +385 -0
  60. package/src/quota/getQuotaCodex.mjs +443 -0
  61. package/src/quota/readJsonOrNull.mjs +41 -0
  62. package/src/quota/toQuotaLabel.mjs +74 -0
  63. package/src/quota/toQuotaResult.mjs +142 -0
  64. package/src/quota/toQuotaScopedLabel.mjs +45 -0
  65. package/src/quota/toQuotaWindow.mjs +167 -0
  66. package/src/resolveProviders.mjs +1 -1
  67. package/test/tools/fakeCliForTest.mjs +4 -3
  68. package/test/tools/fakeServerForApiTest.mjs +108 -3
  69. package/test/tools/fakeServerForQuotaTest.mjs +171 -0
  70. package/test/unit-WDispatchAi.test.mjs +19 -6
  71. package/test/unit-adapters.test.mjs +5 -3
  72. package/test/unit-dispatchAi.test.mjs +1 -1
  73. package/test/unit-dispatchApiOpenaiResponses.test.mjs +198 -0
  74. package/test/unit-fetchQuotaJson.test.mjs +70 -0
  75. package/test/unit-fromCodexUsageHttp.test.mjs +76 -0
  76. package/test/unit-getQuotaAntigravity.test.mjs +123 -0
  77. package/test/unit-getQuotaClaude.test.mjs +137 -0
  78. package/test/unit-getQuotaCodex.test.mjs +191 -0
  79. package/test/unit-readJsonOrNull.test.mjs +44 -0
  80. package/test/unit-toQuotaLabel.test.mjs +43 -0
  81. package/test/unit-toQuotaResult.test.mjs +53 -0
  82. package/test/unit-toQuotaWindow.test.mjs +73 -0
package/README.md CHANGED
@@ -1,569 +1,627 @@
1
- # w-dispatch-ai
2
- A tool for dispatch ai.
3
-
4
- ![language](https://img.shields.io/badge/language-JavaScript-orange.svg)
5
- [![npm version](http://img.shields.io/npm/v/w-dispatch-ai.svg?style=flat)](https://npmjs.org/package/w-dispatch-ai)
6
- [![license](https://img.shields.io/npm/l/w-dispatch-ai.svg?style=flat)](https://npmjs.org/package/w-dispatch-ai)
7
- [![npm download](https://img.shields.io/npm/dt/w-dispatch-ai.svg)](https://npmjs.org/package/w-dispatch-ai)
8
- [![npm download](https://img.shields.io/npm/dm/w-dispatch-ai.svg)](https://npmjs.org/package/w-dispatch-ai)
9
- [![jsdelivr download](https://img.shields.io/jsdelivr/npm/hm/w-dispatch-ai.svg)](https://www.jsdelivr.com/package/npm/w-dispatch-ai)
10
-
11
- ## Documentation
12
- To view documentation or get support, visit [docs](https://yuda-lyu.github.io/w-dispatch-ai/global.html).
13
-
14
- ## Installation
15
-
16
- ### Using npm(ES6 module):
17
- ```alias
18
- npm i w-dispatch-ai
19
- ```
20
-
21
- Note:
22
- - `dispatchClaude` needs [Claude Code CLI](https://claude.com/claude-code) (`claude`) in system PATH, and uses its existing login state.
23
- - `dispatchCodex` needs [OpenAI Codex CLI](https://github.com/openai/codex) (`codex`) in system PATH, and uses its existing login state.
24
- - `dispatchOpencode` needs [opencode CLI](https://opencode.ai/) (`opencode`) in system PATH. Unlike the other two, it accepts a per-call `key`+`provider`, injected through `OPENCODE_AUTH_CONTENT`, so multiple api keys can be rotated without rewriting `auth.json`.
25
- - `dispatchAntigravity` needs [Google Antigravity CLI](https://antigravity.google/) (`agy`, not `antigravity`) in system PATH, and uses its existing OAuth login state (first login requires an interactive desktop session). Unlike the other three, agy takes the prompt via the `--print` flag instead of stdin, so the prompt is capped at 30000 chars (Windows command line limit); longer prompts return an error result.
26
- - `dispatchApiOpenaiCompat` needs **no cli and no login**: it calls any OpenAI-compatible endpoint directly by fetch. Known-working gateways (verified 2026-08-11): [OpenCode Zen](https://opencode.ai/docs/zen) `https://opencode.ai/zen/v1` (same `sk-...` keys as opencode cli, model names without the `opencode/` prefix, e.g. `deepseek-v4-flash-free`) and Agnes `https://apihub.agnes-ai.com/v1` (model `agnes-2.0-flash`). Note claude/codex use subscription login state, not api keys, so they cannot be called this way.
27
- - Each cli adapter also accepts an `exe` option to pin the executable path, useful when the CLI is not in PATH (e.g. Windows Task Scheduler environments).
28
- - For the other three adapters the prompt is always passed through stdin, never as a positional argument, so a prompt of tens of thousands of characters will not cause `ENAMETOOLONG`.
29
- - All functions never reject. Success or failure is reported by the `ok` and `error` fields of the result object.
30
- - **Security**: `dispatchClaude` passes `--dangerously-skip-permissions` by default, so the non-interactive `-p` mode will not hang on permission prompts. If the prompt embeds untrusted content (e.g. a web page to summarize), instructions inside that content would also run without the permission gate. Pass `skipPermissions: false` to keep the CLI permission gate.
31
-
32
- #### Functions:
33
- | function | description |
34
- | --- | --- |
35
- | `dispatchAi(kind, prompt, opt)` | dispatch to the adapter of `kind`, one of `'opencode'`、`'claude'`、`'codex'`、`'antigravity'`、`'api-openai-compat'` |
36
- | `dispatchAiFallback(prompt, opt)` | call ai with an ordered provider list, auto rotating keys within a group and falling back to the next group |
37
- | `dispatchAiWkf(opt)` | workflow factory: inject a named provider table once, returns bound `callAi`/`runFanout`/`runRolePipeline`/`runFanoutPipeline` |
38
- | `dispatchOpencode(prompt, opt)` | call an ai model by opencode cli, supports per-call api key and provider config |
39
- | `dispatchClaude(prompt, opt)` | call a claude model by claude code cli |
40
- | `dispatchCodex(prompt, opt)` | call a gpt model by openai codex cli |
41
- | `dispatchAntigravity(prompt, opt)` | call an ai model by google antigravity cli (`agy`), a multi-model gateway (gemini, claude, gpt-oss) |
42
- | `dispatchApiOpenaiCompat(prompt, opt)` | call an ai model by direct fetch to any OpenAI-compatible API (`baseURL`+`key`+`model`), no cli and no login required |
43
- | `providers` | curated provider entries verified by real tests (cli and rest paths), pick or use all via `resolveProviders` |
44
- | `resolveProviders(providers, opt)` | expand `envVar` `keys` from env (comma-separated, missing vars auto-skipped), supports `pick` subset by id, `exes` per-kind exe injection and `patch` per-id field override; unknown picked ids are reported in `missing` with fuzzy spelling `hints` |
45
- | `readEnvFile(file)` | read a `.env` file into a plain object for `resolveProviders`'s `opt.env`, without polluting `process.env` |
46
- | `budgetFor(providers)` | derive the time budget to walk a whole fallback chain (sum of per-entry `timeoutMs`, defaults applied) |
47
- | `createFileStore(opt)` | file-persisted `store` for `dispatchAiFallback` (cursors and cooling survive across processes), exclusion-style passthrough |
48
- | `createUsageCounter(opt)` | per-day per-key usage counter fed by `onEvent` (observation only, never throttles) |
49
- | `salvageTruncatedArray(text)` | salvage the complete leading elements of a truncated JSON array (opt-in, not part of default parsing) |
50
- | `NO_SIDE_EFFECT` | the no-side-effect prompt prefix (single source), auto-applied by workflow `callAi`, prepend manually for direct `dispatchAiFallback` calls |
51
- | `KINDS` | array of available kinds, `['opencode', 'claude', 'codex', 'antigravity', 'api-openai-compat']` |
52
-
53
- #### Example:
54
- > **Link:** [[dev source code](https://github.com/yuda-lyu/w-dispatch-ai/blob/master/g.mjs)]
55
- ```alias
56
- import wdi from 'w-dispatch-ai'
57
-
58
-
59
- //由.env載入金鑰, OPENCODE_KEYS與AGNES_KEYS各以逗號分隔多把, 未提供時沿用各CLI既有登入狀態
60
- try {
61
- process.loadEnvFile('./.env')
62
- }
63
- catch {}
64
- let opencodeKeys = (process.env.OPENCODE_KEYS || '').split(',').filter(Boolean)
65
- let agnesKeys = (process.env.AGNES_KEYS || '').split(',').filter(Boolean)
66
-
67
-
68
- //agnes-ai為opencode未內建之第三方provider, 須另給其provider定義
69
- let configAgnes = {
70
- provider: {
71
- 'agnes-ai': {
72
- npm: '@ai-sdk/openai-compatible',
73
- name: 'Agnes',
74
- options: { baseURL: 'https://apihub.agnes-ai.com/v1' },
75
- models: { 'agnes-2.0-flash': { name: 'Agnes 2.0 Flash' } },
76
- },
77
- },
78
- }
79
-
80
-
81
- let test = async () => {
82
-
83
- //可用之AI供應商種類
84
- console.log('KINDS:', wdi.KINDS)
85
- // => KINDS: [ 'opencode', 'claude', 'codex', 'antigravity', 'api-openai-compat' ]
86
-
87
- let prompt = '請只回覆兩個字:完成,不要有任何其他文字'
88
-
89
- //以Claude Code CLI呼叫, 沿用CLI既有登入狀態
90
- let r1 = await wdi.dispatchClaude(prompt, { model: 'sonnet' })
91
- console.log('claude:', r1.ok, r1.stdout.trim())
92
- // => claude: true 完成
93
-
94
- //以Codex CLI呼叫, 可指定沙箱模式
95
- let r2 = await wdi.dispatchCodex(prompt, { model: 'gpt-5.6-luna', sandbox: 'read-only' })
96
- console.log('codex:', r2.ok, r2.stdout.trim())
97
- // => codex: true 完成
98
-
99
- //以opencode CLI呼叫, 未給key與provider即沿用CLI既有登入狀態
100
- let r3 = await wdi.dispatchOpencode(prompt, { model: 'opencode/deepseek-v4-flash-free', timeoutMs: 180000 })
101
- console.log('opencode:', r3.ok, r3.stdout.trim())
102
- // => opencode: true 完成
103
-
104
- //以antigravity CLI(agy)呼叫, prompt走--print旗標(長度上限30000字元), model須為`agy models`第一欄slug
105
- let r3b = await wdi.dispatchAntigravity(prompt, { model: 'gemini-3.6-flash-low' })
106
- console.log('antigravity:', r3b.ok, r3b.stdout.trim())
107
- // => antigravity: true 完成
108
-
109
- //以OpenAI相容API直呼(免CLI免登入), 給baseURL+key+model即可; Zen端點即opencode CLI之自家閘道
110
- let r3c = await wdi.dispatchApiOpenaiCompat(prompt, {
111
- baseURL: 'https://apihub.agnes-ai.com/v1',
112
- key: agnesKeys[0],
113
- model: 'agnes-2.0-flash',
114
- })
115
- console.log('api-openai-compat:', r3c.ok, r3c.code, r3c.stdout.trim())
116
- // => api-openai-compat: true 200 完成
117
-
118
- //以供應商條目輪替, 一個條目即一組(kind, model, 可選的key與provider與config), 輪到誰就用誰的CLI與模型
119
- //opencode支援逐次注入金鑰, 故同一provider之多把金鑰可各成一個條目
120
- let items = [
121
- { kind: 'claude', model: 'sonnet' },
122
- { kind: 'codex', model: 'gpt-5.6-luna', sandbox: 'read-only' },
123
- { kind: 'opencode', model: 'opencode/deepseek-v4-flash-free', provider: 'opencode', key: opencodeKeys[0], timeoutMs: 180000 },
124
- { kind: 'opencode', model: 'opencode/deepseek-v4-flash-free', provider: 'opencode', key: opencodeKeys[1], timeoutMs: 180000 },
125
- { kind: 'opencode', model: 'agnes-ai/agnes-2.0-flash', provider: 'agnes-ai', key: agnesKeys[0], config: configAgnes, timeoutMs: 180000 },
126
- { kind: 'antigravity', model: 'gemini-3.6-flash-low' },
127
- ]
128
- for (let item of items) {
129
- let r = await wdi.dispatchAi(item.kind, prompt, item)
130
- console.log('dispatchAi ' + item.model + ':', r.ok, r.stdout.trim())
131
- // => dispatchAi sonnet: true 完成
132
- // => dispatchAi gpt-5.6-luna: true 完成
133
- // => dispatchAi opencode/deepseek-v4-flash-free: true 完成
134
- // => dispatchAi opencode/deepseek-v4-flash-free: true 完成
135
- // => dispatchAi agnes-ai/agnes-2.0-flash: true 完成
136
- // => dispatchAi gemini-3.6-flash-low: true 完成
137
- }
138
-
139
- //未知供應商回傳error結果物件, 不會reject
140
- let r4 = await wdi.dispatchAi('gemini', prompt)
141
- console.log('invalid kind:', r4.ok, r4.error)
142
- // => invalid kind: false unknown ai kind: "gemini" (available: opencode, claude, codex, antigravity, api-openai-compat)
143
-
144
- //prompt非有效字串亦回傳error結果物件
145
- let r5 = await wdi.dispatchClaude('')
146
- console.log('invalid prompt:', r5.ok, r5.error)
147
- // => invalid prompt: false prompt must be a non-empty string
148
-
149
- //執行失敗時, 由ok、code、error與stderr判斷原因
150
- //REST路徑之錯誤依HTTP狀態碼分流(401金鑰無效、429限流、5xx服務端), 判別比CLI之stderr字串可靠
151
- let r6 = await wdi.dispatchApiOpenaiCompat(prompt, {
152
- baseURL: 'https://apihub.agnes-ai.com/v1',
153
- model: 'agnes-2.0-flash',
154
- key: 'sk-invalid-key',
155
- })
156
- console.log('invalid key:', r6.ok, r6.code, r6.error, r6.stderr.includes('无效的令牌'))
157
- // => invalid key: false 401 HTTP 401 true
158
-
159
- //多供應商自動遞補: providers順序即優先序, 組內keys以游標輪替
160
- //此例第1把金鑰無效 自動換組內下一把成功; 若整組用盡會遞補下一組, 依序往下
161
- //
162
- //【id命名】id為游標鍵與日誌標籤, 須區分到「模型」而非只到「廠商」——
163
- // 取'claude'則日後無法同時掛sonnet與opus, 且日誌看不出實際用了哪個模型;
164
- // 同一模型經不同路徑(REST/CLI/不同閘道)取得時額度池與故障域各自獨立,
165
- // 屬不同供應商, 故id須帶上路徑前綴加以區分
166
- let r7 = await wdi.dispatchAiFallback(prompt, {
167
- providers: [
168
- //REST版排前面: 免CLI、快3~5倍, 純文字任務優先走此路
169
- {
170
- id: 'agnes:agnes-2.0-flash',
171
- kind: 'api-openai-compat',
172
- baseURL: 'https://apihub.agnes-ai.com/v1',
173
- model: 'agnes-2.0-flash',
174
- keys: ['sk-invalid-key-demo', agnesKeys[0]], //第1把無效, 示範組內輪替
175
- },
176
- //同一個agnes模型之CLI版: 有工具能力但較慢, 額度池亦不同, 屬另一個供應商
177
- {
178
- id: 'oc:agnes-ai/agnes-2.0-flash',
179
- kind: 'opencode',
180
- model: 'agnes-ai/agnes-2.0-flash',
181
- provider: 'agnes-ai',
182
- keys: agnesKeys,
183
- config: configAgnes, //第三方provider須另給定義
184
- timeoutMs: 180000,
185
- },
186
- { id: 'claude:sonnet', kind: 'claude', model: 'sonnet' },
187
- { id: 'codex:gpt-5.6-luna', kind: 'codex', model: 'gpt-5.6-luna', sandbox: 'read-only' },
188
- { id: 'agy:gemini-3.6-flash-low', kind: 'antigravity', model: 'gemini-3.6-flash-low' },
189
- ],
190
- budgetMs: 600000,
191
- onEvent: (ev) => console.log(' event:', ev.type, ev.keyId, ev.error || ''),
192
- })
193
- console.log('fallback:', r7.ok, r7.providerId, r7.keyIndex, r7.stdout.trim())
194
- console.log('tried:', r7.tried.map((x) => `${x.keyId}:${x.outcome}`).join(', '))
195
- // => event: try agnes:agnes-2.0-flash#0
196
- // => event: next-key agnes:agnes-2.0-flash#0 HTTP 401
197
- // => event: try agnes:agnes-2.0-flash#1
198
- // => event: ok agnes:agnes-2.0-flash#1
199
- // => fallback: true agnes:agnes-2.0-flash 1 完成
200
- // => tried: agnes:agnes-2.0-flash#0:next-key, agnes:agnes-2.0-flash#1:ok
201
-
202
- }
203
- await test()
204
- .catch((err) => {
205
- console.log(err)
206
- })
207
- ```
208
-
209
- #### Options shared by all dispatch functions:
210
- | key | type | default | description |
211
- | --- | --- | --- | --- |
212
- | `exe` | String | 各CLI名稱 | 執行檔名稱或絕對路徑,給予名稱時由系統`PATH`解析 |
213
- | `model` | String | `''` | 模型ID,未給予則不帶模型旗標,由CLI自行決定 |
214
- | `extraArgs` | Array | `[]` | 額外命令列旗標字串陣列,接於固定旗標之後 |
215
- | `timeoutMs` | Integer | `300000` | 逾時毫秒,逾時將強制關閉子進程及其子孫程序;**全套件統一預設**(所有轉接器與各層一致,單一來源`dfTimeoutMs.mjs`),由opt傳入即可覆寫 |
216
- | `cwd` | String | `process.cwd()` | 子進程工作目錄 |
217
- | `validate` | String\|Function | `undefined` | `stdout`驗證規則,可用`'nonempty'`、`'json'`、`'min:100'`,多規則以逗號串接,亦可給予`(stdout)=>Boolean` |
218
- | `maxRetries` | Integer | `0` | 失敗後最大重試次數,遇`ENOENT`或exit code 2視為不可重試而立即中止 |
219
-
220
- 其餘設定會原樣轉傳給`wsemi`之`execCli`,例如`retryDelayMs`、`maxBuffer`、`onStdout`、`onStderr`、`env`。
221
-
222
- #### Options only for dispatchOpencode:
223
- | key | type | default | description |
224
- | --- | --- | --- | --- |
225
- | `key` | String | `''` | 該provider之API key,須與`provider`同時給予才會以`OPENCODE_AUTH_CONTENT`注入 |
226
- | `provider` | String | `''` | `key`所屬provider名稱,須與`model`為同一組 |
227
- | `config` | Object\|String | `null` | opencode設定內容,將以`OPENCODE_CONFIG_CONTENT`注入,供補上第三方provider之定義 |
228
- | `agent` | String | `'build'` | opencode代理名稱 |
229
-
230
- #### Options only for dispatchClaude:
231
- | key | type | default | description |
232
- | --- | --- | --- | --- |
233
- | `skipPermissions` | Boolean | `true` | 是否帶`--dangerously-skip-permissions`旗標,`false`代表保留CLI權限閘門(見上方Security說明) |
234
-
235
- #### Options only for dispatchCodex:
236
- | key | type | default | description |
237
- | --- | --- | --- | --- |
238
- | `sandbox` | String | `'workspace-write'` | 沙箱模式,可用`'read-only'`、`'workspace-write'`、`'danger-full-access'` |
239
-
240
- **Windows 診斷:Codex 回報所有命令 `blocked by policy`(Codex ≥0.149)**
241
-
242
- Codex 0.149 Windows 預設走 elevated 沙箱(專用使用者 `CodexSandboxOffline`/`CodexSandboxOnline`+WFP 網路過濾+家目錄 read ACL),**需一次性管理員設定**;設定未完成時 execpolicy 會在 spawn 前拒絕**所有** shell 命令(含 `Get-Content`、`rg` 等唯讀命令),錯誤形如 `CreateProcess { message: "Rejected(\"... blocked by policy\")" }`——Codex 讀檔即是執行 shell,等同完全不能讀檔。
243
-
244
- - **判別**:`~/.codex/.sandbox/setup_marker.json` 不存在、且 `~/.codex/.sandbox/sandbox.<日期>.log` 只有 `START` 沒有 `SUCCESS` = 設定未完成。
245
- - **正解**:以互動模式跑一次 `codex` 完成設定(會要求 UAC 提權),完成後 `setup_marker.json` 出現,`read-only`/`workspace-write` 皆可正常執行命令(2026-08-26 於 Codex 0.149.0+Windows 11 26200 實測:設定完成前全擋、完成後五種設定全通)。
246
- - **臨時繞道**:`extraArgs: ['--config', 'windows.sandbox="unelevated"']`——跳過管理員設定即可執行,但**隔離較弱**(無專用使用者與網路過濾);本套件**刻意不**將此設為 Windows 預設,避免在已完成設定的機器上默默降級沙箱。
247
- - **靜默失敗警語**:被擋時 Codex 常回「請貼上檔案內容」之合法字串,會通過 `validate: 'nonempty'` 被當成功。凡需 Codex 讀檔的任務,`validate`/工作流 `check` 應要求回覆**引用指定行原文**,不要只驗非空;派長任務前先以「讀一個檔並引用第 N 行」做最小探測。
248
-
249
- #### Options only for dispatchAntigravity:
250
- | key | type | default | description |
251
- | --- | --- | --- | --- |
252
- | `model` | String | `''` | 須為`agy models`**第一欄之slug**(如`gemini-3.6-flash-low`);agy錯誤訊息列出的是顯示名稱而非slug,勿照抄 |
253
- | `effort` | String | `''` | `'low'`、`'medium'`、`'high'`,需agy>=1.1.11;建議搭配不帶檔位之基礎slug(如`gemini-3.1-pro`),與帶檔位slug併用且檔位不一致時agy回conflicts錯誤 |
254
- | `skipPermissions` | Boolean | `true` | 是否帶`--dangerously-skip-permissions`旗標 |
255
- | `printTimeout` | String | 由`timeoutMs`推導 | agy自身等待上限(如`'10m'`、`'570s'`),預設`timeoutMs`扣30秒緩衝(下限30秒),令CLI先於外層逾時而回報自身錯誤訊息 |
256
- | `addDirs` | Array | 自動納入cwd | 加入workspace之目錄字串陣列,逐項展開為`--add-dir`。agy以自身scratch目錄為工作區而**不採子進程cwd**,故未給時自動納入有效cwd令檔案可視範圍與其他CLI一致;明示給陣列(含`[]`代表不揭露任何目錄)則完全尊重呼叫端 |
257
- | `timeoutMs` | Integer | `300000` | 全套件統一預設(恰對齊agy自身print-timeout之5m0s) |
258
-
259
- 注意:agy之prompt走`--print`旗標而非stdin(agy介面如此),故prompt長度上限30000字元,超過回傳錯誤結果物件(不reject)。
260
-
261
- #### Choosing CLI or API (判準):
262
- 選 `kind` 的唯一判準是**這一步需不需要「工具」**:
263
-
264
- | 這次呼叫要做的事 | 選用 | 理由 |
265
- | --- | --- | --- |
266
- | 讀本機檔案、grep、執行指令、抓網頁、寫檔 | **CLI類**:`opencode`/`claude`/`codex`/`antigravity` | CLI本身是agentic harness,自帶完整工具迴圈,呼叫端什麼都不必做 |
267
- | 摘要、分析、改寫、翻譯、產出JSON(素材皆已在prompt內) | **API類**:`api-openai-compat` | 免安裝免登入,且實測較快(Agnes:API 1~2.5s vs CLI 4~6s) |
268
-
269
- **API類不支援工具,且不會自建工具迴圈**——實測(2026-08-11)閘道端零內建工具:Zen與Agnes對 `tools:[{type:'web_search'}]` 皆回400並要求 `function.parameters`,即只接受「呼叫端自行定義且自行執行」的function工具。協定層雖支援function calling(Zen之 `nemotron-3-ultra-free` 與Agnes皆實測回 `finish_reason:'tool_calls'`),但工具的定義、執行、錯誤處理與安全邊界全須自行實作維護,等同重造CLI已提供的harness。故模型回 `tool_calls` 時本套件一律以 `TOOL_CALLS_UNSUPPORTED` 回報失敗,不假裝成功。
270
-
271
- 另注意 `tool_calls` 有**會話束縛**(`tool_call_id` 須於同一條messages串內回填),無法暫停後跨行程外傳給上層agent代跑;工作流各名額(如 `runFanout` 的agents)也只是同行程的async函數呼叫而非獨立agent,故「讓外殼agent提供工具給工作流內的模型使用」在本架構下不成立——**需要工具就選CLI類kind**。
272
-
273
- **混用才是常態**:同一條 `dispatchAiFallback` 鏈可逐條目混搭kind,工作流各階段亦然——產生候選與整合收斂等純文字階段走API,需要翻閱專案檔案的階段換CLI
274
-
275
- #### Options only for dispatchApiOpenaiCompat:
276
- | key | type | default | description |
277
- | --- | --- | --- | --- |
278
- | `baseURL` | String | 必填 | API基底網址,將於尾端接上`/chat/completions` |
279
- | `model` | String | 必填 | 模型ID(Zen之模型名不帶`opencode/`前綴) |
280
- | `key` | String | `''` | API key,以`Bearer`置於`Authorization`標頭,省略代表不帶認證 |
281
- | `system` | String | `''` | system提示詞,置於messages首位 |
282
- | `body` | Object | `{}` | 額外請求本體(`temperature`、`max_tokens`、`response_format`等),同名鍵覆寫預設 |
283
- | `headers` | Object | `{}` | 額外請求標頭 |
284
- | `timeoutMs` | Integer | `300000` | 逾時毫秒,逾時中止請求(含回應串流讀取);全套件統一預設 |
285
- | `maxRetries` | Integer | `0` | 失敗重試次數;**4xx(429除外)為客戶端錯誤不重試**,429/5xx/網路錯誤/逾時線性退避重試 |
286
- | `retryDelayMs` | Integer | `5000` | 重試間隔,實際為`retryDelayMs`×次數且上限15000ms |
287
-
288
- 結果結構對齊execCli:`stdout`為回覆內容、`code`為HTTP狀態碼(網路錯誤/逾時為`null`)、逾時`error`以`TIMEOUT`開頭、驗證失敗為`OUTPUT_VALIDATION_FAILED`——故可直接作為`dispatchAiFallback`條目(`kind: 'api-openai-compat'`,`keys`多金鑰輪替同樣適用)與工作流provider。
289
-
290
- 另追加`usage`欄位:原始回應之token用量物件**原樣透傳**(無則`null`;驗證失敗等已耗token之失敗亦帶出),經`dispatchAiFallback`(最終結果與`tried`歷程各項)與工作流層(`callAi`結果之`usage`欄)一路流出。CLI類轉接器無可靠來源故**無此欄**——對外提供OpenAI相容API的呼叫端可據此把「真實用量(REST路徑)」與「只能估算(CLI路徑)」分開處理。
291
-
292
- **`errorType`機器可讀錯誤類別**(全部轉接器與`dispatchAiFallback`/`callAi`之失敗結果皆帶,成功結果無此欄;`error`字串保留不動,兩者並存):
293
-
294
- | errorType | 意義 | 出現於 |
295
- | --- | --- | --- |
296
- | `params` | 參數/設定檢核失敗(進入執行前即被擋) | 全部 |
297
- | `timeout` | 逾時(execCli強殺或API abort) | 全部 |
298
- | `spawn` | 子進程無法啟動(ENOENT/ENAMETOOLONG) | CLI類 |
299
- | `validation` | stdout未過`validate` | 全部 |
300
- | `exec` | CLI非零離開碼之一般執行失敗(未能再機械細分) | CLI類 |
301
- | `http` | HTTP非2xx(`code`為狀態碼) | api類 |
302
- | `fetch` | 網路層錯誤(DNS/連線拒絕) | api類 |
303
- | `tool-unsupported` | 模型回tool_calls而api類不支援工具 | api類 |
304
- | `invalid-response` | 回應缺`choices[0].message.content` | api類 |
305
- | `aborted` | `shouldStop`中止 | fallback層 |
306
- | `budget` | 時間預算用盡 | fallback層 |
307
-
308
- 僅涵蓋**機械可判**者:CLI類之其餘失敗(額度上限/金鑰無效/服務端錯誤,各家字樣不同且隨版本漂移)一律歸`exec`,套件不維護簽章表(與否決金鑰停用清單同一理由)——需細分時以`coolDetect`式注入自判,或依`tried`內之`error`與`stderr`自行決策。
309
-
310
- #### Options for dispatchAiFallback:
311
- | key | type | default | description |
312
- | --- | --- | --- | --- |
313
- | `providers` | Array | 必填 | 供應商條目陣列,**順序即優先序**。條目除`id`、`keys`外即該次調用之opt,原樣透傳對應轉接器(`kind`、`model`、`exe`、`provider`、`config`、`sandbox`、`timeoutMs`等皆放條目內) |
314
- | `providers[].id` | String | 條目索引 | 群組識別,游標以此為鍵、亦為日誌標籤;本套件不解讀其內容,命名規則見下方 |
315
- | `providers[].keys` | Array | `[]` | 同一服務之多把API key,逐次注入輪替(`kind`為`opencode`時須同時給`provider`);省略代表沿用CLI登入狀態 |
316
- | `providers[].meta` | any | | **保留鍵,保證永不轉傳**轉接器。條目其餘鍵一律原樣轉傳——呼叫端要在條目上掛自有資訊(分類、標籤、註記)一律放`meta`,與轉傳機制永久絕緣(頂層opt與工作流各層規格物件同此約定) |
317
- | `budgetMs` | Integer | 不限 | 整輪遞補之時間上限,剩餘預算會壓進每次呼叫之`timeoutMs` |
318
- | `minAttemptMs` | Integer | `20000` | 單次嘗試之最低剩餘預算,低於此值即停止並回報`budget exhausted` |
319
- | `store` | Object | 行程內記憶體 | 狀態持久化`{get:()=>state, set:(state)=>{}}`,state含`cursors`(逐群組游標)與`cooling`(供應商冷卻時間戳,僅啟用cooldownMs時使用);假定單行程序列調用。跨行程持久化可直接用`createFileStore`;自行實作時**務必整包原封存還**,白名單式挑欄位會在套件擴充state時靜默丟棄新欄位 |
320
- | `cooldownMs` | Integer | `0`不啟用 | 供應商冷卻視窗:條目(限有明給id者)遭遇**限流(HTTP 429,僅api類可偵測)或逾時(TIMEOUT)**後,於視窗內之後續呼叫中被**移至鏈尾(只降序不移除)**——前面全敗時仍會被嘗試、任一次成功立即解除,故不存在把已恢復服務冰住的問題。多階段工作流可大幅省去逐階段重踩已失效供應商的成本(使用端實測107s→15s)。注意啟用時providers順序會被暫時重排,此即機制目的 |
321
- | `coolDetect` | Function | | 冷卻觸發之**注入判定**`(r)=>Boolean`,收完整失敗結果(含`stdout`、`stderr`、`code`、`error`),回傳`true`即視同冷卻觸發(內建429/TIMEOUT觸發不受影響)。CLI類限流埋在stderr且各家字樣不同、隨版本漂移,**簽章表由觀察到字樣的呼叫端維護**,如`(r) => /FreeUsageLimitError/i.test(r.stderr \|\| '')`;漏判僅退回現狀(每階段重探一次)、誤判也只是降尾非移除,兩邊代價都有上限。僅`cooldownMs>0`時有效;回調拋出例外視同`false` |
322
- | `shouldStop` | Function | 無 | 中止判定`()=>Boolean`,於**每次嘗試之間**檢查,`true`即停止遞補回報`ABORTED`——供成果已無人接收時(如server端客戶端斷線)止損,把「斷線後仍空耗整條鏈」縮成「至多再耗當前這一家」。**不中止進行中之嘗試**(不殺子進程/不斷開請求,見Known design notes)。經工作流層原樣轉傳:中止後每個後續呼叫進門即回`ABORTED`,整條工作流自然快速收束,無須逐層處理;回調拋出例外視同`false` |
323
- | `meta` | any | 無 | 保留鍵,同`providers[].meta`,永不轉傳 |
324
- | `onEvent` | Function | 無 | 事件回調`(ev)=>{}`,`ev.type`為`'try'`、`'ok'`、`'next-key'`、`'skip-group'`、`'budget-out'`、`'aborted'`、`'cooled'`(冷卻觸發,帶`error`與`cooldownMs`,僅啟用cooldownMs時出現);失敗事件另帶`errorType`、`stdout`(被拒回覆)與`stderr`(錯誤輸出,皆已截斷)供診斷;回調拋出例外不影響主流程 |
325
-
326
- 頂層其餘設定(`timeoutMs`、`validate`、`maxRetries`等)為各attempt之共用預設,條目可覆寫;`maxRetries`建議維持預設`0`,韌性交給換家而非重試同一家。
327
-
328
- **條目 `id` 之命名規則**(呼叫端負責設計,本套件只當作不透明字串使用):
329
-
330
- `id` 在套件內只有兩個用途——游標的物件鍵(`state.cursors[id]`)與日誌標籤(`providerId`、`keyId` = `` `${id}#${keyIndex}` ``)。不查表、不比對、無格式要求,故「什麼算同一個供應商」由呼叫端定義。
331
-
332
- | 規則 | 說明 |
333
- | --- | --- |
334
- | **區分到「模型」而非只到「廠商」** | ❌ `id: 'claude'` 日後無法同時掛 sonnet opus,日誌也看不出用了哪個模型<br>✅ `id: 'claude:sonnet'`、`id: 'claude:opus'` |
335
- | **同一模型經不同路徑時須帶路徑** | 同一個 laguna 可經 Poolside 官方 REST、OpenRouter、opencode CLI 三條路,額度池與故障域各自獨立,屬三個供應商:<br>`'poolside:laguna-s-2.1'`、`'or:poolside/laguna-s-2.1:free'`、`'oc:poolside/poolside/laguna-s-2.1'` |
336
- | **務必給、務必唯一** | 未給時回退為**陣列索引字串**——索引是位置不是身分,日後於鏈中插入條目會令後續條目繼承他人的游標進度(輪替張冠李戴)。兩個條目同 `id` 則共用同一游標且日誌無法區分。 |
337
-
338
- **同一組金鑰用於多個條目時**(例如某模型的 CLI 版與 REST 版共用同一批金鑰),各條目游標**獨立**:兩者各自從游標起點輪替,同一把金鑰可能被連續使用而另一把閒置。要共享輪替進度就給**相同** `id`(代價:日誌無法區分兩者);要能區分就分開命名(代價:額度不均攤)。此取捨由呼叫端依實際需求決定。
339
-
340
- **失敗分流規則**:
341
- | 失敗 | 判定 | 處置 |
342
- | --- | --- | --- |
343
- | 逾時 | `error`以`TIMEOUT`開頭 | 整組跳過 |
344
- | 執行檔不存在 | `error`含`ENOENT` | 整組跳過 |
345
- | 參數錯誤 | `code === 2` | 整組跳過 |
346
- | 輸出未過驗證 | `error === 'OUTPUT_VALIDATION_FAILED'` | 整組跳過 |
347
- | kind無效 | `error`以`unknown ai kind`開頭 | 整組跳過 |
348
- | 其餘(含額度上限、金鑰無效、服務回錯) | | 換組內下一把 |
349
-
350
- 整組跳過的理由:同組各金鑰共用同一`exe`與`model`,這些失敗換金鑰必然再敗,逐把嘗試純屬空耗。其餘失敗一律換下一把、**不記憶不停用**——額度視窗形態多樣(5小時滾動、逐時、逐日),停用清單會把已恢復的金鑰閒置,而重探的代價僅一次快速失敗;跨次執行僅記憶游標(成功後推進,令額度在多把金鑰間均攤)。
351
-
352
- #### Result of dispatch functions:
353
- ```alias
354
- //成功
355
- {
356
- ok: true,
357
- stdout: '完成\r\n',
358
- stderr: '\x1b[0m\r\n> build · deepseek-v4-flash-free\r\n\x1b[0m\r\n',
359
- code: 0,
360
- error: '',
361
- durationMs: 11742,
362
- pid: 9800,
363
- attempts: 1,
364
- }
365
-
366
- //CLI執行失敗, 本套件各函數皆不reject
367
- {
368
- ok: false,
369
- stdout: '',
370
- stderr: '\x1b[0m\r\n> build · deepseek-v4-flash-free\r\n\x1b[0m\r\n\x1b[91m\x1b[1mError: \x1b[0mInvalid API key.\r\n',
371
- code: 1,
372
- error: 'Exit code 1',
373
- durationMs: 3049,
374
- pid: 15208,
375
- attempts: 1,
376
- }
377
-
378
- //參數檢核失敗, 未實際啟動子進程故無pid
379
- {
380
- ok: false,
381
- stdout: '',
382
- stderr: '',
383
- code: null,
384
- error: 'prompt must be a non-empty string',
385
- durationMs: 0,
386
- attempts: 0,
387
- }
388
- ```
389
-
390
- #### Result of dispatchAiFallback:
391
- 於execCli既有欄位外追加:
392
- ```alias
393
- {
394
- // ...ok, stdout, stderr, code, error, durationMs, attempts, pid...
395
- providerId: 'agnes:agnes-2.0-flash', //實際使用之群組(即條目id)
396
- keyIndex: 1, //實際使用之金鑰索引, 無keys時為null
397
- kind: 'api-openai-compat',
398
- model: 'agnes-2.0-flash',
399
- tried: [ //完整嘗試歷程, 成功時亦回傳; 失敗項另含stdout(被拒回覆)與stderr(錯誤輸出, 皆已截斷)供診斷
400
- { providerId: 'agnes:agnes-2.0-flash', keyIndex: 0, keyId: 'agnes:agnes-2.0-flash#0', outcome: 'next-key', error: 'HTTP 401', durationMs: 105 },
401
- { providerId: 'agnes:agnes-2.0-flash', keyIndex: 1, keyId: 'agnes:agnes-2.0-flash#1', outcome: 'ok', durationMs: 1161 },
402
- ],
403
- }
404
- ```
405
-
406
- #### dispatchAiWkf (workflow factory):
407
- 注入一次provider定義表(名稱 → `dispatchAiFallback`條目)與共用預設,之後以名稱宣告工作流;名稱查無定義即回報錯誤(fail fast)。回覆經寬鬆JSON解析(`extractJsonLoose`)+自訂`check`驗證,非法回覆視為該家失敗而自動遞補;預設於prompt前掛「禁止建檔」約束(`promptPrefix: ''`可關閉);措辭豁免唯讀查閱——codex以shell讀檔,一律禁指令會令其無法讀取專案檔案且靜默回拒答(2026-08-13實測)。
408
-
409
- ```alias
410
- let wkf = wdi.dispatchAiWkf({
411
- providers: {
412
- 'zen:deepseek-v4-flash-free': { kind: 'api-openai-compat', baseURL: 'https://opencode.ai/zen/v1', model: 'deepseek-v4-flash-free', keys: [...] },
413
- 'claude:sonnet': { kind: 'claude', model: 'sonnet' },
414
- 'codex:gpt-5.6-luna': { kind: 'codex', model: 'gpt-5.6-luna' },
415
- },
416
- defaults: { timeoutMs: 300000 },
417
- })
418
-
419
- //單一名額: 主模型+自帶遞補鏈
420
- let r1 = await wkf.callAi('...prompt...', { spec: { use: 'zen:deepseek-v4-flash-free', fallback: ['claude:sonnet'] }, check: (j) => !!j.essence })
421
-
422
- //Fanout: 並行多開執行 單點整合收斂(候選未達minCandidates時以首位候選為成果不硬整合)
423
- //check為共用預設; 名額規格與integrate可各自帶check(候選與終稿判準常不同, 如終稿須含固定段落)
424
- let r2 = await wkf.runFanout({ task, agents: [{ use: 'zen:deepseek-v4-flash-free', fallback: ['claude:sonnet'] }, { use: 'claude:sonnet' }], integrate: { use: 'codex:gpt-5.6-luna' }, check })
425
-
426
- //RolePipeline: 多角色串行鏈, 各階段可自帶AI/遞補/檢核, prompt收ctx={input,prev,results,index}
427
- let r3 = await wkf.runRolePipeline({ input, stages: [{ id: 'draft', use: 'claude:sonnet', prompt: (ctx) => `...` }, { id: 'audit', use: 'codex:gpt-5.6-luna', prompt: (ctx) => `...${JSON.stringify(ctx.prev)}` }] })
428
-
429
- //FanoutPipeline: Fanout成果接RolePipeline(品質天花板組合)
430
- let r4 = await wkf.runFanoutPipeline({ task, agents, integrate, stages, check })
431
- ```
432
-
433
- 各工作流皆部分接受:個別名額/階段失敗不炸整輪,已完成成果完整回傳(`candidates`/`results`+`failedStage`),可只重跑失敗段。
434
-
435
- #### Timeout 總覽(各層預設、行為與調整方式):
436
-
437
- **一句話**:全套件單一預設 **`300000`(5分鐘,單一來源 [src/dfTimeoutMs.mjs](https://github.com/yuda-lyu/w-dispatch-ai/blob/master/src/dfTimeoutMs.mjs))**——不論直接呼叫轉接器、或經 `dispatchAiFallback`/工作流,「單次AI嘗試」的逾時都是它;工作流本身**沒有**獨立的總時限參數(總時長=結構×單次,見下方公式)。
438
-
439
- **階梯結構**(由細至粗,數值須嚴格遞增):
440
-
441
- ```alias
442
- agy --print-timeout(自動=timeoutMs−30s)
443
- < timeoutMs(單次嘗試,統一預設300000)
444
- < budgetMs(單一名額之遞補鏈總預算,預設null不限)
445
- < 工作流總時長(無獨立參數,由結構推導)
446
- ```
447
-
448
- **各參數一覽**:
449
-
450
- | 參數 | 作用範圍 | 預設 | 逾時後果/備註 |
451
- | --- | --- | --- | --- |
452
- | `timeoutMs` | **單次AI嘗試**,所有kind一致(直接呼叫與工作流內皆同一數字) | `300000` | CLI強殺子進程樹/API中止請求;`error`以`TIMEOUT`開頭 → fallback視為**與金鑰無關**,整組跳過(不逐把空耗) |
453
- | `printTimeout` | 僅antigravity,agy自身等待上限 | 自動=timeoutMs−30s | 令CLI先於外層逾時,錯誤訊息來自agy自身;一般無須手動設 |
454
- | `budgetMs` | `dispatchAiFallback`整輪遞補(=工作流的一個名額/階段) | `null`不限 | 有值時剩餘預算會壓進每次嘗試的timeoutMs;用盡回`budget exhausted` |
455
- | `minAttemptMs` | 搭配budgetMs的開工門檻 | `20000` | 剩餘預算低於此值即不再開工;**無budgetMs時不作用** |
456
- | 工作流總時長 | `runFanout`/`runRolePipeline`/`runFanoutPipeline` | 無(刻意) | 由結構推導,要上限就設各名額的`budgetMs` |
457
-
458
- **工作流總時長公式**(每次嘗試≤timeoutMs;K=遞補鏈組數、M=階段數):
459
-
460
- | 工作流 | 正常情況 | 最壞情況(多家連環卡死) |
461
- | --- | --- | --- |
462
- | `callAi`單一名額 | 首家耗時 | K×timeoutMs(逾時型失敗每組只燒一次即跳組;額度型失敗為秒級) |
463
- | `runFanout` | 最慢名額+整合名額(agents**並行**) | ≈2×K×timeoutMs |
464
- | `runRolePipeline` | Σ各階段(**序列**) | ≈M×K×timeoutMs |
465
- | `runFanoutPipeline` | 上兩者相加 | ≈(2+M)×K×timeoutMs |
466
-
467
- 量級感受:內建providers 9條全上陣時,一個名額最壞9×300s=45min;3階段RolePipeline最壞約2.25小時(正常情況為秒級~分鐘級,最壞只在多家連環卡死時發生)。
468
-
469
- **外部調整四層**(細者覆蓋粗者,全部免改套件程式):
470
- 1. **全域**:`dispatchAiWkf({ defaults: { timeoutMs, budgetMs, minAttemptMs } })`
471
- 2. **單工作流**:`runFanout({ callOpt: { timeoutMs... } })`
472
- 3. **單階段/名額**:stage/agent 規格上直接給 `timeoutMs`/`budgetMs`
473
- 4. **單條目**:provider 條目給 `timeoutMs`(如已知會卡死之供應商給小蓋子,卡死成本從名額預算縮為該蓋子)
474
-
475
- **三種常用設定**:
476
-
477
- ```alias
478
- //1. 簡單任務(秒級~分鐘級): 什麼都不用設, 全走統一預設300000
479
-
480
- //2. 要給工作流總上限: 設每名額budgetMs(序列工作流總上限≈Σ各階段budget; fanout≈名額+整合)
481
- let wkf = wdi.dispatchAiWkf({ providers: table, defaults: {
482
- budgetMs: 900000, //每名額至多15min → 3階段RolePipeline總上限≈45min
483
- minAttemptMs: 60000, //剩餘不足1min就不再開工
484
- } })
485
-
486
- //3. 複雜任務(單一AI工作約15min, fallback須能走到最末):
487
- let wkf2 = wdi.dispatchAiWkf({ providers: table, defaults: {
488
- timeoutMs: 1200000, //20min=15min工作+33%餘裕(太緊會殺掉合法執行)
489
- minAttemptMs: 1200000, //剩餘不足完整視窗即不開工——開了也不可能完成, 純浪費
490
- budgetMs: 4800000, //鏈長K×timeoutMs(K=4→80min): 逾時每組只燒一次即跳組, 故保證走得到最末; 無外部時限可null
491
- } })
492
- ```
493
-
494
- #### providers.mjs(內建供應商定義檔):
495
- [src/providers.mjs](https://github.com/yuda-lyu/w-dispatch-ai/blob/master/src/providers.mjs) 收錄各供應商條目(CLI版與REST版),金鑰以 `envVar` 間接引用(機密只放 `.env`),經 `resolveProviders` 展開後即可直接使用或以 `pick` 自選。
496
-
497
- **zen免費模型清單為「更新日快照」**:`zen:` 系收錄截至 2026-08-21 經 `GET /zen/v1/models` 查得之**全部**免費模型(`*-free`),不做好用篩選——新模型會上線、舊模型可能下架或限流,**不保證清單即為當前最新可用狀態**;且各模型能力/速度/輸出習慣差異極大(各條目註解記錄已測特性,如批次涵蓋率、實測耗時),由呼叫端自行評估選用。暫時打不通的條目依本套件哲學保留不移除:恢復的偵測就是下次再打一次,`fallback`/`cooldownMs` 即為此而生。
498
-
499
- ```alias
500
- import wdi from 'w-dispatch-ai'
501
-
502
- //金鑰放.env(OPENCODE_KEYS/AGNES_KEYS/POOLSIDE_KEYS, 逗號分隔多把), 以readEnvFile讀成物件——
503
- //不用process.loadEnvFile: 那會把金鑰塞進process.env, 多專案並行時互相覆蓋
504
- let env = wdi.readEnvFile('./.env')
505
-
506
- //全取: envVar → keys, 缺環境變數之條目自動停用並列入skipped
507
- let { providers, table, skipped } = wdi.resolveProviders(wdi.providers, { env })
508
-
509
- //自選: pick順序即遞補優先序; providers餵dispatchAiFallback, table餵dispatchAiWkf
510
- let picked = wdi.resolveProviders(wdi.providers, { env, pick: ['agnes:agnes-2.0-flash', 'claude:sonnet'] })
511
- let r = await wdi.dispatchAiFallback(prompt, { providers: picked.providers, timeoutMs: 1200000 })
512
- let wkf = wdi.dispatchAiWkf({ providers: picked.table, defaults: { timeoutMs: 1200000 } })
513
-
514
- //後處理(選用): exes逐kind注入CLI執行檔絕對路徑(Windows排程session 0之PATH常缺npm全域目錄),
515
- //patch逐id淺合併覆寫任意欄位; 兩者於函數內施作, providers與table同源產出必然一致
516
- let p2 = wdi.resolveProviders(wdi.providers, {
517
- env,
518
- pick: ['claude:sonnet', 'codex:gpt-5.6-luna'],
519
- exes: { claude: 'C:/Users/x/.local/bin/claude.exe' },
520
- patch: { 'claude:sonnet': { timeoutMs: 360000 } },
521
- })
522
-
523
- //pick打錯字時missing附拼寫提示hints(最接近之可用id), 可直接組出可定位的錯誤訊息
524
- let pm = wdi.resolveProviders(wdi.providers, { env, pick: ['poolside/laguna-s-2.1'] })
525
- if (pm.missing.length > 0) {
526
- throw new Error(`unknown provider id(s): ${pm.missing.map((id) => `${id} (did you mean ${pm.hints[id]}?)`).join(', ')}`)
527
- }
528
- ```
529
-
530
- **自帶條目(新模型上線快於套件發版時)**:`resolveProviders` 第一參數就是普通條目陣列,安裝端把自訂條目**合併進輸入**再傳入即可,同 id 時以自訂者覆蓋內建:
531
-
532
- ```alias
533
- let extra = [{ id: 'zen:some-new-model-free', model: 'some-new-model-free', kind: 'api-openai-compat', envVar: 'OPENCODE_KEYS', baseURL: 'https://opencode.ai/zen/v1', body: { max_tokens: 8192 } }]
534
- let merged = [...wdi.providers.filter((p) => !extra.some((e) => e.id === p.id)), ...extra]
535
- let resolved = wdi.resolveProviders(merged, { env, pick: [...] })
536
- ```
537
-
538
- **警語:動「輸入」、不要動「回傳」**——把條目 push 進回傳的 `providers` 陣列不會同步進 `table`,兩者當場分歧;合併輸入再呼叫則兩種輸出同源產出、必然一致。另同 id 重複條目屬設定錯誤(共用游標、日誌無法區分),合併時務必如上例先濾再接。
539
-
540
- **配套工具**(皆為選用,深層引入或由聚合物件取用):
541
-
542
- | 工具 | 用途 |
543
- | --- | --- |
544
- | `createFileStore({ dir })` | `dispatchAiFallback`之`store`的檔案持久化——排程任務每次執行都是新行程,記憶體游標/冷卻每次歸零;本實作採**排除式passthrough**(state原封存還,僅剔自用欄位`at`),日後套件擴充state欄位自動相容(殷鑑:白名單store曾把1.0.7新增的`cooling`靜默丟棄) |
545
- | `createUsageCounter({ dir })` | 逐日逐鍵用量計帳,`onEvent`直接掛進dispatch即於`try`事件記帳;**純觀測絕不據以節流**(額度視窗形態多樣,臆測門檻擋自己的呼叫等同拿猜測當事實);排程環境務必注入`getDate`錨定時區 |
546
- | `budgetFor(chain)` | 遞補鏈走滿全鏈之時間預算(Σ各條目`timeoutMs`,未帶者以統一預設300000計);與外部排程硬上限取小者交`budgetMs` |
547
- | `salvageTruncatedArray(text)` | 截斷JSON陣列之前段搶救(救回的每個元素皆完整合法);**不併入預設解析**——「判失敗換家重產」與「搶救前段部分接受」是同一問題的兩種合法策略,組成自訂`parse`注入即可 |
548
- | `NO_SIDE_EFFECT` | 防副作用prompt前綴之單一來源(措辭含唯讀查閱豁免——codex以shell讀檔,一律禁指令等同禁讀檔);工作流`callAi`預設自動掛上,直呼`dispatchAiFallback`者自行前綴 |
549
-
550
- **內建CLI條目之防寫機制對照**(內建清單定位為唯讀調用,各家CLI條目皆自帶機械防寫;需要寫入能力時於條目或呼叫時覆寫該欄位即可。api類為純文字生成天然無寫檔能力,不在此列):
551
-
552
- | kind | 條目防寫欄位 | 機制 | 實測依據 |
553
- | --- | --- | --- | --- |
554
- | `opencode` | `config.permission: { edit/write/bash: 'deny' }` | opencode設定層拒絕編輯/寫檔/執行指令 | 2026-08 實測 |
555
- | `claude` | `extraArgs: ['--disallowedTools', 'Write,Edit,NotebookEdit,Bash']` | CLI停用寫入類工具 | 2026-08 實測 |
556
- | `codex` | `sandbox: 'read-only'` | Codex沙箱唯讀模式 | 2026-08-26 於 Codex 0.149.0 實測可執行唯讀命令;前提是 Windows elevated 沙箱之一次性設定已完成,否則所有命令 `blocked by policy`(診斷見「Options only for dispatchCodex」) |
557
- | `antigravity` | `skipPermissions: false` | 保留agy權限閘門(不送`--dangerously-skip-permissions`) | 2026-08-15 canary實測:無此鎖時要求建檔**會真的落地**;`false`之下寫入被擋且**不卡逾時**(6.4s正常返回)、唯讀工具照常 |
558
-
559
- 注意agy被權限閘門擋下寫入時回`ok: true`且**stdout為空**(靜默拒絕非報錯):工作流層無害(空回覆過不了validate而自動遞補),但直接呼叫`dispatchAntigravity`者須以「空輸出」判別被擋,不能只看`ok`。另提示詞層的`NO_SIDE_EFFECT`前綴是「請求」不是「強制」,機械防寫以上表欄位為準。
560
-
561
- #### Known design notes:
562
- - `package.json`**刻意不設**`exports`欄位:wsemi與w-*系列皆為自有套件,呼叫端以按需深層引入(`w-dispatch-ai/src/xxx.mjs`)為既定路線;增設exports會封死此路徑,勿加。
563
- - `dispatchAi(kind, prompt, opt)`會把整個`opt`原樣轉傳對應轉接器,該轉接器用不到的鍵(例如輪替條目物件內的`kind`)會被忽略,故「供應商條目物件直接當`opt`」是預期用法;`dispatchAiFallback`之providers條目沿用同一約定。
564
- - `dispatchAiFallback`為單向單輪:全數群組試畢即回傳最後一筆失敗結果與`tried`歷程,不回頭重試已敗的組。跨次執行僅記憶游標,不設金鑰停用清單(理由見上方失敗分流說明);需跨次跳過特定金鑰時,由呼叫端依`tried`/`onEvent`內之`error`與`stderr`自行決策。
565
- - `shouldStop`**只在嘗試邊界檢查,不中止進行中之嘗試**(不殺子進程、不斷開HTTP請求):進行中嘗試之強制中止需侵入execCli層與各轉接器,屬已知設計取捨——最小版已把斷線後的損失從「整條鏈」縮成「至多再耗當前這一家」;如有實測場景證明不足再議完整版。
566
- - CLI類限流簽章**不進套件**:各家stderr字樣不同且隨CLI版本漂移,套件維護簽章表等同養一個自己驗證不了的分類器(與否決金鑰停用清單同一理由)。偵測經`coolDetect`依賴注入,由觀察到字樣的呼叫端維護。
567
- - `dispatchOpencode`之`key`與`provider`須同時給予才會注入金鑰;只給其一(或範例中`.env`缺鍵導致`key`為`undefined`)時不會報錯,而是靜默沿用CLI既有登入狀態。
568
- - 範例中之`process.loadEnvFile`需Node.js >= 20.12,僅範例使用,套件本身無此限制。
569
- - `config`以`OPENCODE_CONFIG_CONTENT`注入後,與使用者既有`opencode.jsonc`為覆蓋或合併關係未經實測確認;建議`config`內含該次調用所需之完整provider定義,不依賴與既有設定檔之合併行為。
1
+ # w-dispatch-ai
2
+ A tool for dispatch ai.
3
+
4
+ ![language](https://img.shields.io/badge/language-JavaScript-orange.svg)
5
+ [![npm version](http://img.shields.io/npm/v/w-dispatch-ai.svg?style=flat)](https://npmjs.org/package/w-dispatch-ai)
6
+ [![license](https://img.shields.io/npm/l/w-dispatch-ai.svg?style=flat)](https://npmjs.org/package/w-dispatch-ai)
7
+ [![npm download](https://img.shields.io/npm/dt/w-dispatch-ai.svg)](https://npmjs.org/package/w-dispatch-ai)
8
+ [![npm download](https://img.shields.io/npm/dm/w-dispatch-ai.svg)](https://npmjs.org/package/w-dispatch-ai)
9
+ [![jsdelivr download](https://img.shields.io/jsdelivr/npm/hm/w-dispatch-ai.svg)](https://www.jsdelivr.com/package/npm/w-dispatch-ai)
10
+
11
+ ## Documentation
12
+ To view documentation or get support, visit [docs](https://yuda-lyu.github.io/w-dispatch-ai/global.html).
13
+
14
+ ## Installation
15
+
16
+ ### Using npm(ES6 module):
17
+ ```alias
18
+ npm i w-dispatch-ai
19
+ ```
20
+
21
+ Note:
22
+ - `dispatchClaude` needs [Claude Code CLI](https://claude.com/claude-code) (`claude`) in system PATH, and uses its existing login state.
23
+ - `dispatchCodex` needs [OpenAI Codex CLI](https://github.com/openai/codex) (`codex`) in system PATH, and uses its existing login state.
24
+ - `dispatchOpencode` needs [opencode CLI](https://opencode.ai/) (`opencode`) in system PATH. Unlike the other two, it accepts a per-call `key`+`provider`, injected through `OPENCODE_AUTH_CONTENT`, so multiple api keys can be rotated without rewriting `auth.json`.
25
+ - `dispatchAntigravity` needs [Google Antigravity CLI](https://antigravity.google/) (`agy`, not `antigravity`) in system PATH, and uses its existing OAuth login state (first login requires an interactive desktop session). Unlike the other three, agy takes the prompt via the `--print` flag instead of stdin, so the prompt is capped at 30000 chars (Windows command line limit); longer prompts return an error result.
26
+ - `dispatchApiOpenaiCompat` needs **no cli and no login**: it calls any OpenAI-compatible endpoint directly by fetch. Known-working gateways (verified 2026-08-11): [OpenCode Zen](https://opencode.ai/docs/zen) `https://opencode.ai/zen/v1` (same `sk-...` keys as opencode cli, model names without the `opencode/` prefix, e.g. `deepseek-v4-flash-free`) and Agnes `https://apihub.agnes-ai.com/v1` (model `agnes-2.0-flash`). Note claude/codex use subscription login state, not api keys, so they cannot be called this way.
27
+ - Each cli adapter also accepts an `exe` option to pin the executable path, useful when the CLI is not in PATH (e.g. Windows Task Scheduler environments).
28
+ - For the other three adapters the prompt is always passed through stdin, never as a positional argument, so a prompt of tens of thousands of characters will not cause `ENAMETOOLONG`.
29
+ - All functions never reject. Success or failure is reported by the `ok` and `error` fields of the result object.
30
+ - **Security**: `dispatchClaude` passes `--dangerously-skip-permissions` by default, so the non-interactive `-p` mode will not hang on permission prompts. If the prompt embeds untrusted content (e.g. a web page to summarize), instructions inside that content would also run without the permission gate. Pass `skipPermissions: false` to keep the CLI permission gate.
31
+
32
+ #### Functions:
33
+ | function | description |
34
+ | --- | --- |
35
+ | `dispatchAi(kind, prompt, opt)` | dispatch to the adapter of `kind`, one of `'opencode'`、`'claude'`、`'codex'`、`'antigravity'`、`'api-openai-compat'`、`'api-openai-responses'` |
36
+ | `dispatchAiFallback(prompt, opt)` | call ai with an ordered provider list, auto rotating keys within a group and falling back to the next group |
37
+ | `dispatchAiWkf(opt)` | workflow factory: inject a named provider table once, returns bound `callAi`/`runFanout`/`runRolePipeline`/`runFanoutPipeline` |
38
+ | `dispatchOpencode(prompt, opt)` | call an ai model by opencode cli, supports per-call api key and provider config |
39
+ | `dispatchClaude(prompt, opt)` | call a claude model by claude code cli |
40
+ | `dispatchCodex(prompt, opt)` | call a gpt model by openai codex cli |
41
+ | `dispatchAntigravity(prompt, opt)` | call an ai model by google antigravity cli (`agy`), a multi-model gateway (gemini, claude, gpt-oss) |
42
+ | `dispatchApiOpenaiCompat(prompt, opt)` | call an ai model by direct fetch to any OpenAI-compatible API (`baseURL`+`key`+`model`), no cli and no login required |
43
+ | `dispatchApiOpenaiResponses(prompt, opt)` | same, but for the OpenAI **Responses API** (`/responses`) required by model families that are not served on `/chat/completions` (e.g. OpenCode Zen's muse-spark and GPT families) |
44
+ | `providers` | curated provider entries verified by real tests (cli and rest paths), pick or use all via `resolveProviders` |
45
+ | `resolveProviders(providers, opt)` | expand `envVar` → `keys` from env (comma-separated, missing vars auto-skipped), supports `pick` subset by id, `exes` per-kind exe injection and `patch` per-id field override; unknown picked ids are reported in `missing` with fuzzy spelling `hints` |
46
+ | `readEnvFile(file)` | read a `.env` file into a plain object for `resolveProviders`'s `opt.env`, without polluting `process.env` |
47
+ | `budgetFor(providers)` | derive the time budget to walk a whole fallback chain (sum of per-entry `timeoutMs`, defaults applied) |
48
+ | `createFileStore(opt)` | file-persisted `store` for `dispatchAiFallback` (cursors and cooling survive across processes), exclusion-style passthrough |
49
+ | `createUsageCounter(opt)` | per-day per-key usage counter fed by `onEvent` (observation only, never throttles) |
50
+ | `salvageTruncatedArray(text)` | salvage the complete leading elements of a truncated JSON array (opt-in, not part of default parsing) |
51
+ | `NO_SIDE_EFFECT` | the no-side-effect prompt prefix (single source), auto-applied by workflow `callAi`, prepend manually for direct `dispatchAiFallback` calls |
52
+ | `getQuotaClaude(email, opt)` | read the current subscription quota windows (5h / 7d / per-model 7d) of the locally logged-in Claude Code account via Anthropic's OAuth usage API; `email` is compared against the local account, not used to look one up |
53
+ | `getQuotaCodex(email, opt)` | same for the Codex CLI account: primary path `codex app-server` JSON-RPC (auth handled by codex), fallback to chatgpt.com's usage endpoint |
54
+ | `getQuotaAntigravity(email, opt)` | same for the Antigravity CLI (`agy`) account via its headless `-p "/usage" --output-format json` (agy ≥ 1.1.11, version-gated) |
55
+ | `KINDS` | array of available kinds, `['opencode', 'claude', 'codex', 'antigravity', 'api-openai-compat', 'api-openai-responses']` |
56
+
57
+ #### Example:
58
+ > **Link:** [[dev source code](https://github.com/yuda-lyu/w-dispatch-ai/blob/master/g.mjs)]
59
+ ```alias
60
+ import wdi from 'w-dispatch-ai'
61
+
62
+
63
+ //由.env載入金鑰, OPENCODE_KEYS與AGNES_KEYS各以逗號分隔多把, 未提供時沿用各CLI既有登入狀態
64
+ try {
65
+ process.loadEnvFile('./.env')
66
+ }
67
+ catch {}
68
+ let opencodeKeys = (process.env.OPENCODE_KEYS || '').split(',').filter(Boolean)
69
+ let agnesKeys = (process.env.AGNES_KEYS || '').split(',').filter(Boolean)
70
+
71
+
72
+ //agnes-ai為opencode未內建之第三方provider, 須另給其provider定義
73
+ let configAgnes = {
74
+ provider: {
75
+ 'agnes-ai': {
76
+ npm: '@ai-sdk/openai-compatible',
77
+ name: 'Agnes',
78
+ options: { baseURL: 'https://apihub.agnes-ai.com/v1' },
79
+ models: { 'agnes-2.0-flash': { name: 'Agnes 2.0 Flash' } },
80
+ },
81
+ },
82
+ }
83
+
84
+
85
+ let test = async () => {
86
+
87
+ //可用之AI供應商種類
88
+ console.log('KINDS:', wdi.KINDS)
89
+ // => KINDS: [ 'opencode', 'claude', 'codex', 'antigravity', 'api-openai-compat', 'api-openai-responses' ]
90
+
91
+ let prompt = '請只回覆兩個字:完成,不要有任何其他文字'
92
+
93
+ //以Claude Code CLI呼叫, 沿用CLI既有登入狀態
94
+ let r1 = await wdi.dispatchClaude(prompt, { model: 'sonnet' })
95
+ console.log('claude:', r1.ok, r1.stdout.trim())
96
+ // => claude: true 完成
97
+
98
+ //以Codex CLI呼叫, 可指定沙箱模式
99
+ let r2 = await wdi.dispatchCodex(prompt, { model: 'gpt-5.6-luna', sandbox: 'read-only' })
100
+ console.log('codex:', r2.ok, r2.stdout.trim())
101
+ // => codex: true 完成
102
+
103
+ //以opencode CLI呼叫, 未給key與provider即沿用CLI既有登入狀態
104
+ let r3 = await wdi.dispatchOpencode(prompt, { model: 'opencode/deepseek-v4-flash-free', timeoutMs: 180000 })
105
+ console.log('opencode:', r3.ok, r3.stdout.trim())
106
+ // => opencode: true 完成
107
+
108
+ //以antigravity CLI(agy)呼叫, prompt走--print旗標(長度上限30000字元), model須為`agy models`第一欄slug
109
+ let r3b = await wdi.dispatchAntigravity(prompt, { model: 'gemini-3.6-flash-low' })
110
+ console.log('antigravity:', r3b.ok, r3b.stdout.trim())
111
+ // => antigravity: true 完成
112
+
113
+ //以OpenAI相容API直呼(免CLI免登入), 給baseURL+key+model即可; Zen端點即opencode CLI之自家閘道
114
+ let r3c = await wdi.dispatchApiOpenaiCompat(prompt, {
115
+ baseURL: 'https://apihub.agnes-ai.com/v1',
116
+ key: agnesKeys[0],
117
+ model: 'agnes-2.0-flash',
118
+ })
119
+ console.log('api-openai-compat:', r3c.ok, r3c.code, r3c.stdout.trim())
120
+ // => api-openai-compat: true 200 完成
121
+
122
+ //以供應商條目輪替, 一個條目即一組(kind, model, 可選的key與provider與config), 輪到誰就用誰的CLI與模型
123
+ //opencode支援逐次注入金鑰, 故同一provider之多把金鑰可各成一個條目
124
+ let items = [
125
+ { kind: 'claude', model: 'sonnet' },
126
+ { kind: 'codex', model: 'gpt-5.6-luna', sandbox: 'read-only' },
127
+ { kind: 'opencode', model: 'opencode/deepseek-v4-flash-free', provider: 'opencode', key: opencodeKeys[0], timeoutMs: 180000 },
128
+ { kind: 'opencode', model: 'opencode/deepseek-v4-flash-free', provider: 'opencode', key: opencodeKeys[1], timeoutMs: 180000 },
129
+ { kind: 'opencode', model: 'agnes-ai/agnes-2.0-flash', provider: 'agnes-ai', key: agnesKeys[0], config: configAgnes, timeoutMs: 180000 },
130
+ { kind: 'antigravity', model: 'gemini-3.6-flash-low' },
131
+ ]
132
+ for (let item of items) {
133
+ let r = await wdi.dispatchAi(item.kind, prompt, item)
134
+ console.log('dispatchAi ' + item.model + ':', r.ok, r.stdout.trim())
135
+ // => dispatchAi sonnet: true 完成
136
+ // => dispatchAi gpt-5.6-luna: true 完成
137
+ // => dispatchAi opencode/deepseek-v4-flash-free: true 完成
138
+ // => dispatchAi opencode/deepseek-v4-flash-free: true 完成
139
+ // => dispatchAi agnes-ai/agnes-2.0-flash: true 完成
140
+ // => dispatchAi gemini-3.6-flash-low: true 完成
141
+ }
142
+
143
+ //未知供應商回傳error結果物件, 不會reject
144
+ let r4 = await wdi.dispatchAi('gemini', prompt)
145
+ console.log('invalid kind:', r4.ok, r4.error)
146
+ // => invalid kind: false unknown ai kind: "gemini" (available: opencode, claude, codex, antigravity, api-openai-compat)
147
+
148
+ //prompt非有效字串亦回傳error結果物件
149
+ let r5 = await wdi.dispatchClaude('')
150
+ console.log('invalid prompt:', r5.ok, r5.error)
151
+ // => invalid prompt: false prompt must be a non-empty string
152
+
153
+ //執行失敗時, 由ok、code、error與stderr判斷原因
154
+ //REST路徑之錯誤依HTTP狀態碼分流(401金鑰無效、429限流、5xx服務端), 判別比CLI之stderr字串可靠
155
+ let r6 = await wdi.dispatchApiOpenaiCompat(prompt, {
156
+ baseURL: 'https://apihub.agnes-ai.com/v1',
157
+ model: 'agnes-2.0-flash',
158
+ key: 'sk-invalid-key',
159
+ })
160
+ console.log('invalid key:', r6.ok, r6.code, r6.error, r6.stderr.includes('无效的令牌'))
161
+ // => invalid key: false 401 HTTP 401 true
162
+
163
+ //多供應商自動遞補: providers順序即優先序, 組內keys以游標輪替
164
+ //此例第1把金鑰無效 → 自動換組內下一把成功; 若整組用盡會遞補下一組, 依序往下
165
+ //
166
+ //【id命名】id為游標鍵與日誌標籤, 須區分到「模型」而非只到「廠商」——
167
+ // 取'claude'則日後無法同時掛sonnet與opus, 且日誌看不出實際用了哪個模型;
168
+ // 同一模型經不同路徑(RESTCLI/不同閘道)取得時額度池與故障域各自獨立,
169
+ // 屬不同供應商, 故id須帶上路徑前綴加以區分
170
+ let r7 = await wdi.dispatchAiFallback(prompt, {
171
+ providers: [
172
+ //REST版排前面: 免CLI、快3~5倍, 純文字任務優先走此路
173
+ {
174
+ id: 'agnes:agnes-2.0-flash',
175
+ kind: 'api-openai-compat',
176
+ baseURL: 'https://apihub.agnes-ai.com/v1',
177
+ model: 'agnes-2.0-flash',
178
+ keys: ['sk-invalid-key-demo', agnesKeys[0]], //第1把無效, 示範組內輪替
179
+ },
180
+ //同一個agnes模型之CLI版: 有工具能力但較慢, 額度池亦不同, 屬另一個供應商
181
+ {
182
+ id: 'oc:agnes-ai/agnes-2.0-flash',
183
+ kind: 'opencode',
184
+ model: 'agnes-ai/agnes-2.0-flash',
185
+ provider: 'agnes-ai',
186
+ keys: agnesKeys,
187
+ config: configAgnes, //第三方provider須另給定義
188
+ timeoutMs: 180000,
189
+ },
190
+ { id: 'claude:sonnet', kind: 'claude', model: 'sonnet' },
191
+ { id: 'codex:gpt-5.6-luna', kind: 'codex', model: 'gpt-5.6-luna', sandbox: 'read-only' },
192
+ { id: 'agy:gemini-3.6-flash-low', kind: 'antigravity', model: 'gemini-3.6-flash-low' },
193
+ ],
194
+ budgetMs: 600000,
195
+ onEvent: (ev) => console.log(' event:', ev.type, ev.keyId, ev.error || ''),
196
+ })
197
+ console.log('fallback:', r7.ok, r7.providerId, r7.keyIndex, r7.stdout.trim())
198
+ console.log('tried:', r7.tried.map((x) => `${x.keyId}:${x.outcome}`).join(', '))
199
+ // => event: try agnes:agnes-2.0-flash#0
200
+ // => event: next-key agnes:agnes-2.0-flash#0 HTTP 401
201
+ // => event: try agnes:agnes-2.0-flash#1
202
+ // => event: ok agnes:agnes-2.0-flash#1
203
+ // => fallback: true agnes:agnes-2.0-flash 1 完成
204
+ // => tried: agnes:agnes-2.0-flash#0:next-key, agnes:agnes-2.0-flash#1:ok
205
+
206
+ }
207
+ await test()
208
+ .catch((err) => {
209
+ console.log(err)
210
+ })
211
+ ```
212
+
213
+ #### Options shared by all dispatch functions:
214
+ | key | type | default | description |
215
+ | --- | --- | --- | --- |
216
+ | `exe` | String | 各CLI名稱 | 執行檔名稱或絕對路徑,給予名稱時由系統`PATH`解析 |
217
+ | `model` | String | `''` | 模型ID,未給予則不帶模型旗標,由CLI自行決定 |
218
+ | `extraArgs` | Array | `[]` | 額外命令列旗標字串陣列,接於固定旗標之後 |
219
+ | `timeoutMs` | Integer | `300000` | 逾時毫秒,逾時將強制關閉子進程及其子孫程序;**全套件統一預設**(所有轉接器與各層一致,單一來源`dfTimeoutMs.mjs`),由opt傳入即可覆寫 |
220
+ | `cwd` | String | `process.cwd()` | 子進程工作目錄 |
221
+ | `validate` | String\|Function | `undefined` | `stdout`驗證規則,可用`'nonempty'`、`'json'`、`'min:100'`,多規則以逗號串接,亦可給予`(stdout)=>Boolean` |
222
+ | `maxRetries` | Integer | `0` | 失敗後最大重試次數,遇`ENOENT`或exit code 2視為不可重試而立即中止 |
223
+
224
+ 其餘設定會原樣轉傳給`wsemi`之`execCli`,例如`retryDelayMs`、`maxBuffer`、`onStdout`、`onStderr`、`env`。
225
+
226
+ #### Options only for dispatchOpencode:
227
+ | key | type | default | description |
228
+ | --- | --- | --- | --- |
229
+ | `key` | String | `''` | 該provider之API key,須與`provider`同時給予才會以`OPENCODE_AUTH_CONTENT`注入 |
230
+ | `provider` | String | `''` | `key`所屬provider名稱,須與`model`為同一組 |
231
+ | `config` | Object\|String | `null` | opencode設定內容,將以`OPENCODE_CONFIG_CONTENT`注入,供補上第三方provider之定義 |
232
+ | `agent` | String | `'build'` | opencode代理名稱 |
233
+
234
+ #### Options only for dispatchClaude:
235
+ | key | type | default | description |
236
+ | --- | --- | --- | --- |
237
+ | `skipPermissions` | Boolean | `true` | 是否帶`--dangerously-skip-permissions`旗標,`false`代表保留CLI權限閘門(見上方Security說明) |
238
+
239
+ #### Options only for dispatchCodex:
240
+ | key | type | default | description |
241
+ | --- | --- | --- | --- |
242
+ | `sandbox` | String | `'workspace-write'` | 沙箱模式,可用`'read-only'`、`'workspace-write'`、`'danger-full-access'` |
243
+
244
+ **Windows 診斷:Codex 回報所有命令 `blocked by policy`(Codex ≥0.149)**
245
+
246
+ Codex 0.149 起 Windows 預設走 elevated 沙箱(專用使用者 `CodexSandboxOffline`/`CodexSandboxOnline`+WFP 網路過濾+家目錄 read ACL),**需一次性管理員設定**;設定未完成時 execpolicy 會在 spawn 前拒絕**所有** shell 命令(含 `Get-Content`、`rg` 等唯讀命令),錯誤形如 `CreateProcess { message: "Rejected(\"... blocked by policy\")" }`——Codex 讀檔即是執行 shell,等同完全不能讀檔。
247
+
248
+ - **判別**:`~/.codex/.sandbox/setup_marker.json` 不存在、且 `~/.codex/.sandbox/sandbox.<日期>.log` 只有 `START` 沒有 `SUCCESS` = 設定未完成。
249
+ - **正解**:以互動模式跑一次 `codex` 完成設定(會要求 UAC 提權),完成後 `setup_marker.json` 出現,`read-only`/`workspace-write` 皆可正常執行命令(2026-08-26 於 Codex 0.149.0+Windows 11 26200 實測:設定完成前全擋、完成後五種設定全通)。
250
+ - **臨時繞道**:`extraArgs: ['--config', 'windows.sandbox="unelevated"']`——跳過管理員設定即可執行,但**隔離較弱**(無專用使用者與網路過濾);本套件**刻意不**將此設為 Windows 預設,避免在已完成設定的機器上默默降級沙箱。
251
+ - **靜默失敗警語**:被擋時 Codex 常回「請貼上檔案內容」之合法字串,會通過 `validate: 'nonempty'` 被當成功。凡需 Codex 讀檔的任務,`validate`/工作流 `check` 應要求回覆**引用指定行原文**,不要只驗非空;派長任務前先以「讀一個檔並引用第 N 行」做最小探測。
252
+
253
+ #### Options only for dispatchAntigravity:
254
+ | key | type | default | description |
255
+ | --- | --- | --- | --- |
256
+ | `model` | String | `''` | 須為`agy models`**第一欄之slug**(如`gemini-3.6-flash-low`);agy錯誤訊息列出的是顯示名稱而非slug,勿照抄 |
257
+ | `effort` | String | `''` | `'low'`、`'medium'`、`'high'`,需agy>=1.1.11;建議搭配不帶檔位之基礎slug(如`gemini-3.1-pro`),與帶檔位slug併用且檔位不一致時agy回conflicts錯誤 |
258
+ | `skipPermissions` | Boolean | `true` | 是否帶`--dangerously-skip-permissions`旗標 |
259
+ | `printTimeout` | String | 由`timeoutMs`推導 | agy自身等待上限(如`'10m'`、`'570s'`),預設`timeoutMs`扣30秒緩衝(下限30秒),令CLI先於外層逾時而回報自身錯誤訊息 |
260
+ | `addDirs` | Array | 自動納入cwd | 加入workspace之目錄字串陣列,逐項展開為`--add-dir`。agy以自身scratch目錄為工作區而**不採子進程cwd**,故未給時自動納入有效cwd令檔案可視範圍與其他CLI一致;明示給陣列(含`[]`代表不揭露任何目錄)則完全尊重呼叫端 |
261
+ | `timeoutMs` | Integer | `300000` | 全套件統一預設(恰對齊agy自身print-timeout之5m0s) |
262
+
263
+ 注意:agy之prompt走`--print`旗標而非stdin(agy介面如此),故prompt長度上限30000字元,超過回傳錯誤結果物件(不reject)。
264
+
265
+ #### Choosing CLI or API (判準):
266
+ `kind` 的唯一判準是**這一步需不需要「工具」**:
267
+
268
+ | 這次呼叫要做的事 | 選用 | 理由 |
269
+ | --- | --- | --- |
270
+ | 讀本機檔案、grep、執行指令、抓網頁、寫檔 | **CLI類**:`opencode`/`claude`/`codex`/`antigravity` | CLI本身是agentic harness,自帶完整工具迴圈,呼叫端什麼都不必做 |
271
+ | 摘要、分析、改寫、翻譯、產出JSON(素材皆已在prompt內) | **API類**:`api-openai-compat` | 免安裝免登入,且實測較快(Agnes:API 1~2.5s vs CLI 4~6s) |
272
+
273
+ **API類不支援工具,且不會自建工具迴圈**——實測(2026-08-11)閘道端零內建工具:Zen與Agnes對 `tools:[{type:'web_search'}]` 皆回400並要求 `function.parameters`,即只接受「呼叫端自行定義且自行執行」的function工具。協定層雖支援function calling(Zen之 `nemotron-3-ultra-free` 與Agnes皆實測回 `finish_reason:'tool_calls'`),但工具的定義、執行、錯誤處理與安全邊界全須自行實作維護,等同重造CLI已提供的harness。故模型回 `tool_calls` 時本套件一律以 `TOOL_CALLS_UNSUPPORTED` 回報失敗,不假裝成功。
274
+
275
+ 另注意 `tool_calls` 有**會話束縛**(`tool_call_id` 須於同一條messages串內回填),無法暫停後跨行程外傳給上層agent代跑;工作流各名額(如 `runFanout` 的agents)也只是同行程的async函數呼叫而非獨立agent,故「讓外殼agent提供工具給工作流內的模型使用」在本架構下不成立——**需要工具就選CLI類kind**。
276
+
277
+ **混用才是常態**:同一條 `dispatchAiFallback` 鏈可逐條目混搭kind,工作流各階段亦然——產生候選與整合收斂等純文字階段走API,需要翻閱專案檔案的階段換CLI。
278
+
279
+ **先選對端點型別,再選kind**:同一個閘道的不同模型可能走不同端點,打錯端點會得到 **HTTP 500 而非 404**,極易被誤判為「模型故障」而反覆重試。以 OpenCode Zen 為例([官方端點對照表](https://opencode.ai/docs/zh-tw/zen/),2026-09-03 查證):
280
+
281
+ | 端點 | 對應kind | 該端點之模型(Zen) |
282
+ | --- | --- | --- |
283
+ | `/v1/chat/completions` | `api-openai-compat` | deepseek/glm/kimi/minimax/nemotron/ling/mimo |
284
+ | `/v1/responses` | `api-openai-responses` | muse-spark 系、GPT 系、Grok 系 |
285
+ | `/v1/messages` | 本套件無(改用 `opencode` CLI kind) | Claude 系、Qwen 系 |
286
+ | `/v1/models/<id>` | 本套件無(改用 `opencode` CLI kind) | Gemini |
287
+
288
+ 實測佐證:`muse-spark-1.2/1.3` 走 `/chat/completions` 連續 10 次 500,同金鑰同模型改打 `/responses` 立即 200;且 1.2 於 2026-08-21 曾以 `/chat/completions` 成功——**閘道會事後改路由,「以前能用」不構成「現在該能用」**。完整診斷流程見 [src/providers.mjs](https://github.com/yuda-lyu/w-dispatch-ai/blob/master/src/providers.mjs) 檔頭。
289
+
290
+ #### Options only for dispatchApiOpenaiCompat:
291
+ | key | type | default | description |
292
+ | --- | --- | --- | --- |
293
+ | `baseURL` | String | 必填 | API基底網址,將於尾端接上`/chat/completions` |
294
+ | `model` | String | 必填 | 模型ID(Zen之模型名不帶`opencode/`前綴) |
295
+ | `key` | String | `''` | API key,以`Bearer`置於`Authorization`標頭,省略代表不帶認證 |
296
+ | `system` | String | `''` | system提示詞,置於messages首位 |
297
+ | `body` | Object | `{}` | 額外請求本體(`temperature`、`max_tokens`、`response_format`等),同名鍵覆寫預設 |
298
+ | `headers` | Object | `{}` | 額外請求標頭 |
299
+ | `timeoutMs` | Integer | `300000` | 逾時毫秒,逾時中止請求(含回應串流讀取);全套件統一預設 |
300
+ | `maxRetries` | Integer | `0` | 失敗重試次數;**4xx(429除外)為客戶端錯誤不重試**,429/5xx/網路錯誤/逾時線性退避重試 |
301
+ | `retryDelayMs` | Integer | `5000` | 重試間隔,實際為`retryDelayMs`×次數且上限15000ms |
302
+
303
+ 結果結構對齊execCli:`stdout`為回覆內容、`code`為HTTP狀態碼(網路錯誤/逾時為`null`)、逾時`error`以`TIMEOUT`開頭、驗證失敗為`OUTPUT_VALIDATION_FAILED`——故可直接作為`dispatchAiFallback`條目(`kind: 'api-openai-compat'`,`keys`多金鑰輪替同樣適用)與工作流provider。
304
+
305
+ 另追加`usage`欄位:原始回應之token用量物件**原樣透傳**(無則`null`;驗證失敗等已耗token之失敗亦帶出),經`dispatchAiFallback`(最終結果與`tried`歷程各項)與工作流層(`callAi`結果之`usage`欄)一路流出。CLI類轉接器無可靠來源故**無此欄**——對外提供OpenAI相容API的呼叫端可據此把「真實用量(REST路徑)」與「只能估算(CLI路徑)」分開處理。
306
+
307
+ **`errorType`機器可讀錯誤類別**(全部轉接器與`dispatchAiFallback`/`callAi`之失敗結果皆帶,成功結果無此欄;`error`字串保留不動,兩者並存):
308
+
309
+ | errorType | 意義 | 出現於 |
310
+ | --- | --- | --- |
311
+ | `params` | 參數/設定檢核失敗(進入執行前即被擋) | 全部 |
312
+ | `timeout` | 逾時(execCli強殺或API abort) | 全部 |
313
+ | `spawn` | 子進程無法啟動(ENOENT/ENAMETOOLONG) | CLI類 |
314
+ | `validation` | stdout未過`validate` | 全部 |
315
+ | `exec` | CLI非零離開碼之一般執行失敗(未能再機械細分) | CLI |
316
+ | `http` | HTTP非2xx(`code`為狀態碼) | api類 |
317
+ | `fetch` | 網路層錯誤(DNS/連線拒絕) | api類 |
318
+ | `tool-unsupported` | 模型回tool_calls而api類不支援工具 | api類 |
319
+ | `invalid-response` | 回應缺`choices[0].message.content` | api類 |
320
+ | `aborted` | `shouldStop`中止 | fallback層 |
321
+ | `budget` | 時間預算用盡 | fallback層 |
322
+
323
+ 僅涵蓋**機械可判**者:CLI類之其餘失敗(額度上限/金鑰無效/服務端錯誤,各家字樣不同且隨版本漂移)一律歸`exec`,套件不維護簽章表(與否決金鑰停用清單同一理由)——需細分時以`coolDetect`式注入自判,或依`tried`內之`error`與`stderr`自行決策。
324
+
325
+ #### Options for dispatchAiFallback:
326
+ | key | type | default | description |
327
+ | --- | --- | --- | --- |
328
+ | `providers` | Array | 必填 | 供應商條目陣列,**順序即優先序**。條目除`id`、`keys`外即該次調用之opt,原樣透傳對應轉接器(`kind`、`model`、`exe`、`provider`、`config`、`sandbox`、`timeoutMs`等皆放條目內) |
329
+ | `providers[].id` | String | 條目索引 | 群組識別,游標以此為鍵、亦為日誌標籤;本套件不解讀其內容,命名規則見下方 |
330
+ | `providers[].keys` | Array | `[]` | 同一服務之多把API key,逐次注入輪替(`kind`為`opencode`時須同時給`provider`);省略代表沿用CLI登入狀態 |
331
+ | `providers[].meta` | any | 無 | **保留鍵,保證永不轉傳**轉接器。條目其餘鍵一律原樣轉傳——呼叫端要在條目上掛自有資訊(分類、標籤、註記)一律放`meta`,與轉傳機制永久絕緣(頂層opt與工作流各層規格物件同此約定) |
332
+ | `budgetMs` | Integer | 不限 | 整輪遞補之時間上限,剩餘預算會壓進每次呼叫之`timeoutMs` |
333
+ | `minAttemptMs` | Integer | `20000` | 單次嘗試之最低剩餘預算,低於此值即停止並回報`budget exhausted` |
334
+ | `store` | Object | 行程內記憶體 | 狀態持久化`{get:()=>state, set:(state)=>{}}`,state含`cursors`(逐群組游標)與`cooling`(供應商冷卻時間戳,僅啟用cooldownMs時使用);假定單行程序列調用。跨行程持久化可直接用`createFileStore`;自行實作時**務必整包原封存還**,白名單式挑欄位會在套件擴充state時靜默丟棄新欄位 |
335
+ | `cooldownMs` | Integer | `0`不啟用 | 供應商冷卻視窗:條目(限有明給id者)遭遇**限流(HTTP 429,僅api類可偵測)或逾時(TIMEOUT)**後,於視窗內之後續呼叫中被**移至鏈尾(只降序不移除)**——前面全敗時仍會被嘗試、任一次成功立即解除,故不存在把已恢復服務冰住的問題。多階段工作流可大幅省去逐階段重踩已失效供應商的成本(使用端實測107s→15s)。注意啟用時providers順序會被暫時重排,此即機制目的 |
336
+ | `coolDetect` | Function | | 冷卻觸發之**注入判定**`(r)=>Boolean`,收完整失敗結果(含`stdout`、`stderr`、`code`、`error`),回傳`true`即視同冷卻觸發(內建429/TIMEOUT觸發不受影響)。CLI類限流埋在stderr且各家字樣不同、隨版本漂移,**簽章表由觀察到字樣的呼叫端維護**,如`(r) => /FreeUsageLimitError/i.test(r.stderr \|\| '')`;漏判僅退回現狀(每階段重探一次)、誤判也只是降尾非移除,兩邊代價都有上限。僅`cooldownMs>0`時有效;回調拋出例外視同`false` |
337
+ | `shouldStop` | Function | 無 | 中止判定`()=>Boolean`,於**每次嘗試之間**檢查,`true`即停止遞補回報`ABORTED`——供成果已無人接收時(如server端客戶端斷線)止損,把「斷線後仍空耗整條鏈」縮成「至多再耗當前這一家」。**不中止進行中之嘗試**(不殺子進程/不斷開請求,見Known design notes)。經工作流層原樣轉傳:中止後每個後續呼叫進門即回`ABORTED`,整條工作流自然快速收束,無須逐層處理;回調拋出例外視同`false` |
338
+ | `meta` | any | 無 | 保留鍵,同`providers[].meta`,永不轉傳 |
339
+ | `onEvent` | Function | 無 | 事件回調`(ev)=>{}`,`ev.type`為`'try'`、`'ok'`、`'next-key'`、`'skip-group'`、`'budget-out'`、`'aborted'`、`'cooled'`(冷卻觸發,帶`error`與`cooldownMs`,僅啟用cooldownMs時出現);失敗事件另帶`errorType`、`stdout`(被拒回覆)與`stderr`(錯誤輸出,皆已截斷)供診斷;回調拋出例外不影響主流程 |
340
+
341
+ 頂層其餘設定(`timeoutMs`、`validate`、`maxRetries`等)為各attempt之共用預設,條目可覆寫;`maxRetries`建議維持預設`0`,韌性交給換家而非重試同一家。
342
+
343
+ **條目 `id` 之命名規則**(呼叫端負責設計,本套件只當作不透明字串使用):
344
+
345
+ `id` 在套件內只有兩個用途——游標的物件鍵(`state.cursors[id]`)與日誌標籤(`providerId`、`keyId` = `` `${id}#${keyIndex}` ``)。不查表、不比對、無格式要求,故「什麼算同一個供應商」由呼叫端定義。
346
+
347
+ | 規則 | 說明 |
348
+ | --- | --- |
349
+ | **區分到「模型」而非只到「廠商」** | ❌ `id: 'claude'` — 日後無法同時掛 sonnet 與 opus,日誌也看不出用了哪個模型<br>✅ `id: 'claude:sonnet'`、`id: 'claude:opus'` |
350
+ | **同一模型經不同路徑時須帶路徑** | 同一個 laguna 可經 Poolside 官方 REST、OpenRouter、opencode CLI 三條路,額度池與故障域各自獨立,屬三個供應商:<br>`'poolside:laguna-s-2.1'`、`'or:poolside/laguna-s-2.1:free'`、`'oc:poolside/poolside/laguna-s-2.1'` |
351
+ | **務必給、務必唯一** | 未給時回退為**陣列索引字串**——索引是位置不是身分,日後於鏈中插入條目會令後續條目繼承他人的游標進度(輪替張冠李戴)。兩個條目同 `id` 則共用同一游標且日誌無法區分。 |
352
+
353
+ **同一組金鑰用於多個條目時**(例如某模型的 CLI 版與 REST 版共用同一批金鑰),各條目游標**獨立**:兩者各自從游標起點輪替,同一把金鑰可能被連續使用而另一把閒置。要共享輪替進度就給**相同** `id`(代價:日誌無法區分兩者);要能區分就分開命名(代價:額度不均攤)。此取捨由呼叫端依實際需求決定。
354
+
355
+ **失敗分流規則**:
356
+ | 失敗 | 判定 | 處置 |
357
+ | --- | --- | --- |
358
+ | 逾時 | `error`以`TIMEOUT`開頭 | 整組跳過 |
359
+ | 執行檔不存在 | `error`含`ENOENT` | 整組跳過 |
360
+ | 參數錯誤 | `code === 2` | 整組跳過 |
361
+ | 輸出未過驗證 | `error === 'OUTPUT_VALIDATION_FAILED'` | 整組跳過 |
362
+ | kind無效 | `error`以`unknown ai kind`開頭 | 整組跳過 |
363
+ | 其餘(含額度上限、金鑰無效、服務回錯) | — | 換組內下一把 |
364
+
365
+ 整組跳過的理由:同組各金鑰共用同一`exe`與`model`,這些失敗換金鑰必然再敗,逐把嘗試純屬空耗。其餘失敗一律換下一把、**不記憶不停用**——額度視窗形態多樣(5小時滾動、逐時、逐日),停用清單會把已恢復的金鑰閒置,而重探的代價僅一次快速失敗;跨次執行僅記憶游標(成功後推進,令額度在多把金鑰間均攤)。
366
+
367
+ #### Result of dispatch functions:
368
+ ```alias
369
+ //成功
370
+ {
371
+ ok: true,
372
+ stdout: '完成\r\n',
373
+ stderr: '\x1b[0m\r\n> build · deepseek-v4-flash-free\r\n\x1b[0m\r\n',
374
+ code: 0,
375
+ error: '',
376
+ durationMs: 11742,
377
+ pid: 9800,
378
+ attempts: 1,
379
+ }
380
+
381
+ //CLI執行失敗, 本套件各函數皆不reject
382
+ {
383
+ ok: false,
384
+ stdout: '',
385
+ stderr: '\x1b[0m\r\n> build · deepseek-v4-flash-free\r\n\x1b[0m\r\n\x1b[91m\x1b[1mError: \x1b[0mInvalid API key.\r\n',
386
+ code: 1,
387
+ error: 'Exit code 1',
388
+ durationMs: 3049,
389
+ pid: 15208,
390
+ attempts: 1,
391
+ }
392
+
393
+ //參數檢核失敗, 未實際啟動子進程故無pid
394
+ {
395
+ ok: false,
396
+ stdout: '',
397
+ stderr: '',
398
+ code: null,
399
+ error: 'prompt must be a non-empty string',
400
+ durationMs: 0,
401
+ attempts: 0,
402
+ }
403
+ ```
404
+
405
+ #### Result of dispatchAiFallback:
406
+ 於execCli既有欄位外追加:
407
+ ```alias
408
+ {
409
+ // ...ok, stdout, stderr, code, error, durationMs, attempts, pid...
410
+ providerId: 'agnes:agnes-2.0-flash', //實際使用之群組(即條目id)
411
+ keyIndex: 1, //實際使用之金鑰索引, 無keys時為null
412
+ kind: 'api-openai-compat',
413
+ model: 'agnes-2.0-flash',
414
+ tried: [ //完整嘗試歷程, 成功時亦回傳; 失敗項另含stdout(被拒回覆)與stderr(錯誤輸出, 皆已截斷)供診斷
415
+ { providerId: 'agnes:agnes-2.0-flash', keyIndex: 0, keyId: 'agnes:agnes-2.0-flash#0', outcome: 'next-key', error: 'HTTP 401', durationMs: 105 },
416
+ { providerId: 'agnes:agnes-2.0-flash', keyIndex: 1, keyId: 'agnes:agnes-2.0-flash#1', outcome: 'ok', durationMs: 1161 },
417
+ ],
418
+ }
419
+ ```
420
+
421
+ #### dispatchAiWkf (workflow factory):
422
+ 注入一次provider定義表(名稱`dispatchAiFallback`條目)與共用預設,之後以名稱宣告工作流;名稱查無定義即回報錯誤(fail fast)。回覆經寬鬆JSON解析(`extractJsonLoose`)+自訂`check`驗證,非法回覆視為該家失敗而自動遞補;預設於prompt前掛「禁止建檔」約束(`promptPrefix: ''`可關閉);措辭豁免唯讀查閱——codex以shell讀檔,一律禁指令會令其無法讀取專案檔案且靜默回拒答(2026-08-13實測)。
423
+
424
+ ```alias
425
+ let wkf = wdi.dispatchAiWkf({
426
+ providers: {
427
+ 'zen:deepseek-v4-flash-free': { kind: 'api-openai-compat', baseURL: 'https://opencode.ai/zen/v1', model: 'deepseek-v4-flash-free', keys: [...] },
428
+ 'claude:sonnet': { kind: 'claude', model: 'sonnet' },
429
+ 'codex:gpt-5.6-luna': { kind: 'codex', model: 'gpt-5.6-luna' },
430
+ },
431
+ defaults: { timeoutMs: 300000 },
432
+ })
433
+
434
+ //單一名額: 主模型+自帶遞補鏈
435
+ let r1 = await wkf.callAi('...prompt...', { spec: { use: 'zen:deepseek-v4-flash-free', fallback: ['claude:sonnet'] }, check: (j) => !!j.essence })
436
+
437
+ //Fanout: 並行多開執行 → 單點整合收斂(候選未達minCandidates時以首位候選為成果不硬整合)
438
+ //check為共用預設; 名額規格與integrate可各自帶check(候選與終稿判準常不同, 如終稿須含固定段落)
439
+ let r2 = await wkf.runFanout({ task, agents: [{ use: 'zen:deepseek-v4-flash-free', fallback: ['claude:sonnet'] }, { use: 'claude:sonnet' }], integrate: { use: 'codex:gpt-5.6-luna' }, check })
440
+
441
+ //RolePipeline: 多角色串行鏈, 各階段可自帶AI/遞補/檢核, prompt收ctx={input,prev,results,index}
442
+ let r3 = await wkf.runRolePipeline({ input, stages: [{ id: 'draft', use: 'claude:sonnet', prompt: (ctx) => `...` }, { id: 'audit', use: 'codex:gpt-5.6-luna', prompt: (ctx) => `...${JSON.stringify(ctx.prev)}` }] })
443
+
444
+ //FanoutPipeline: Fanout成果接RolePipeline(品質天花板組合)
445
+ let r4 = await wkf.runFanoutPipeline({ task, agents, integrate, stages, check })
446
+ ```
447
+
448
+ 各工作流皆部分接受:個別名額/階段失敗不炸整輪,已完成成果完整回傳(`candidates`/`results`+`failedStage`),可只重跑失敗段。
449
+
450
+ #### Timeout 總覽(各層預設、行為與調整方式):
451
+
452
+ **一句話**:全套件單一預設 **`300000`(5分鐘,單一來源 [src/dfTimeoutMs.mjs](https://github.com/yuda-lyu/w-dispatch-ai/blob/master/src/dfTimeoutMs.mjs))**——不論直接呼叫轉接器、或經 `dispatchAiFallback`/工作流,「單次AI嘗試」的逾時都是它;工作流本身**沒有**獨立的總時限參數(總時長=結構×單次,見下方公式)。
453
+
454
+ **階梯結構**(由細至粗,數值須嚴格遞增):
455
+
456
+ ```alias
457
+ agy --print-timeout(自動=timeoutMs−30s)
458
+ < timeoutMs(單次嘗試,統一預設300000)
459
+ < budgetMs(單一名額之遞補鏈總預算,預設null不限)
460
+ < 工作流總時長(無獨立參數,由結構推導)
461
+ ```
462
+
463
+ **各參數一覽**:
464
+
465
+ | 參數 | 作用範圍 | 預設 | 逾時後果/備註 |
466
+ | --- | --- | --- | --- |
467
+ | `timeoutMs` | **單次AI嘗試**,所有kind一致(直接呼叫與工作流內皆同一數字) | `300000` | CLI強殺子進程樹/API中止請求;`error`以`TIMEOUT`開頭 → fallback視為**與金鑰無關**,整組跳過(不逐把空耗) |
468
+ | `printTimeout` | 僅antigravity,agy自身等待上限 | 自動=timeoutMs−30s | 令CLI先於外層逾時,錯誤訊息來自agy自身;一般無須手動設 |
469
+ | `budgetMs` | `dispatchAiFallback`整輪遞補(=工作流的一個名額/階段) | `null`不限 | 有值時剩餘預算會壓進每次嘗試的timeoutMs;用盡回`budget exhausted` |
470
+ | `minAttemptMs` | 搭配budgetMs的開工門檻 | `20000` | 剩餘預算低於此值即不再開工;**無budgetMs時不作用** |
471
+ | 工作流總時長 | `runFanout`/`runRolePipeline`/`runFanoutPipeline` | 無(刻意) | 由結構推導,要上限就設各名額的`budgetMs` |
472
+
473
+ **工作流總時長公式**(每次嘗試≤timeoutMs;K=遞補鏈組數、M=階段數):
474
+
475
+ | 工作流 | 正常情況 | 最壞情況(多家連環卡死) |
476
+ | --- | --- | --- |
477
+ | `callAi`單一名額 | 首家耗時 | K×timeoutMs(逾時型失敗每組只燒一次即跳組;額度型失敗為秒級) |
478
+ | `runFanout` | 最慢名額+整合名額(agents**並行**) | ≈2×K×timeoutMs |
479
+ | `runRolePipeline` | Σ各階段(**序列**) | ≈M×K×timeoutMs |
480
+ | `runFanoutPipeline` | 上兩者相加 | ≈(2+M)×K×timeoutMs |
481
+
482
+ 量級感受:內建providers 9條全上陣時,一個名額最壞9×300s=45min;3階段RolePipeline最壞約2.25小時(正常情況為秒級~分鐘級,最壞只在多家連環卡死時發生)。
483
+
484
+ **外部調整四層**(細者覆蓋粗者,全部免改套件程式):
485
+ 1. **全域**:`dispatchAiWkf({ defaults: { timeoutMs, budgetMs, minAttemptMs } })`
486
+ 2. **單工作流**:`runFanout({ callOpt: { timeoutMs... } })`
487
+ 3. **單階段/名額**:stage/agent 規格上直接給 `timeoutMs`/`budgetMs`
488
+ 4. **單條目**:provider 條目給 `timeoutMs`(如已知會卡死之供應商給小蓋子,卡死成本從名額預算縮為該蓋子)
489
+
490
+ **三種常用設定**:
491
+
492
+ ```alias
493
+ //1. 簡單任務(秒級~分鐘級): 什麼都不用設, 全走統一預設300000
494
+
495
+ //2. 要給工作流總上限: 設每名額budgetMs(序列工作流總上限≈Σ各階段budget; fanout≈名額+整合)
496
+ let wkf = wdi.dispatchAiWkf({ providers: table, defaults: {
497
+ budgetMs: 900000, //每名額至多15min 3階段RolePipeline總上限≈45min
498
+ minAttemptMs: 60000, //剩餘不足1min就不再開工
499
+ } })
500
+
501
+ //3. 複雜任務(單一AI工作約15min, fallback須能走到最末):
502
+ let wkf2 = wdi.dispatchAiWkf({ providers: table, defaults: {
503
+ timeoutMs: 1200000, //20min=15min工作+33%餘裕(太緊會殺掉合法執行)
504
+ minAttemptMs: 1200000, //剩餘不足完整視窗即不開工——開了也不可能完成, 純浪費
505
+ budgetMs: 4800000, //鏈長K×timeoutMs(K=4→80min): 逾時每組只燒一次即跳組, 故保證走得到最末; 無外部時限可null
506
+ } })
507
+ ```
508
+
509
+ #### providers.mjs(內建供應商定義檔):
510
+ [src/providers.mjs](https://github.com/yuda-lyu/w-dispatch-ai/blob/master/src/providers.mjs) 收錄各供應商條目(CLI版與REST版),金鑰以 `envVar` 間接引用(機密只放 `.env`),經 `resolveProviders` 展開後即可直接使用或以 `pick` 自選。
511
+
512
+ **zen免費模型清單為「更新日快照」**:`zen:` 系收錄截至 2026-08-21 `GET /zen/v1/models` 查得之**全部**免費模型(`*-free`),不做好用篩選——新模型會上線、舊模型可能下架或限流,**不保證清單即為當前最新可用狀態**;且各模型能力/速度/輸出習慣差異極大(各條目註解記錄已測特性,如批次涵蓋率、實測耗時),由呼叫端自行評估選用。暫時打不通的條目依本套件哲學保留不移除:恢復的偵測就是下次再打一次,`fallback`/`cooldownMs` 即為此而生。
513
+
514
+ ```alias
515
+ import wdi from 'w-dispatch-ai'
516
+
517
+ //金鑰放.env(OPENCODE_KEYS/AGNES_KEYS/POOLSIDE_KEYS, 逗號分隔多把), 以readEnvFile讀成物件——
518
+ //不用process.loadEnvFile: 那會把金鑰塞進process.env, 多專案並行時互相覆蓋
519
+ let env = wdi.readEnvFile('./.env')
520
+
521
+ //全取: envVar → keys, 缺環境變數之條目自動停用並列入skipped
522
+ let { providers, table, skipped } = wdi.resolveProviders(wdi.providers, { env })
523
+
524
+ //自選: pick順序即遞補優先序; providers餵dispatchAiFallback, table餵dispatchAiWkf
525
+ let picked = wdi.resolveProviders(wdi.providers, { env, pick: ['agnes:agnes-2.0-flash', 'claude:sonnet'] })
526
+ let r = await wdi.dispatchAiFallback(prompt, { providers: picked.providers, timeoutMs: 1200000 })
527
+ let wkf = wdi.dispatchAiWkf({ providers: picked.table, defaults: { timeoutMs: 1200000 } })
528
+
529
+ //後處理(選用): exes逐kind注入CLI執行檔絕對路徑(Windows排程session 0之PATH常缺npm全域目錄),
530
+ //patch逐id淺合併覆寫任意欄位; 兩者於函數內施作, providers與table同源產出必然一致
531
+ let p2 = wdi.resolveProviders(wdi.providers, {
532
+ env,
533
+ pick: ['claude:sonnet', 'codex:gpt-5.6-luna'],
534
+ exes: { claude: 'C:/Users/x/.local/bin/claude.exe' },
535
+ patch: { 'claude:sonnet': { timeoutMs: 360000 } },
536
+ })
537
+
538
+ //pick打錯字時missing附拼寫提示hints(最接近之可用id), 可直接組出可定位的錯誤訊息
539
+ let pm = wdi.resolveProviders(wdi.providers, { env, pick: ['poolside/laguna-s-2.1'] })
540
+ if (pm.missing.length > 0) {
541
+ throw new Error(`unknown provider id(s): ${pm.missing.map((id) => `${id} (did you mean ${pm.hints[id]}?)`).join(', ')}`)
542
+ }
543
+ ```
544
+
545
+ **自帶條目(新模型上線快於套件發版時)**:`resolveProviders` 第一參數就是普通條目陣列,安裝端把自訂條目**合併進輸入**再傳入即可,同 id 時以自訂者覆蓋內建:
546
+
547
+ ```alias
548
+ let extra = [{ id: 'zen:some-new-model-free', model: 'some-new-model-free', kind: 'api-openai-compat', envVar: 'OPENCODE_KEYS', baseURL: 'https://opencode.ai/zen/v1', body: { max_tokens: 8192 } }]
549
+ let merged = [...wdi.providers.filter((p) => !extra.some((e) => e.id === p.id)), ...extra]
550
+ let resolved = wdi.resolveProviders(merged, { env, pick: [...] })
551
+ ```
552
+
553
+ **警語:動「輸入」、不要動「回傳」**——把條目 push 進回傳的 `providers` 陣列不會同步進 `table`,兩者當場分歧;合併輸入再呼叫則兩種輸出同源產出、必然一致。另同 id 重複條目屬設定錯誤(共用游標、日誌無法區分),合併時務必如上例先濾再接。
554
+
555
+ **配套工具**(皆為選用,深層引入或由聚合物件取用):
556
+
557
+ | 工具 | 用途 |
558
+ | --- | --- |
559
+ | `createFileStore({ dir })` | `dispatchAiFallback`之`store`的檔案持久化——排程任務每次執行都是新行程,記憶體游標/冷卻每次歸零;本實作採**排除式passthrough**(state原封存還,僅剔自用欄位`at`),日後套件擴充state欄位自動相容(殷鑑:白名單store曾把1.0.7新增的`cooling`靜默丟棄) |
560
+ | `createUsageCounter({ dir })` | 逐日逐鍵用量計帳,`onEvent`直接掛進dispatch即於`try`事件記帳;**純觀測絕不據以節流**(額度視窗形態多樣,臆測門檻擋自己的呼叫等同拿猜測當事實);排程環境務必注入`getDate`錨定時區 |
561
+ | `budgetFor(chain)` | 遞補鏈走滿全鏈之時間預算(Σ各條目`timeoutMs`,未帶者以統一預設300000計);與外部排程硬上限取小者交`budgetMs` |
562
+ | `salvageTruncatedArray(text)` | 截斷JSON陣列之前段搶救(救回的每個元素皆完整合法);**不併入預設解析**——「判失敗換家重產」與「搶救前段部分接受」是同一問題的兩種合法策略,組成自訂`parse`注入即可 |
563
+ | `NO_SIDE_EFFECT` | 防副作用prompt前綴之單一來源(措辭含唯讀查閱豁免——codex以shell讀檔,一律禁指令等同禁讀檔);工作流`callAi`預設自動掛上,直呼`dispatchAiFallback`者自行前綴 |
564
+
565
+ #### 訂閱額度查詢(quota):`getQuotaClaude`/`getQuotaCodex`/`getQuotaAntigravity`
566
+
567
+ 查詢**本機各 CLI 當前登入帳號**之訂閱額度窗口(5 小時/7 天/模型別 7 天等),三家回傳統一結構。所在目錄 `src/quota/`,深層引入 `w-dispatch-ai/src/quota/getQuotaClaude.mjs` 或由聚合物件取用。
568
+
569
+ ```alias
570
+ import wdi from 'w-dispatch-ai'
571
+
572
+ let r = await wdi.getQuotaClaude('me@example.com') //給email即「比對」本機登入帳號; 給''則不比對直接回報
573
+ console.log(r.ok, r.matched, r.email, r.plan)
574
+ r.windows.forEach((w) => console.log(w.key, w.label, w.usedPercent, w.remainingPercent, w.resetAt, w.active, w.severity))
575
+ // session 5小時 3 97 2026-... false normal
576
+ // weekly_all 7天 13 87 2026-... false normal
577
+ // weekly_scoped 7天(Fable) 15 85 2026-... true normal ← 帶模型別、實際會先觸頂的窗口
578
+
579
+ let c = await wdi.getQuotaCodex() // source: 'codex-app-server'(主) 或 'chatgpt-wham-usage-api'(備援)
580
+ let a = await wdi.getQuotaAntigravity() // source: 'agy-print-usage'; windows之scope為群組名(Gemini Models / Claude and GPT models)
581
+ ```
582
+
583
+ **email 的真實角色是「比對」不是「查詢」**:三家額度皆綁定本機該 CLI 當前登入之憑證,沒有任何一家提供「給 email 查任意帳號」的公開介面(那會是帳號列舉漏洞)。故 `email` 參數用來核對本機實際登入者——一機多帳號時(實測本機 claude/codex/agy 分屬不同 gmail)不核對就會把甲帳號的額度當成乙的。不符時 `ok:false`、`matched:false`、`errorType:'account'`,但額度資料**仍回傳**(已取得,丟棄只是浪費)。
584
+
585
+ **結果結構**:`{ ok, provider, email, matched, plan, planTier, source, windows[], credits, raw, error, errorType, durationMs }`;每個窗口 `{ key, label, windowSeconds, usedPercent, remainingPercent, resetAt(ISO), resetAfterSeconds, scope, active, severity }`。`severity` 供應商有給即用,未給則由 `usedPercent` 推導(用罄 `exhausted`/其餘 `normal`)。
586
+
587
+ **`key` 是各家原生識別、刻意不統一;跨家比較用 `windowSeconds` 配 `scope`**。統一的是信封(上列欄位)——三家原始欄位確實互異(Claude 給已用 % `utilization`+ISO `resets_at`;Codex 給 `usedPercent`+unix 秒 `resetsAt`+`windowDurationMins`;agy 給**剩餘**比例 `remaining_fraction`+ISO `reset_time`),全部正規化為 `usedPercent`/`resetAt`/`resetAfterSeconds`/`windowSeconds`。但 `key` 原樣透傳各家自己的窗口識別:
588
+
589
+ | 供應商 | `key` 值 | 來源 |
590
+ | --- | --- | --- |
591
+ | Claude | `session`/`weekly_all`/`weekly_scoped` | Anthropic `limits[].kind` 原值(回退舊欄位時為 `five_hour`/`seven_day_opus` 等欄位名) |
592
+ | Codex | `primary`/`secondary`;巢狀限額為 `code_review:primary`、`<limitId>:primary` | app-server `rateLimits.primary`/`.secondary` 物件名;巢狀者由套件組唯一鍵 |
593
+ | agy | `gemini-5h`/`gemini-weekly`/`3p-5h`/`3p-weekly` | agy `buckets[].id` 原值 |
594
+
595
+ 保留原生值可回溯 `raw`,也不必為求一致把 agy 的 4 桶 2 群組硬壓成 2 個。要「跨家找 5 小時窗口」請用 `windowSeconds === 18000`(7 天為 `604800`)配 `scope`,**不要比對 `key` 字串**。
596
+
597
+ **errorType(quota 專用詞彙,與轉接器之 errorType 分開)**:`params`/`notfound`(憑證或執行檔不存在、未登入)/`unsupported`(API key 或雲端模式無訂閱額度、agy 版本過舊)/`auth`(權杖被拒)/`forbidden`/`ratelimit`(查詢端點自身之 429,非訂閱額度用罄)/`http`/`parse`/`timeout`/`network`/`toolarge`/`account`(帳號不符)/`exit`/`rpc`。
598
+
599
+ | 設計要點 | 說明 |
600
+ | --- | --- |
601
+ | **唯讀憑證,刻意不刷新權杖** | Anthropic 的 refresh token 每次使用即輪替並作廢前一枚;監控程式若自行刷新而不寫回,Claude Code 存檔的權杖立即失效、使用者被迫重登;寫回則與 Claude Code 競爭同一檔。故 401 時的正確指引是「執行一次 claude 讓它自行刷新」,**不需重新登入**(存檔的 refresh token 仍有效,本機實測期限約登入後 30 天,只是要由 Claude Code 去用它)——錯誤訊息已內建此指引 |
602
+ | **codex 主路徑走第一方協定** | `codex app-server --stdio` JSON-RPC(認證、刷新、多帳號全由 codex 自理,本套件不碰 token),失敗才退回 chatgpt.com 之內部端點(欄位可能變動,對映邏輯獨立於 `fromCodexUsageHttp` 以便離線 fixture 驗證) |
603
+ | **agy 版本閘門** | 1.1.11 之前 `-p "/usage"` 會被當一般 prompt 起一個 agent turn(耗額度、留對話),故先以 `agy --version` 把關,過舊回 `unsupported` 而不冒險執行;另有 num_turns>0 之事後防呆 |
604
+ | **機密不入 log** | HTTP 錯誤訊息中之權杖與帳號 ID 一律先遮蔽(`[REDACTED]`)再截短;回應本文有 1MB 上限防異常頁撐爆 |
605
+ | **可測性/可注入** | `opt.env`(隔離本機環境變數)、`opt.usageUrl`/`opt.profileUrl`(指向假伺服器或企業代理)、`opt.configDir`/`opt.codexHome`(僅 codex 備援路徑用,app-server 主路徑之子進程繼承本進程的 `CODEX_HOME`)/`opt.exe`;額度查詢之預設逾時為 20 秒(`dfQuotaTimeoutMs`,與 agent 推論之 300 秒分開),agy 因啟動較慢預設 60 秒 |
606
+ | **需 wsemi ≥ 1.8.85** | codex 主路徑依賴其 `execCliJsonRpc`(stdio JSON-RPC 會話管理) |
607
+
608
+ **內建CLI條目之防寫機制對照**(內建清單定位為唯讀調用,各家CLI條目皆自帶機械防寫;需要寫入能力時於條目或呼叫時覆寫該欄位即可。api類為純文字生成天然無寫檔能力,不在此列):
609
+
610
+ | kind | 條目防寫欄位 | 機制 | 實測依據 |
611
+ | --- | --- | --- | --- |
612
+ | `opencode` | `config.permission: { edit/write/bash: 'deny' }` | opencode設定層拒絕編輯/寫檔/執行指令 | 2026-08 實測 |
613
+ | `claude` | `extraArgs: ['--disallowedTools', 'Write,Edit,NotebookEdit,Bash']` | CLI停用寫入類工具 | 2026-08 實測 |
614
+ | `codex` | `sandbox: 'read-only'` | Codex沙箱唯讀模式 | 2026-08-26 於 Codex 0.149.0 實測可執行唯讀命令;前提是 Windows elevated 沙箱之一次性設定已完成,否則所有命令 `blocked by policy`(診斷見「Options only for dispatchCodex」) |
615
+ | `antigravity` | `skipPermissions: false` | 保留agy權限閘門(不送`--dangerously-skip-permissions`) | 2026-08-15 canary實測:無此鎖時要求建檔**會真的落地**;`false`之下寫入被擋且**不卡逾時**(6.4s正常返回)、唯讀工具照常 |
616
+
617
+ 注意agy被權限閘門擋下寫入時回`ok: true`且**stdout為空**(靜默拒絕非報錯):工作流層無害(空回覆過不了validate而自動遞補),但直接呼叫`dispatchAntigravity`者須以「空輸出」判別被擋,不能只看`ok`。另提示詞層的`NO_SIDE_EFFECT`前綴是「請求」不是「強制」,機械防寫以上表欄位為準。
618
+
619
+ #### Known design notes:
620
+ - `package.json`**刻意不設**`exports`欄位:wsemi與w-*系列皆為自有套件,呼叫端以按需深層引入(`w-dispatch-ai/src/xxx.mjs`)為既定路線;增設exports會封死此路徑,勿加。
621
+ - `dispatchAi(kind, prompt, opt)`會把整個`opt`原樣轉傳對應轉接器,該轉接器用不到的鍵(例如輪替條目物件內的`kind`)會被忽略,故「供應商條目物件直接當`opt`」是預期用法;`dispatchAiFallback`之providers條目沿用同一約定。
622
+ - `dispatchAiFallback`為單向單輪:全數群組試畢即回傳最後一筆失敗結果與`tried`歷程,不回頭重試已敗的組。跨次執行僅記憶游標,不設金鑰停用清單(理由見上方失敗分流說明);需跨次跳過特定金鑰時,由呼叫端依`tried`/`onEvent`內之`error`與`stderr`自行決策。
623
+ - `shouldStop`**只在嘗試邊界檢查,不中止進行中之嘗試**(不殺子進程、不斷開HTTP請求):進行中嘗試之強制中止需侵入execCli層與各轉接器,屬已知設計取捨——最小版已把斷線後的損失從「整條鏈」縮成「至多再耗當前這一家」;如有實測場景證明不足再議完整版。
624
+ - CLI類限流簽章**不進套件**:各家stderr字樣不同且隨CLI版本漂移,套件維護簽章表等同養一個自己驗證不了的分類器(與否決金鑰停用清單同一理由)。偵測經`coolDetect`依賴注入,由觀察到字樣的呼叫端維護。
625
+ - `dispatchOpencode`之`key`與`provider`須同時給予才會注入金鑰;只給其一(或範例中`.env`缺鍵導致`key`為`undefined`)時不會報錯,而是靜默沿用CLI既有登入狀態。
626
+ - 範例中之`process.loadEnvFile`需Node.js >= 20.12,僅範例使用,套件本身無此限制。
627
+ - `config`以`OPENCODE_CONFIG_CONTENT`注入後,與使用者既有`opencode.jsonc`為覆蓋或合併關係未經實測確認;建議`config`內含該次調用所需之完整provider定義,不依賴與既有設定檔之合併行為。