w-dispatch-ai 1.0.7 → 1.0.8

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 (51) hide show
  1. package/README.md +27 -1
  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 +4 -3
  5. package/docs/adapters.mjs.html +2 -2
  6. package/docs/castPintOr.mjs.html +109 -0
  7. package/docs/dfTimeoutMs.mjs.html +2 -2
  8. package/docs/dispatchAi.mjs.html +14 -13
  9. package/docs/dispatchAiFallback.mjs.html +156 -97
  10. package/docs/dispatchAiWkf.mjs.html +2 -2
  11. package/docs/dispatchAntigravity.mjs.html +10 -14
  12. package/docs/dispatchApiOpenaiCompat.mjs.html +50 -64
  13. package/docs/dispatchClaude.mjs.html +8 -13
  14. package/docs/dispatchCodex.mjs.html +8 -13
  15. package/docs/dispatchOpencode.mjs.html +8 -13
  16. package/docs/getCliArgs.mjs.html +2 -2
  17. package/docs/getErrorResult.mjs.html +12 -5
  18. package/docs/getErrorType.mjs.html +171 -0
  19. package/docs/global.html +1275 -88
  20. package/docs/index.html +2 -2
  21. package/docs/resolveProviders.mjs.html +2 -2
  22. package/docs/wkf_callAiWithFallback.mjs.html +14 -10
  23. package/docs/wkf_extractJsonLoose.mjs.html +2 -2
  24. package/docs/wkf_runFanout.mjs.html +8 -8
  25. package/docs/wkf_runFanoutPipeline.mjs.html +14 -18
  26. package/docs/wkf_runRolePipeline.mjs.html +5 -4
  27. package/package.json +1 -1
  28. package/src/WDispatchAi.mjs +2 -1
  29. package/src/castPintOr.mjs +37 -0
  30. package/src/dispatchAi.mjs +12 -11
  31. package/src/dispatchAiFallback.mjs +154 -95
  32. package/src/dispatchAntigravity.mjs +8 -12
  33. package/src/dispatchApiOpenaiCompat.mjs +48 -62
  34. package/src/dispatchClaude.mjs +6 -11
  35. package/src/dispatchCodex.mjs +6 -11
  36. package/src/dispatchOpencode.mjs +6 -11
  37. package/src/getErrorResult.mjs +10 -3
  38. package/src/getErrorType.mjs +99 -0
  39. package/src/wkf/callAiWithFallback.mjs +12 -8
  40. package/src/wkf/runFanout.mjs +6 -6
  41. package/src/wkf/runFanoutPipeline.mjs +12 -16
  42. package/src/wkf/runRolePipeline.mjs +3 -2
  43. package/test/tools/fakeServerForApiTest.mjs +4 -1
  44. package/test/unit-callAiWithFallback.test.mjs +24 -0
  45. package/test/unit-castPintOr.test.mjs +28 -0
  46. package/test/unit-dispatchAiFallback.test.mjs +137 -0
  47. package/test/unit-dispatchApiOpenaiCompat.test.mjs +39 -0
  48. package/test/unit-getErrorResult.test.mjs +13 -1
  49. package/test/unit-getErrorType.test.mjs +36 -0
  50. package/test/unit-runFanout.test.mjs +14 -0
  51. package/test/unit-runRolePipeline.test.mjs +12 -0
@@ -8,6 +8,7 @@ import ispint from 'wsemi/src/ispint.mjs'
8
8
  import cint from 'wsemi/src/cint.mjs'
9
9
  import dispatchAi from './dispatchAi.mjs'
10
10
  import getErrorResult from './getErrorResult.mjs'
11
+ import castPintOr from './castPintOr.mjs'
11
12
  import dfTimeoutMs from './dfTimeoutMs.mjs'
12
13
 
13
14
 
@@ -32,9 +33,23 @@ import dfTimeoutMs from './dfTimeoutMs.mjs'
32
33
  // (使用端實測: 一次107秒的多階段請求中72秒耗在重複踩同一組429, 啟用冷卻後降至15秒)。
33
34
  // 設計與「金鑰停用清單」(已否決)的關鍵差異: 以「條目」為單位、短視窗、且「只降序不移除」
34
35
  // ——冷卻中的條目移到鏈尾而非移除, 前面全敗時照樣會被嘗試, 故不存在把已恢復服務冰住的問題;
