w-dispatch-ai 1.0.2 → 1.0.3

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