35
- // 任一次成功立即解除。觸發限於限流(HTTP 429, 僅api-openai-compat可靠偵測; CLI類之限流
36
- // 埋在stderr文字中不納入)與逾時(TIMEOUT開頭, 各kind皆可)兩類——其餘失敗已有換金鑰換家
37
- // 機制處理, 納入反而誤傷。狀態存於state.cooling, 與cursors同走store持久化。
36
+ // 任一次成功立即解除。內建觸發限於限流(HTTP 429, 僅api-openai-compat可靠偵測; CLI類之
37
+ // 限流埋在stderr文字中, 各家字樣不同且隨版本漂移, 本套件不維護簽章表)與逾時(TIMEOUT開頭,
38
+ // 各kind皆可)兩類——其餘失敗已有換金鑰換家機制處理, 納入反而誤傷。
39
+ // CLI類限流之偵測採依賴注入: 呼叫端於實測中觀察到穩定字樣時, 以coolDetect(r)=>Boolean
40
+ // 自行判定(收到完整失敗結果含stderr), 命中即視同冷卻觸發——簽章表由觀察到字樣的呼叫端
41
+ // 維護, 漏判僅退回現狀(每階段重探一次), 誤判也只是降尾非移除, 兩邊代價都有上限。
42
+ // 狀態存於state.cooling, 與cursors同走store持久化。
43
+ //
44
+ // 【中止(shouldStop)】呼叫端(如server於客戶端斷線後)可注入shouldStop()=>Boolean,
45
+ // 於「每次嘗試之間」檢查, true即停止遞補回報ABORTED——把「斷線後仍空耗整條鏈」
46
+ // 縮成「至多再耗當前這一家」。檢查點只此一處: 工作流各層經omit轉傳自動獲得,
47
+ // 中止後每個後續呼叫進門即回ABORTED, 整條工作流自然快速收束, 不需逐層實作。
48
+ // 不中止進行中之嘗試(不殺子進程/不斷開請求), 此為已知設計取捨(避免侵入execCli層)。
49
+ //
50
+ // 【meta保留鍵】「剔除自用鍵後原樣轉傳」令條目即調校點, 但呼叫端放進條目/opt的任何
51
+ // 自有欄位都會被靜默轉傳——保留meta一鍵保證永不轉傳, 呼叫端要掛分類/標籤/註記
52
+ // 一律放meta, 與轉傳機制永久絕緣(工作流各層之規格物件同此約定)。
38
53
  //
39
54
  // 【時間預算】budgetMs限制整輪遞補的總時長, 剩餘預算會壓進每次呼叫的timeoutMs,
40
55
  // 防止多家連續卡逾時而撞破外部排程的執行上限。
@@ -69,11 +84,12 @@ import dfTimeoutMs from './dfTimeoutMs.mjs'
69
84
 
70
85
 
71
86
  //fallback層自用之設定鍵, 其餘鍵作為各attempt之共用預設原樣轉傳
72
- let FALLBACK_KEYS = ['providers', 'budgetMs', 'minAttemptMs', 'cooldownMs', 'store', 'onEvent']
87
+ //meta為保留鍵: 呼叫端掛自有資訊(分類/標籤/註記)用, 保證永不轉傳(見檔頭【meta保留鍵】)
88
+ let FALLBACK_KEYS = ['providers', 'budgetMs', 'minAttemptMs', 'cooldownMs', 'shouldStop', 'coolDetect', 'store', 'onEvent', 'meta']
73
89
 
74
90
 
75
91
  //providers條目自用之設定鍵, 其餘鍵(含kind)即該條目之opt原樣轉傳對應轉接器
76
- let ENTRY_KEYS = ['id', 'keys']
92
+ let ENTRY_KEYS = ['id', 'keys', 'meta']
77
93
 
78
94
 
79
95
  //預設值
@@ -85,6 +101,79 @@ let DEFAULT_TIMEOUT_MS = dfTimeoutMs //全套件統一預設300000
85
101
  let memoryState = { cursors: {} }
86
102
 
87
103
 
104
+ /**
105
+ * 初始化游標與冷卻狀態(store有效即載入持久化狀態, 否則用行程內記憶體)
106
+ *
107
+ * @param {Object} store 輸入狀態持久化物件{get,set},無效代表用行程內記憶體
108
+ * @returns {Object} 回傳物件,內含state(狀態物件,保證有cursors與cooling)與saveState(寫回函數,store無效或寫入失敗皆靜默)
109
+ */
110
+ function initState(store) {
111
+ let useStore = isobj(store) && isfun(store.get) && isfun(store.set)
112
+ let state = null
113
+ if (useStore) {
114
+ try {
115
+ state = store.get()
116
+ }
117
+ catch {}
118
+ }
119
+ if (!isobj(state)) {
120
+ state = useStore ? { cursors: {} } : memoryState
121
+ }
122
+ if (!isobj(state.cursors)) {
123
+ state.cursors = {}
124
+ }
125
+ if (!isobj(state.cooling)) {
126
+ state.cooling = {}
127
+ }
128
+ let saveState = () => {
129
+ if (useStore) {
130
+ try {
131
+ store.set(state)
132
+ }
133
+ catch {}
134
+ }
135
+ }
136
+ return { state, saveState }
137
+ }
138
+
139
+
140
+ /**
141
+ * 依冷卻狀態重排providers:冷卻中的條目「只降序不移除」——移到鏈尾, 前面全敗時仍會被嘗試,
142
+ * 故不存在把已恢復服務冰住的問題(此為與「金鑰停用清單」的關鍵差異, 後者已被否決)。
143
+ * 僅追蹤有明給id之條目(索引式id會因重排而錯位); 過期紀錄順手清除並寫回
144
+ *
145
+ * @param {Array} providers 輸入供應商條目陣列
146
+ * @param {Object} state 輸入狀態物件(取其cooling)
147
+ * @param {Number} cooldownMs 輸入冷卻視窗毫秒正整數
148
+ * @param {Function} saveState 輸入狀態寫回函數
149
+ * @returns {Array} 回傳重排後之條目陣列(active在前, 冷卻中殿後, 各自保持原相對順序)
150
+ */
151
+ function reorderByCooling(providers, state, cooldownMs, saveState) {
152
+ let now = Date.now()
153
+ let act = []
154
+ let cool = []
155
+ let dirty = false
156
+ for (let p of providers) {
157
+ let pid = get(p, 'id', null)
158
+ let ts = isestr(pid) ? get(state.cooling, pid, null) : null
159
+ if (ispint(ts) && (now - ts) < cooldownMs) {
160
+ cool.push(p)
161
+ }
162
+ else {
163
+ if (isestr(pid) && state.cooling[pid] !== undefined) {
164
+ delete state.cooling[pid] //冷卻已過期, 清除
165
+ dirty = true
166
+ }
167
+ act.push(p)
168
+ }
169
+ }
170
+ if (dirty) {
171
+ saveState()
172
+ }
173
+ return [...act, ...cool]
174
+ }
175
+
176
+
88
177
  /**
89
178
  * 判斷失敗結果是否與「哪一把金鑰」無關(換組內金鑰必然再敗, 應整組跳過)
90
179
  *
@@ -146,15 +235,19 @@ function isKeyIndependentFail(r) {
146
235
  * @param {Array} opt.providers 輸入供應商條目物件陣列,順序即優先序。各條目除下列鍵外,其餘鍵(kind、model、exe、provider、config、sandbox、timeoutMs等)即該條目之opt原樣透傳對應轉接器
147
236
  * @param {String} [opt.providers[].id=條目索引字串] 輸入群組識別字串,游標以此為鍵、亦為日誌標籤,本套件不解讀其內容。須區分到「模型」而非只到「廠商」(如'claude:sonnet'而非'claude'),同一模型經不同路徑取得時須帶上路徑(如'poolside:laguna-s-2.1'與'or:poolside/laguna-s-2.1:free'),且務必唯一。省略時回退為陣列索引字串——索引是位置不是身分,日後插入條目會令後續條目繼承他人游標進度,故正式設定一律明給。詳見本檔檔頭之id設計規則
148
237
  * @param {Array} [opt.providers[].keys=[]] 輸入同一服務之多把API key字串陣列,逐次注入輪替(kind為opencode時須同時於條目給予provider),省略代表沿用CLI既有登入狀態之單一虛擬金鑰
238
+ * @param {*} [opt.providers[].meta=undefined] 輸入呼叫端自有資訊(分類、標籤、註記),保留鍵保證永不轉傳對應轉接器——條目其餘鍵一律原樣轉傳,自有欄位放此鍵可與轉傳機制永久絕緣,預設undefined
149
239
  * @param {Number} [opt.budgetMs=null] 輸入整輪遞補之時間上限毫秒正整數,剩餘預算會壓進每次呼叫之timeoutMs,預設null代表不限
150
240
  * @param {Number} [opt.minAttemptMs=20000] 輸入單次嘗試之最低剩餘預算毫秒正整數,剩餘低於此值即停止嘗試回報budget exhausted,預設20000
151
241
  * @param {Object} [opt.store=null] 輸入狀態持久化物件{get:()=>state,set:(state)=>{}},state內含cursors(逐群組游標)與cooling(供應商冷卻時間戳,僅cooldownMs>0時使用),省略代表用行程內記憶體(跨呼叫有效,重啟歸零)。假定單行程序列調用,並行請自行加鎖
152
- * @param {Number} [opt.cooldownMs=0] 輸入供應商冷卻視窗毫秒非負整數,>0啟用:條目(限有明給id者)遭遇限流(HTTP 429,僅api類可偵測)或逾時(TIMEOUT開頭)後,於冷卻視窗內之後續呼叫中被移至鏈尾——只降序不移除,前面全敗時仍會被嘗試,任一次成功立即解除;注意啟用時「providers順序即優先序」會被暫時重排,此即本機制之目的;預設0代表不啟用
153
- * @param {Function} [opt.onEvent=null] 輸入事件回調函數(ev)=>{},ev.type可為'try''ok''next-key''skip-group'、'budget-out';失敗事件(next-key/skip-group)另帶stdout(被拒回覆)stderr(錯誤輸出)供診斷,兩者於失敗路徑已由轉接器截斷;回調拋出例外不影響主流程,預設null
242
+ * @param {Number} [opt.cooldownMs=0] 輸入供應商冷卻視窗毫秒非負整數,>0啟用:條目(限有明給id者)遭遇限流(HTTP 429,僅api類可偵測;CLI類可經coolDetect注入判定)或逾時(TIMEOUT開頭)後,於冷卻視窗內之後續呼叫中被移至鏈尾——只降序不移除,前面全敗時仍會被嘗試,任一次成功立即解除;注意啟用時「providers順序即優先序」會被暫時重排,此即本機制之目的;預設0代表不啟用
243
+ * @param {Function} [opt.coolDetect=null] 輸入冷卻觸發判定函數(r)=>Boolean,收完整失敗結果物件(含stdoutstderrcodeerror),回傳true即視同冷卻觸發(內建429/TIMEOUT觸發不受影響)——CLI類限流埋在stderr且各家字樣不同,簽章表由觀察到字樣的呼叫端維護,如(r)=>/FreeUsageLimitError/i.test(r.stderr||'');僅cooldownMs>0時有效,回調拋出例外視同false,預設null
244
+ * @param {Function} [opt.shouldStop=null] 輸入中止判定函數()=>Boolean,於每次嘗試之間檢查,回傳true即停止遞補回報ABORTED(不中止進行中之嘗試)——供呼叫端於成果已無人接收時(如客戶端斷線)止損;經工作流層原樣轉傳,中止後各後續呼叫進門即回ABORTED令整條工作流快速收束;回調拋出例外視同false,預設null
245
+ * @param {*} [opt.meta=undefined] 輸入呼叫端自有資訊,保留鍵保證永不轉傳各轉接器,預設undefined
246
+ * @param {Function} [opt.onEvent=null] 輸入事件回調函數(ev)=>{},ev.type可為'try'、'ok'、'next-key'、'skip-group'、'budget-out'、'aborted'、'cooled'(冷卻觸發,帶error與cooldownMs,僅cooldownMs>0時出現);失敗事件(next-key/skip-group)另帶errorType、stdout(被拒回覆)與stderr(錯誤輸出)供診斷,後兩者於失敗路徑已由轉接器截斷;回調拋出例外不影響主流程,預設null
154
247
  * @param {Number} [opt.timeoutMs=300000] 輸入各attempt共用之逾時毫秒正整數,條目可覆寫,全套件統一預設300000
155
248
  * @param {String|Function} [opt.validate=undefined] 輸入各attempt共用之stdout驗證規則,條目可覆寫,預設undefined
156
249
  * @param {Number} [opt.maxRetries=0] 輸入各attempt共用之同家重試次數非負整數,韌性建議交給換家而非重試同一家,預設0
157
- * @returns {Promise} 回傳Promise,resolve回傳結果物件,除execCli既有欄位(ok、stdout、stderr、code、error、durationMs、attempts、pid)外,追加providerId(實際使用之群組)、keyIndex(實際使用之金鑰索引,無keys時為null)、kind、model、tried(全部嘗試歷程陣列,成功時亦回傳;失敗項含stdout與stderr供診斷被拒原因),本函數不會reject
250
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件,除execCli既有欄位(ok、stdout、stderr、code、error、durationMs、attempts、pid)外,追加providerId(實際使用之群組)、keyIndex(實際使用之金鑰索引,無keys時為null)、kind、model、tried(全部嘗試歷程陣列,成功時亦回傳;失敗項含errorType、stdout與stderr供診斷被拒原因);失敗結果帶機器可讀之errorType(一覽見getErrorType.mjs檔頭);api類轉接器提供usage(token用量)時原樣流出於結果與tried各項,CLI類無此欄;本函數不會reject
158
251
  * @example
159
252
  * //need opencode, claude, codex cli in system PATH
160
253
  *
@@ -211,87 +304,44 @@ async function dispatchAiFallback(prompt, opt = {}) {
211
304
  }
212
305
 
213
306
  //budgetMs, 無效視為不限
214
- let budgetMs = get(opt, 'budgetMs', null)
215
- if (!ispint(budgetMs)) {
216
- budgetMs = null
217
- }
218
- else {
219
- budgetMs = cint(budgetMs)
220
- }
307
+ let budgetMs = castPintOr(get(opt, 'budgetMs', null), null)
221
308
  let deadline = (budgetMs === null) ? null : Date.now() + budgetMs
222
309
 
223
310
  //minAttemptMs, 無效回退預設20000
224
- let minAttemptMs = get(opt, 'minAttemptMs', null)
225
- if (!ispint(minAttemptMs)) {
226
- minAttemptMs = DEFAULT_MIN_ATTEMPT_MS
227
- }
228
- else {
229
- minAttemptMs = cint(minAttemptMs)
311
+ let minAttemptMs = castPintOr(get(opt, 'minAttemptMs', null), DEFAULT_MIN_ATTEMPT_MS)
312
+
313
+ //state與saveState, store須同時具get與set函數才視為有效, 否則用行程內記憶體
314
+ let { state, saveState } = initState(get(opt, 'store', null))
315
+
316
+ //cooldownMs, 無效視為0=不啟用(現行行為零改變)
317
+ let cooldownMs = castPintOr(get(opt, 'cooldownMs', null), 0)
318
+
319
+ //coolDetect, 冷卻觸發之注入判定(CLI類限流簽章由呼叫端維護, 見檔頭), 僅cooldownMs>0時有意義
320
+ let coolDetect = get(opt, 'coolDetect', null)
321
+ if (!isfun(coolDetect)) {
322
+ coolDetect = null
230
323
  }
231
324
 
232
- //store, 須同時具get與set函數才視為有效, 否則用行程內記憶體
233
- let store = get(opt, 'store', null)
234
- let useStore = isobj(store) && isfun(store.get) && isfun(store.set)
235
- let state = null
236
- if (useStore) {
325
+ //shouldStop, 中止判定, 於每次嘗試之間檢查; 回調拋出例外視同false(不中止), 不得中斷主流程
326
+ let shouldStop = get(opt, 'shouldStop', null)
327
+ if (!isfun(shouldStop)) {
328
+ shouldStop = null
329
+ }
330
+ let stopRequested = () => {
331
+ if (shouldStop === null) {
332
+ return false
333
+ }
237
334
  try {
238
- state = store.get()
335
+ return shouldStop() === true
239
336
  }
240
- catch {}
241
- }
242
- if (!isobj(state)) {
243
- state = useStore ? { cursors: {} } : memoryState
244
- }
245
- if (!isobj(state.cursors)) {
246
- state.cursors = {}
247
- }
248
- if (!isobj(state.cooling)) {
249
- state.cooling = {}
250
- }
251
- let saveState = () => {
252
- if (useStore) {
253
- try {
254
- store.set(state)
255
- }
256
- catch {}
337
+ catch {
338
+ return false
257
339
  }
258
340
  }
259
341
 
260
- //cooldownMs, 無效視為0=不啟用(現行行為零改變)
261
- let cooldownMs = get(opt, 'cooldownMs', null)
262
- if (!ispint(cooldownMs)) {
263
- cooldownMs = 0
264
- }
265
- else {
266
- cooldownMs = cint(cooldownMs)
267
- }
268
-
269
- //供應商冷卻: 冷卻中的條目「只降序不移除」——移到鏈尾, 前面全敗時仍會被嘗試,
270
- //故不存在把已恢復服務冰住的問題(此為與「金鑰停用清單」的關鍵差異, 後者已被否決)。
271
- //僅追蹤有明給id之條目(索引式id會因重排而錯位); 過期項順手清除
342
+ //供應商冷卻: 冷卻中的條目降至鏈尾(細節見reorderByCooling)
272
343
  if (cooldownMs > 0) {
273
- let now = Date.now()
274
- let act = []
275
- let cool = []
276
- let dirty = false
277
- for (let p of providers) {
278
- let pid = get(p, 'id', null)
279
- let ts = isestr(pid) ? get(state.cooling, pid, null) : null
280
- if (ispint(ts) && (now - ts) < cooldownMs) {
281
- cool.push(p)
282
- }
283
- else {
284
- if (isestr(pid) && state.cooling[pid] !== undefined) {
285
- delete state.cooling[pid] //冷卻已過期, 清除
286
- dirty = true
287
- }
288
- act.push(p)
289
- }
290
- }
291
- providers = [...act, ...cool]
292
- if (dirty) {
293
- saveState()
294
- }
344
+ providers = reorderByCooling(providers, state, cooldownMs, saveState)
295
345
  }
296
346
 
297
347
  //onEvent, 回調拋出例外不得中斷主流程
@@ -347,6 +397,13 @@ async function dispatchAiFallback(prompt, opt = {}) {
347
397
  let skipGroup = false
348
398
  for (let a = 0; a < nAttempts && !skipGroup; a++) {
349
399
 
400
+ //中止檢查(嘗試邊界): 成果已無人接收時止損, 不中止進行中之嘗試(見檔頭【中止】)
401
+ if (stopRequested()) {
402
+ emit({ type: 'aborted', providerId: id, keyIndex: null, keyId: id })
403
+ tried.push({ providerId: id, keyIndex: null, keyId: id, outcome: 'aborted' })
404
+ return { ...getErrorResult('ABORTED', 'aborted'), tried }
405
+ }
406
+
350
407
  //keyIndex, 無keys時為null
351
408
  let keyIndex = (nk > 0) ? (cursor + a) % nk : null
352
409
  let keyId = (keyIndex === null) ? id : `${id}#${keyIndex}`
@@ -358,13 +415,7 @@ async function dispatchAiFallback(prompt, opt = {}) {
358
415
  }
359
416
 
360
417
  //有效timeout, 有預算時以剩餘預算封頂
361
- let effTimeout = get(attemptOpt, 'timeoutMs', null)
362
- if (!ispint(effTimeout)) {
363
- effTimeout = DEFAULT_TIMEOUT_MS
364
- }
365
- else {
366
- effTimeout = cint(effTimeout)
367
- }
418
+ let effTimeout = castPintOr(get(attemptOpt, 'timeoutMs', null), DEFAULT_TIMEOUT_MS)
368
419
  if (deadline !== null) {
369
420
  let remaining = deadline - Date.now()
370
421
 
@@ -372,7 +423,7 @@ async function dispatchAiFallback(prompt, opt = {}) {
372
423
  if (remaining < minAttemptMs) {
373
424
  emit({ type: 'budget-out', providerId: id, keyIndex, keyId, remainingMs: remaining })
374
425
  tried.push({ providerId: id, keyIndex, keyId, outcome: 'budget-out' })
375
- return { ...getErrorResult('budget exhausted'), tried }
426
+ return { ...getErrorResult('budget exhausted', 'budget'), tried }
376
427
  }
377
428
  effTimeout = Math.min(effTimeout, remaining)
378
429
  }
@@ -393,20 +444,28 @@ async function dispatchAiFallback(prompt, opt = {}) {
393
444
  saveState()
394
445
  }
395
446
  emit({ type: 'ok', providerId: id, keyIndex, keyId, durationMs: r.durationMs })
396
- tried.push({ providerId: id, keyIndex, keyId, outcome: 'ok', durationMs: r.durationMs })
447
+ tried.push({ providerId: id, keyIndex, keyId, outcome: 'ok', durationMs: r.durationMs, ...(r.usage !== undefined ? { usage: r.usage } : {}) })
397
448
  return { ...r, providerId: id, keyIndex, kind, model, tried }
398
449
  }
399
450
 
400
451
  //失敗分流
401
452
  lastResult = r
402
453
  lastMeta = { providerId: id, keyIndex, kind, model }
403
- //冷卻觸發: 限流(HTTP 429, 僅api類可偵測)與逾時(TIMEOUT開頭, CLI與api皆可)兩類——
404
- //其餘失敗(金鑰無效/服務端錯誤)已有換金鑰換家機制處理, 納入冷卻反而誤傷
454
+ //冷卻觸發: 內建為限流(HTTP 429, 僅api類可偵測)與逾時(TIMEOUT開頭, CLI與api皆可)——
455
+ //其餘失敗(金鑰無效/服務端錯誤)已有換金鑰換家機制處理, 納入冷卻反而誤傷;
456
+ //CLI類限流簽章經coolDetect注入判定(呼叫端維護, 見檔頭), 拋出例外視同false
405
457
  if (cooldownMs > 0 && idExplicit) {
406
458
  let isCoolTrigger = (r.code === 429) || (isestr(r.error) && r.error.indexOf('TIMEOUT') === 0)
459
+ if (!isCoolTrigger && coolDetect !== null) {
460
+ try {
461
+ isCoolTrigger = coolDetect(r) === true
462
+ }
463
+ catch {}
464
+ }
407
465
  if (isCoolTrigger) {
408
466
  state.cooling[id] = Date.now()
409
467
  saveState()
468
+ emit({ type: 'cooled', providerId: id, keyIndex, keyId, error: r.error, cooldownMs })
410
469
  }
411
470
  }
412
471
 
@@ -415,22 +474,22 @@ async function dispatchAiFallback(prompt, opt = {}) {
415
474
  if (isKeyIndependentFail(r)) {
416
475
 
417
476
  //與金鑰無關, 整組跳過
418
- emit({ type: 'skip-group', providerId: id, keyIndex, keyId, error: r.error, stdout: r.stdout, stderr: r.stderr })
419
- tried.push({ providerId: id, keyIndex, keyId, outcome: 'skip-group', error: r.error, stdout: r.stdout, stderr: r.stderr, durationMs: r.durationMs })
477
+ emit({ type: 'skip-group', providerId: id, keyIndex, keyId, error: r.error, errorType: r.errorType, stdout: r.stdout, stderr: r.stderr })
478
+ tried.push({ providerId: id, keyIndex, keyId, outcome: 'skip-group', error: r.error, errorType: r.errorType, stdout: r.stdout, stderr: r.stderr, durationMs: r.durationMs, ...(r.usage !== undefined ? { usage: r.usage } : {}) })
420
479
  skipGroup = true
421
480
  }
422
481
  else {
423
482
 
424
483
  //其餘(含額度上限/金鑰無效/未分類), 換組內下一把, 不記憶不停用
425
- emit({ type: 'next-key', providerId: id, keyIndex, keyId, error: r.error, stdout: r.stdout, stderr: r.stderr })
426
- tried.push({ providerId: id, keyIndex, keyId, outcome: 'next-key', error: r.error, stdout: r.stdout, stderr: r.stderr, durationMs: r.durationMs })
484
+ emit({ type: 'next-key', providerId: id, keyIndex, keyId, error: r.error, errorType: r.errorType, stdout: r.stdout, stderr: r.stderr })
485
+ tried.push({ providerId: id, keyIndex, keyId, outcome: 'next-key', error: r.error, errorType: r.errorType, stdout: r.stdout, stderr: r.stderr, durationMs: r.durationMs, ...(r.usage !== undefined ? { usage: r.usage } : {}) })
427
486
  }
428
487
 
429
488
  }
430
489
  }
431
490
 
432
- //全數失敗, 回傳最後一筆失敗結果與完整歷程
433
- let r = lastResult || getErrorResult('all providers failed')
491
+ //全數失敗, 回傳最後一筆失敗結果(含其errorType)與完整歷程
492
+ let r = lastResult || getErrorResult('all providers failed', 'exec')
434
493
  return { ...r, ...(lastMeta || {}), tried }
435
494
  }
436
495
 
@@ -3,11 +3,11 @@ import omit from 'lodash-es/omit.js'
3
3
  import isarr from 'wsemi/src/isarr.mjs'
4
4
  import isbol from 'wsemi/src/isbol.mjs'
5
5
  import isestr from 'wsemi/src/isestr.mjs'
6
- import ispint from 'wsemi/src/ispint.mjs'
7
- import cint from 'wsemi/src/cint.mjs'
8
6
  import execCli from 'wsemi/src/execCli.mjs'
9
7
  import getCliArgs from './getCliArgs.mjs'
10
8
  import getErrorResult from './getErrorResult.mjs'
9
+ import { attachErrorType } from './getErrorType.mjs'
10
+ import castPintOr from './castPintOr.mjs'
11
11
  import dfTimeoutMs from './dfTimeoutMs.mjs'
12
12
 
13
13
 
@@ -134,13 +134,7 @@ async function dispatchAntigravity(prompt, opt = {}) {
134
134
  }
135
135
 
136
136
  //timeoutMs, 先行取值以供printTimeout推導, 無效回退全套件統一預設300000
137
- let timeoutMs = get(opt, 'timeoutMs', null)
138
- if (!ispint(timeoutMs)) {
139
- timeoutMs = DEFAULT_TIMEOUT_MS
140
- }
141
- else {
142
- timeoutMs = cint(timeoutMs)
143
- }
137
+ let timeoutMs = castPintOr(get(opt, 'timeoutMs', null), DEFAULT_TIMEOUT_MS)
144
138
 
145
139
  //printTimeout, 未給時由timeoutMs推導並預留緩衝, 令agy先於外層逾時而回報自身錯誤訊息
146
140
  let printTimeout = get(opt, 'printTimeout', null)
@@ -187,15 +181,17 @@ async function dispatchAntigravity(prompt, opt = {}) {
187
181
  let optCli = omit(opt, OWN_KEYS)
188
182
 
189
183
  //execCli, 不帶input(agy由--print取得prompt); try/catch兜底轉義膨脹等
190
- //前置檢查漏網之spawn同步拋出(如ENAMETOOLONG), 維持不reject契約
184
+ //前置檢查漏網之spawn同步拋出(如ENAMETOOLONG), 維持不reject契約;
185
+ //失敗結果補上機器可讀之errorType(僅機械可判者, 見getErrorType.mjs)
191
186
  try {
192
- return await execCli(exe, args, {
187
+ let r = await execCli(exe, args, {
193
188
  ...optCli,
194
189
  timeoutMs,
195
190
  })
191
+ return attachErrorType(r)
196
192
  }
197
193
  catch (err) {
198
- return getErrorResult(`${err.code || 'UNKNOWN'}: ${err.message}`)
194
+ return getErrorResult(`${err.code || 'UNKNOWN'}: ${err.message}`, 'spawn')
199
195
  }
200
196
  }
201
197
 
@@ -3,10 +3,10 @@ import isobj from 'wsemi/src/isobj.mjs'
3
3
  import isfun from 'wsemi/src/isfun.mjs'
4
4
  import isnum from 'wsemi/src/isnum.mjs'
5
5
  import isestr from 'wsemi/src/isestr.mjs'
6
- import ispint from 'wsemi/src/ispint.mjs'
7
6
  import isp0int from 'wsemi/src/isp0int.mjs'
8
7
  import cint from 'wsemi/src/cint.mjs'
9
8
  import delay from 'wsemi/src/delay.mjs'
9
+ import castPintOr from './castPintOr.mjs'
10
10
  import strleft from 'wsemi/src/strleft.mjs'
11
11
  import strdelleft from 'wsemi/src/strdelleft.mjs'
12
12
  import strTruncate from 'wsemi/src/strTruncate.mjs'
@@ -44,7 +44,11 @@ import dfTimeoutMs from './dfTimeoutMs.mjs'
44
44
  // 【重試語意對齊execCli】4xx(429除外)為客戶端錯誤不可重試而立即中止;
45
45
  // 429/5xx/網路錯誤/逾時依maxRetries線性退避重試(間隔retryDelayMs*次數, 上限15000ms)。
46
46
  //
47
- // 【結果結構對齊execCli】{ ok, stdout, stderr, code, error, durationMs, attempts },
47
+ // 【結果結構對齊execCli】{ ok, stdout, stderr, code, error, durationMs, attempts, usage },
48
+ // usage為原始回應之token用量原樣透傳(無則null; 驗證失敗等已耗token之失敗亦帶出),
49
+ // CLI類轉接器無可靠來源故無此欄——呼叫端可據此把「真實用量」與「只能估算」分開處理。
50
+ // 失敗結果另帶機器可讀之errorType(timeout/fetch/http/tool-unsupported/invalid-response/
51
+ // validation/params, 一覽見getErrorType.mjs檔頭), error字串保留不動, 兩者並存。
48
52
  // stdout為回覆內容、code為HTTP狀態碼(網路錯誤與逾時為null)、逾時error以TIMEOUT開頭、
49
53
  // 驗證失敗error為OUTPUT_VALIDATION_FAILED——故dispatchAiFallback之失敗分流
50
54
  // (TIMEOUT/驗證失敗跳組, 其餘換金鑰)對本轉接器同樣成立, 無須任何修改。
@@ -138,6 +142,19 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
138
142
 
139
143
  let t0 = Date.now()
140
144
 
145
+ //mkResult, 結果形狀之單一來源(欄位對齊execCli, 追加errorType與usage),
146
+ //durationMs於呼叫當下計算; 失敗分支各自給errorType, 成功分支不帶(僅失敗結果有此欄)
147
+ let mkResult = (patch) => ({
148
+ ok: false,
149
+ stdout: '',
150
+ stderr: '',
151
+ code: null,
152
+ error: '',
153
+ durationMs: Date.now() - t0,
154
+ usage: null,
155
+ ...patch,
156
+ })
157
+
141
158
  //AbortController, 逾時中止(含回應本體之串流讀取)
142
159
  let controller = new AbortController()
143
160
  let timer = setTimeout(() => {
@@ -157,81 +174,65 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
157
174
  }
158
175
  catch (err) {
159
176
  clearTimeout(timer)
160
- let durationMs = Date.now() - t0
161
177
 
162
178
  //逾時, error以TIMEOUT開頭令dispatchAiFallback視為與金鑰無關而跳組
163
179
  if (err.name === 'AbortError') {
164
- return {
165
- ok: false,
166
- stdout: '',
167
- stderr: '',
168
- code: null,
169
- error: `TIMEOUT after ${timeoutMs / 1000}s`,
170
- durationMs,
171
- }
180
+ return mkResult({ error: `TIMEOUT after ${timeoutMs / 1000}s`, errorType: 'timeout' })
172
181
  }
173
182
 
174
183
  //網路層錯誤(DNS/連線拒絕等)
175
184
  let cause = get(err, 'cause.code', '') || err.message
176
- return {
177
- ok: false,
178
- stdout: '',
179
- stderr: '',
180
- code: null,
181
- error: `FETCH_ERROR: ${cause}`,
182
- durationMs,
183
- }
185
+ return mkResult({ error: `FETCH_ERROR: ${cause}`, errorType: 'fetch' })
184
186
  }
185
187
  clearTimeout(timer)
186
188
 
187
- let durationMs = Date.now() - t0
188
-
189
189
  //HTTP非2xx, 原始回應本體放stderr供除錯與分類
190
190
  if (!res.ok) {
191
- return {
192
- ok: false,
193
- stdout: '',
191
+ return mkResult({
194
192
  stderr: strTruncate(txt, 1000, optTruncate),
195
193
  code: res.status,
196
194
  error: `HTTP ${res.status}`,
197
- durationMs,
198
- }
195
+ errorType: 'http',
196
+ })
199
197
  }
200
198
 
201
- //取出choices[0]
199
+ //取出choices[0]與usage(token用量, 原樣透傳; 失敗回應亦可能已耗token, 一併帶出)
202
200
  let content = null
203
201
  let finishReason = ''
204
202
  let toolCalls = null
203
+ let usage = null
205
204
  try {
206
205
  let j = JSON.parse(txt)
207
206
  content = get(j, 'choices.0.message.content', null)
208
207
  finishReason = get(j, 'choices.0.finish_reason', '')
209
208
  toolCalls = get(j, 'choices.0.message.tool_calls', null)
209
+ usage = get(j, 'usage', null)
210
+ if (!isobj(usage)) {
211
+ usage = null
212
+ }
210
213
  }
211
214
  catch {}
212
215
 
213
216
  //tool_calls, 本轉接器不支援工具迴圈(見檔頭), 明確回報而不假裝成功
214
217
  //(Agnes於tool_calls時content為"\n\n"非null, 不攔截會靜默回傳空白內容)
215
218
  if (finishReason === 'tool_calls' || (toolCalls !== null && toolCalls !== undefined)) {
216
- return {
217
- ok: false,
218
- stdout: '',
219
+ return mkResult({
219
220
  stderr: strTruncate(txt, 1000, optTruncate),
220
221
  code: res.status,
221
222
  error: 'TOOL_CALLS_UNSUPPORTED: use a cli kind (opencode/claude/codex/antigravity) when tools are needed',
222
- durationMs,
223
- }
223
+ errorType: 'tool-unsupported',
224
+ usage,
225
+ })
224
226
  }
225
227
 
226
228
  if (content === null || content === undefined) {
227
- return {
228
- ok: false,
229
- stdout: '',
229
+ return mkResult({
230
230
  stderr: strTruncate(txt, 500, optTruncate),
231
231
  code: res.status,
232
232
  error: 'INVALID_RESPONSE: missing choices[0].message.content',
233
- durationMs,
234
- }
233
+ errorType: 'invalid-response',
234
+ usage,
235
+ })
235
236
  }
236
237
  if (typeof content !== 'string') {
237
238
  content = JSON.stringify(content) //少數閘道回array形態
@@ -239,24 +240,21 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
239
240
 
240
241
  //validator, error與execCli一致令dispatchAiFallback可統一分流
241
242
  if (validator && !validator(content)) {
242
- return {
243
- ok: false,
243
+ return mkResult({
244
244
  stdout: strTruncate(content, 500, optTruncate),
245
- stderr: '',
246
245
  code: res.status,
247
246
  error: 'OUTPUT_VALIDATION_FAILED',
248
- durationMs,
249
- }
247
+ errorType: 'validation',
248
+ usage,
249
+ })
250
250
  }
251
251
 
252
- return {
252
+ return mkResult({
253
253
  ok: true,
254
254
  stdout: content,
255
- stderr: '',
256
255
  code: res.status,
257
- error: '',
258
- durationMs,
259
- }
256
+ usage,
257
+ })
260
258
  }
261
259
 
262
260
 
@@ -287,7 +285,7 @@ async function callOnce(url, headers, body, timeoutMs, validator) {
287
285
  * @param {String|Function} [opt.validate=undefined] 輸入回覆內容驗證規則字串或自訂驗證函數,規則字串支援'nonempty'、'json'、'min:100',多規則可用逗號串接,預設undefined代表不驗證
288
286
  * @param {Number} [opt.maxRetries=0] 輸入失敗後最大重試次數非負整數,4xx(429除外)不重試,預設0
289
287
  * @param {Number} [opt.retryDelayMs=5000] 輸入重試間隔毫秒正整數,實際間隔為retryDelayMs乘以重試次數且上限15000ms,預設5000
290
- * @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否成功布林值)、stdout(回覆內容字串)、stderr(失敗時之原始回應本體)、code(HTTP狀態碼,網路錯誤與逾時為null)、error(錯誤訊息字串,成功時為空字串)、durationMs(耗時毫秒)、attempts(實際嘗試次數),本函數不會reject
288
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否成功布林值)、stdout(回覆內容字串)、stderr(失敗時之原始回應本體)、code(HTTP狀態碼,網路錯誤與逾時為null)、error(錯誤訊息字串,成功時為空字串)、errorType(僅失敗時,機器可讀錯誤類別字串,一覽見getErrorType.mjs檔頭)、durationMs(耗時毫秒)、attempts(實際嘗試次數)、usage(原始回應之token用量物件原樣透傳,無則null),本函數不會reject
291
289
  * @example
292
290
  * //need network, no cli required
293
291
  *
@@ -362,13 +360,7 @@ async function dispatchApiOpenaiCompat(prompt, opt = {}) {
362
360
  }
363
361
 
364
362
  //timeoutMs
365
- let timeoutMs = get(opt, 'timeoutMs', null)
366
- if (!ispint(timeoutMs)) {
367
- timeoutMs = DEFAULT_TIMEOUT_MS
368
- }
369
- else {
370
- timeoutMs = cint(timeoutMs)
371
- }
363
+ let timeoutMs = castPintOr(get(opt, 'timeoutMs', null), DEFAULT_TIMEOUT_MS)
372
364
 
373
365
  //maxRetries
374
366
  let maxRetries = get(opt, 'maxRetries', null)
@@ -380,13 +372,7 @@ async function dispatchApiOpenaiCompat(prompt, opt = {}) {
380
372
  }
381
373
 
382
374
  //retryDelayMs
383
- let retryDelayMs = get(opt, 'retryDelayMs', null)
384
- if (!ispint(retryDelayMs)) {
385
- retryDelayMs = DEFAULT_RETRY_DELAY_MS
386
- }
387
- else {
388
- retryDelayMs = cint(retryDelayMs)
389
- }
375
+ let retryDelayMs = castPintOr(get(opt, 'retryDelayMs', null), DEFAULT_RETRY_DELAY_MS)
390
376
 
391
377
  //validator
392
378
  let validator = buildValidator(get(opt, 'validate', null))