w-dispatch-ai 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/index.html CHANGED
@@ -29,7 +29,7 @@
29
29
  <nav >
30
30
 
31
31
 
32
- <h2><a href="index.html">Home</a></h2><h3>Global</h3><ul><li><a href="global.html#WDispatchAi">WDispatchAi</a></li><li><a href="global.html#adapters">adapters</a></li><li><a href="global.html#dispatchAi">dispatchAi</a></li><li><a href="global.html#dispatchClaude">dispatchClaude</a></li><li><a href="global.html#dispatchCodex">dispatchCodex</a></li><li><a href="global.html#dispatchOpencode">dispatchOpencode</a></li><li><a href="global.html#getCliArgs">getCliArgs</a></li><li><a href="global.html#getErrorResult">getErrorResult</a></li></ul>
32
+ <h2><a href="index.html">Home</a></h2><h3>Global</h3><ul><li><a href="global.html#WDispatchAi">WDispatchAi</a></li><li><a href="global.html#adapters">adapters</a></li><li><a href="global.html#dispatchAi">dispatchAi</a></li><li><a href="global.html#dispatchAiFallback">dispatchAiFallback</a></li><li><a href="global.html#dispatchAntigravity">dispatchAntigravity</a></li><li><a href="global.html#dispatchClaude">dispatchClaude</a></li><li><a href="global.html#dispatchCodex">dispatchCodex</a></li><li><a href="global.html#dispatchOpencode">dispatchOpencode</a></li><li><a href="global.html#getCliArgs">getCliArgs</a></li><li><a href="global.html#getErrorResult">getErrorResult</a></li><li><a href="global.html#isKeyIndependentFail">isKeyIndependentFail</a></li></ul>
33
33
 
34
34
  </nav>
35
35
 
@@ -71,7 +71,7 @@
71
71
  <br class="clear">
72
72
 
73
73
  <footer>
74
- Documentation generated by <a href="https://github.com/jsdoc3/jsdoc">JSDoc 4.0.5</a> on Sun Aug 09 2026 22:39:08 GMT+0800 (台北標準時間) using the <a href="https://github.com/clenemt/docdash">docdash</a> theme.
74
+ Documentation generated by <a href="https://github.com/jsdoc3/jsdoc">JSDoc 4.0.5</a> on Mon Aug 10 2026 09:36:49 GMT+0800 (台北標準時間) using the <a href="https://github.com/clenemt/docdash">docdash</a> theme.
75
75
  </footer>
76
76
 
77
77
  <script>prettyPrint();</script>
package/g.mjs CHANGED
@@ -27,7 +27,7 @@ let test = async () => {
27
27
 
28
28
  //可用之AI供應商種類
29
29
  console.log('KINDS:', wdi.KINDS)
30
- // => KINDS: [ 'opencode', 'claude', 'codex' ]
30
+ // => KINDS: [ 'opencode', 'claude', 'codex', 'antigravity' ]
31
31
 
32
32
  let prompt = '請只回覆兩個字:完成,不要有任何其他文字'
33
33
 
@@ -46,6 +46,11 @@ let test = async () => {
46
46
  console.log('opencode:', r3.ok, r3.stdout.trim())
47
47
  // => opencode: true 完成
48
48
 
49
+ //以antigravity CLI(agy)呼叫, prompt走--print旗標(長度上限30000字元), model須為`agy models`第一欄slug
50
+ let r3b = await wdi.dispatchAntigravity(prompt, { model: 'gemini-3.6-flash-low' })
51
+ console.log('antigravity:', r3b.ok, r3b.stdout.trim())
52
+ // => antigravity: true 完成
53
+
49
54
  //以供應商條目輪替, 一個條目即一組(kind, model, 可選的key與provider與config), 輪到誰就用誰的CLI與模型
50
55
  //opencode支援逐次注入金鑰, 故同一provider之多把金鑰可各成一個條目
51
56
  let items = [
@@ -54,6 +59,7 @@ let test = async () => {
54
59
  { kind: 'opencode', model: 'opencode/deepseek-v4-flash-free', provider: 'opencode', key: opencodeKeys[0], timeoutMs: 180000 },
55
60
  { kind: 'opencode', model: 'opencode/deepseek-v4-flash-free', provider: 'opencode', key: opencodeKeys[1], timeoutMs: 180000 },
56
61
  { kind: 'opencode', model: 'agnes-ai/agnes-2.0-flash', provider: 'agnes-ai', key: agnesKeys[0], config: configAgnes, timeoutMs: 180000 },
62
+ { kind: 'antigravity', model: 'gemini-3.6-flash-low' },
57
63
  ]
58
64
  for (let item of items) {
59
65
  let r = await wdi.dispatchAi(item.kind, prompt, item)
@@ -63,12 +69,13 @@ let test = async () => {
63
69
  // => dispatchAi opencode/deepseek-v4-flash-free: true 完成
64
70
  // => dispatchAi opencode/deepseek-v4-flash-free: true 完成
65
71
  // => dispatchAi agnes-ai/agnes-2.0-flash: true 完成
72
+ // => dispatchAi gemini-3.6-flash-low: true 完成
66
73
  }
67
74
 
68
75
  //未知供應商回傳error結果物件, 不會reject
69
76
  let r4 = await wdi.dispatchAi('gemini', prompt)
70
77
  console.log('invalid kind:', r4.ok, r4.error)
71
- // => invalid kind: false unknown ai kind: "gemini" (available: opencode, claude, codex)
78
+ // => invalid kind: false unknown ai kind: "gemini" (available: opencode, claude, codex, antigravity)
72
79
 
73
80
  //prompt非有效字串亦回傳error結果物件
74
81
  let r5 = await wdi.dispatchClaude('')
@@ -84,6 +91,43 @@ let test = async () => {
84
91
  console.log('invalid key:', r6.ok, r6.code, r6.error, r6.stderr.includes('Invalid API key'))
85
92
  // => invalid key: false 1 Exit code 1 true
86
93
 
94
+ //多供應商自動遞補: providers順序即優先序, 組內keys以游標輪替
95
+ //此例第1把金鑰無效 → 自動換組內下一把成功; 若整組用盡會遞補下一組(claude), 再失敗遞補codex
96
+ let r7 = await wdi.dispatchAiFallback(prompt, {
97
+ providers: [
98
+ {
99
+ id: 'deepseek',
100
+ kind: 'opencode',
101
+ model: 'opencode/deepseek-v4-flash-free',
102
+ provider: 'opencode',
103
+ keys: ['sk-invalid-key-demo', opencodeKeys[0]], //第1把無效, 示範組內輪替
104
+ timeoutMs: 180000,
105
+ },
106
+ {
107
+ id: 'agnes',
108
+ kind: 'opencode',
109
+ model: 'agnes-ai/agnes-2.0-flash',
110
+ provider: 'agnes-ai',
111
+ keys: agnesKeys,
112
+ config: configAgnes, //第三方provider須另給定義
113
+ timeoutMs: 180000,
114
+ },
115
+ { id: 'claude', kind: 'claude', model: 'sonnet' },
116
+ { id: 'codex', kind: 'codex', model: 'gpt-5.6-luna', sandbox: 'read-only' },
117
+ { id: 'antigravity', kind: 'antigravity', model: 'gemini-3.6-flash-low' },
118
+ ],
119
+ budgetMs: 600000,
120
+ onEvent: (ev) => console.log(' event:', ev.type, ev.keyId, ev.error || ''),
121
+ })
122
+ console.log('fallback:', r7.ok, r7.providerId, r7.keyIndex, r7.stdout.trim())
123
+ console.log('tried:', r7.tried.map((x) => `${x.keyId}:${x.outcome}`).join(', '))
124
+ // => event: try deepseek#0
125
+ // => event: next-key deepseek#0 Exit code 1
126
+ // => event: try deepseek#1
127
+ // => event: ok deepseek#1
128
+ // => fallback: true deepseek 1 完成
129
+ // => tried: deepseek#0:next-key, deepseek#1:ok
130
+
87
131
  }
88
132
  await test()
89
133
  .catch((err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "w-dispatch-ai",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "main": "dist/w-dispatch-ai.umd.js",
5
5
  "dependencies": {
6
6
  "wsemi": "^1.8.70"
@@ -1,9 +1,11 @@
1
1
  import keys from 'lodash-es/keys.js'
2
2
  import adapters from './adapters.mjs'
3
3
  import dispatchAi from './dispatchAi.mjs'
4
+ import dispatchAiFallback from './dispatchAiFallback.mjs'
4
5
  import dispatchOpencode from './dispatchOpencode.mjs'
5
6
  import dispatchClaude from './dispatchClaude.mjs'
6
7
  import dispatchCodex from './dispatchCodex.mjs'
8
+ import dispatchAntigravity from './dispatchAntigravity.mjs'
7
9
 
8
10
 
9
11
  // WDispatchAi.mjs — AI供應商分派層
@@ -20,18 +22,20 @@ let KINDS = keys(adapters)
20
22
  /**
21
23
  * AI供應商分派
22
24
  *
23
- * @returns {Object} 回傳物件,其內含KINDS(可用供應商種類字串陣列),以及dispatchAi、dispatchOpencode、dispatchClaude、dispatchCodex之async函數
25
+ * @returns {Object} 回傳物件,其內含KINDS(可用供應商種類字串陣列),以及dispatchAi、dispatchAiFallback、dispatchOpencode、dispatchClaude、dispatchCodex、dispatchAntigravity之async函數
24
26
  * @example
25
27
  *
26
- * 詳見dispatchAi、dispatchOpencode、dispatchClaude、dispatchCodex範例
28
+ * 詳見dispatchAi、dispatchAiFallback、dispatchOpencode、dispatchClaude、dispatchCodex、dispatchAntigravity範例
27
29
  *
28
30
  */
29
31
  let WDispatchAi = {
30
32
  KINDS,
31
33
  dispatchAi,
34
+ dispatchAiFallback,
32
35
  dispatchOpencode,
33
36
  dispatchClaude,
34
37
  dispatchCodex,
38
+ dispatchAntigravity,
35
39
  }
36
40
 
37
41
 
package/src/adapters.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import dispatchOpencode from './dispatchOpencode.mjs'
2
2
  import dispatchClaude from './dispatchClaude.mjs'
3
3
  import dispatchCodex from './dispatchCodex.mjs'
4
+ import dispatchAntigravity from './dispatchAntigravity.mjs'
4
5
 
5
6
 
6
7
  /**
@@ -15,13 +16,14 @@ import dispatchCodex from './dispatchCodex.mjs'
15
16
  * import adapters from './src/adapters.mjs'
16
17
  *
17
18
  * console.log(Object.keys(adapters))
18
- * // => ['opencode', 'claude', 'codex']
19
+ * // => ['opencode', 'claude', 'codex', 'antigravity']
19
20
  *
20
21
  */
21
22
  let adapters = {
22
23
  opencode: dispatchOpencode,
23
24
  claude: dispatchClaude,
24
25
  codex: dispatchCodex,
26
+ antigravity: dispatchAntigravity,
25
27
  }
26
28
 
27
29
 
@@ -0,0 +1,334 @@
1
+ import get from 'lodash-es/get.js'
2
+ import omit from 'lodash-es/omit.js'
3
+ import isarr from 'wsemi/src/isarr.mjs'
4
+ import isobj from 'wsemi/src/isobj.mjs'
5
+ import isfun from 'wsemi/src/isfun.mjs'
6
+ import isestr from 'wsemi/src/isestr.mjs'
7
+ import ispint from 'wsemi/src/ispint.mjs'
8
+ import cint from 'wsemi/src/cint.mjs'
9
+ import dispatchAi from './dispatchAi.mjs'
10
+ import getErrorResult from './getErrorResult.mjs'
11
+
12
+
13
+ // dispatchAiFallback.mjs — 多供應商自動遞補層
14
+ //
15
+ // 【兩層策略】群組之間依providers宣告順序(優先序), 群組之內(keys多把)以游標輪替(額度均攤)。
16
+ //
17
+ // 【失敗分流】只分兩路:
18
+ // 與金鑰無關之失敗(TIMEOUT/ENOENT/參數錯誤/驗證失敗/未知kind) → 整組跳過——
19
+ // 同組各金鑰共用同一exe與model, 換金鑰必然再敗一次, 純屬空耗;
20
+ // 其餘失敗(含額度上限/金鑰無效/服務回錯等一切未分類者) → 換組內下一把, 不記憶、不停用。
21
+ // 不可把正確性建立在「錯誤分類器必須窮盡」之上——實測各家額度/金鑰錯誤訊息
22
+ // 含中文(无效的令牌)與無特徵字串(UnknownError), 正則涵蓋不了; 而額度視窗有5小時滾動、
23
+ // 逐小時、逐日等多種形態, 「命中即停用到當日結束」會把已復活的金鑰冰到隔天。
24
+ // 故跨次執行不設停用清單: 額度恢復的偵測就是「下次再打一次」, 代價僅一次快速失敗。
25
+ //
26
+ // 【跨次記憶只有游標】成功後游標推進至下一把, 令額度在同組多把金鑰間自動均攤;
27
+ // 死金鑰的代價也被游標攤平——這輪從key1敗轉key2成功後游標停在key3, 下輪不會先碰key1。
28
+ //
29
+ // 【時間預算】budgetMs限制整輪遞補的總時長, 剩餘預算會壓進每次呼叫的timeoutMs,
30
+ // 防止多家連續卡逾時而撞破外部排程的執行上限。
31
+
32
+
33
+ //fallback層自用之設定鍵, 其餘鍵作為各attempt之共用預設原樣轉傳
34
+ let FALLBACK_KEYS = ['providers', 'budgetMs', 'minAttemptMs', 'store', 'onEvent']
35
+
36
+
37
+ //providers條目自用之設定鍵, 其餘鍵(含kind)即該條目之opt原樣轉傳對應轉接器
38
+ let ENTRY_KEYS = ['id', 'keys']
39
+
40
+
41
+ //預設值
42
+ let DEFAULT_MIN_ATTEMPT_MS = 20000
43
+ let DEFAULT_TIMEOUT_MS = 120000
44
+
45
+
46
+ //memoryState, 未注入store時之行程內預設狀態(跨呼叫有效, 重啟歸零)
47
+ let memoryState = { cursors: {} }
48
+
49
+
50
+ /**
51
+ * 判斷失敗結果是否與「哪一把金鑰」無關(換組內金鑰必然再敗, 應整組跳過)
52
+ *
53
+ * @param {Object} r 輸入dispatchAi失敗結果物件
54
+ * @returns {Boolean} 回傳是否應整組跳過之布林值
55
+ */
56
+ function isKeyIndependentFail(r) {
57
+
58
+ let error = get(r, 'error', '')
59
+ if (!isestr(error)) {
60
+ error = ''
61
+ }
62
+ let code = get(r, 'code', null)
63
+
64
+ //逾時, 該服務卡住, 同服務其他金鑰只會再空耗一次完整timeout
65
+ if (error.indexOf('TIMEOUT') === 0) {
66
+ return true
67
+ }
68
+
69
+ //執行檔不存在, 同組共用同一exe
70
+ if (error.includes('ENOENT')) {
71
+ return true
72
+ }
73
+
74
+ //參數錯誤, 同組共用同一組旗標
75
+ if (code === 2) {
76
+ return true
77
+ }
78
+
79
+ //輸出未過驗證, CLI正常結束(code=0)且模型有回應, 換金鑰仍是同一模型之產出習慣
80
+ //注意判定依據是error字串而非code===0(code===0且ok===true是成功)
81
+ if (error === 'OUTPUT_VALIDATION_FAILED') {
82
+ return true
83
+ }
84
+
85
+ //kind無效, 屬條目設定錯誤
86
+ if (error.indexOf('unknown ai kind') === 0) {
87
+ return true
88
+ }
89
+
90
+ return false
91
+ }
92
+
93
+
94
+ /**
95
+ * 依供應商清單順序自動遞補調用AI,組內多金鑰以游標輪替
96
+ *
97
+ * 特點:
98
+ * providers陣列順序即優先序,排前面的先用;
99
+ * 條目本身即該次調用之opt(除id與keys外原樣透傳對應轉接器),與dispatchAi「條目直接當opt」同一約定;
100
+ * 條目給予keys(多把金鑰)時以游標輪替,某把失敗自動換下一把,全數失敗才遞補下一組;
101
+ * 與金鑰無關之失敗(逾時/執行檔不存在/參數錯誤/輸出未過驗證/未知kind)直接整組跳過,不逐把空耗;
102
+ * 跨次執行僅記憶游標(經store注入持久化),不設金鑰停用清單——額度視窗形態多樣(5小時滾動/逐時/逐日),
103
+ * 停用會把已恢復的金鑰閒置,而重探的代價僅一次快速失敗;
104
+ * 本函數不會reject,一律以結果物件之ok與error欄位回報成敗
105
+ *
106
+ * @param {String} prompt 輸入提示詞字串,一律以stdin傳入子進程
107
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
108
+ * @param {Array} opt.providers 輸入供應商條目物件陣列,順序即優先序。各條目除下列鍵外,其餘鍵(kind、model、exe、provider、config、sandbox、timeoutMs等)即該條目之opt原樣透傳對應轉接器
109
+ * @param {String} [opt.providers[].id=條目索引字串] 輸入群組識別字串,游標以此為鍵,多金鑰條目應給予穩定id,預設為條目索引字串
110
+ * @param {Array} [opt.providers[].keys=[]] 輸入同一服務之多把API key字串陣列,逐次注入輪替(kind為opencode時須同時於條目給予provider),省略代表沿用CLI既有登入狀態之單一虛擬金鑰
111
+ * @param {Number} [opt.budgetMs=null] 輸入整輪遞補之時間上限毫秒正整數,剩餘預算會壓進每次呼叫之timeoutMs,預設null代表不限
112
+ * @param {Number} [opt.minAttemptMs=20000] 輸入單次嘗試之最低剩餘預算毫秒正整數,剩餘低於此值即停止嘗試回報budget exhausted,預設20000
113
+ * @param {Object} [opt.store=null] 輸入狀態持久化物件{get:()=>state,set:(state)=>{}},state內含cursors(逐群組游標),省略代表用行程內記憶體(跨呼叫有效,重啟歸零)。假定單行程序列調用,並行請自行加鎖
114
+ * @param {Function} [opt.onEvent=null] 輸入事件回調函數(ev)=>{},ev.type可為'try'、'ok'、'next-key'、'skip-group'、'budget-out',回調拋出例外不影響主流程,預設null
115
+ * @param {Number} [opt.timeoutMs=120000] 輸入各attempt共用之逾時毫秒正整數,條目可覆寫,預設120000
116
+ * @param {String|Function} [opt.validate=undefined] 輸入各attempt共用之stdout驗證規則,條目可覆寫,預設undefined
117
+ * @param {Number} [opt.maxRetries=0] 輸入各attempt共用之同家重試次數非負整數,韌性建議交給換家而非重試同一家,預設0
118
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件,除execCli既有欄位(ok、stdout、stderr、code、error、durationMs、attempts、pid)外,追加providerId(實際使用之群組)、keyIndex(實際使用之金鑰索引,無keys時為null)、kind、model、tried(全部嘗試歷程陣列,成功時亦回傳),本函數不會reject
119
+ * @example
120
+ * //need opencode, claude, codex cli in system PATH
121
+ *
122
+ * import dispatchAiFallback from './src/dispatchAiFallback.mjs'
123
+ *
124
+ * let test = async () => {
125
+ *
126
+ * let r = await dispatchAiFallback('請只回覆兩個字:完成', {
127
+ * providers: [
128
+ * {
129
+ * id: 'deepseek',
130
+ * kind: 'opencode',
131
+ * model: 'opencode/deepseek-v4-flash-free',
132
+ * provider: 'opencode',
133
+ * keys: ['sk-aaa', 'sk-bbb'], //多把金鑰, 某把失敗自動換下一把
134
+ * timeoutMs: 180000,
135
+ * },
136
+ * { id: 'claude', kind: 'claude', model: 'sonnet' }, //deepseek全敗時遞補
137
+ * { id: 'codex', kind: 'codex', model: 'gpt-5.6-luna', sandbox: 'read-only' },
138
+ * ],
139
+ * budgetMs: 600000,
140
+ * onEvent: (ev) => console.log(ev.type, ev.providerId, ev.keyIndex),
141
+ * })
142
+ * console.log(r.ok, r.providerId, r.keyIndex, r.tried.length)
143
+ * // => true 'deepseek' 0 1
144
+ *
145
+ * }
146
+ * await test()
147
+ * .catch((err) => {
148
+ * console.log(err)
149
+ * })
150
+ *
151
+ */
152
+ async function dispatchAiFallback(prompt, opt = {}) {
153
+
154
+ //check prompt, 於頂層先擋, 否則會逐組空跑同一錯誤
155
+ if (!isestr(prompt)) {
156
+ return { ...getErrorResult('prompt must be a non-empty string'), tried: [] }
157
+ }
158
+
159
+ //providers, 濾除非物件條目
160
+ let providersRaw = get(opt, 'providers', null)
161
+ let providers = isarr(providersRaw) ? providersRaw.filter(isobj) : []
162
+ if (providers.length === 0) {
163
+ return { ...getErrorResult('providers must be a non-empty array'), tried: [] }
164
+ }
165
+
166
+ //budgetMs, 無效視為不限
167
+ let budgetMs = get(opt, 'budgetMs', null)
168
+ if (!ispint(budgetMs)) {
169
+ budgetMs = null
170
+ }
171
+ else {
172
+ budgetMs = cint(budgetMs)
173
+ }
174
+ let deadline = (budgetMs === null) ? null : Date.now() + budgetMs
175
+
176
+ //minAttemptMs, 無效回退預設20000
177
+ let minAttemptMs = get(opt, 'minAttemptMs', null)
178
+ if (!ispint(minAttemptMs)) {
179
+ minAttemptMs = DEFAULT_MIN_ATTEMPT_MS
180
+ }
181
+ else {
182
+ minAttemptMs = cint(minAttemptMs)
183
+ }
184
+
185
+ //store, 須同時具get與set函數才視為有效, 否則用行程內記憶體
186
+ let store = get(opt, 'store', null)
187
+ let useStore = isobj(store) && isfun(store.get) && isfun(store.set)
188
+ let state = null
189
+ if (useStore) {
190
+ try {
191
+ state = store.get()
192
+ }
193
+ catch {}
194
+ }
195
+ if (!isobj(state)) {
196
+ state = useStore ? { cursors: {} } : memoryState
197
+ }
198
+ if (!isobj(state.cursors)) {
199
+ state.cursors = {}
200
+ }
201
+ let saveState = () => {
202
+ if (useStore) {
203
+ try {
204
+ store.set(state)
205
+ }
206
+ catch {}
207
+ }
208
+ }
209
+
210
+ //onEvent, 回調拋出例外不得中斷主流程
211
+ let onEvent = get(opt, 'onEvent', null)
212
+ let emit = (ev) => {
213
+ if (isfun(onEvent)) {
214
+ try {
215
+ onEvent(ev)
216
+ }
217
+ catch {}
218
+ }
219
+ }
220
+
221
+ //sharedOpt, 剔除fallback層自用鍵後作為各attempt共用預設, 條目覆寫之
222
+ let sharedOpt = omit(opt, FALLBACK_KEYS)
223
+
224
+ let tried = []
225
+ let lastResult = null
226
+ let lastMeta = null
227
+
228
+ //逐群組(宣告順序即優先序), 單向單輪不回頭
229
+ for (let ig = 0; ig < providers.length; ig++) {
230
+ let entry = providers[ig]
231
+
232
+ //id, 無效回退條目索引字串
233
+ let id = get(entry, 'id', null)
234
+ if (!isestr(id)) {
235
+ id = String(ig)
236
+ }
237
+
238
+ //kind與model僅供事件與回傳meta, kind有效性由dispatchAi判定
239
+ let kind = get(entry, 'kind', null)
240
+ let model = get(entry, 'model', null)
241
+
242
+ //keys, 濾除非有效字串, 空陣列視同未給(登入態單一虛擬金鑰)
243
+ let keysRaw = get(entry, 'keys', null)
244
+ let keys = isarr(keysRaw) ? keysRaw.filter(isestr) : []
245
+ let nk = keys.length
246
+
247
+ //entryOpt, 剔除條目自用鍵後即該條目之opt
248
+ let entryOpt = omit(entry, ENTRY_KEYS)
249
+
250
+ //游標, 逐群組記錄, 以現行keys長度取模自癒(金鑰陣列改動時不出界)
251
+ let cursor = 0
252
+ if (nk > 0) {
253
+ let c = get(state.cursors, id, 0)
254
+ cursor = ispint(c) ? cint(c) % nk : 0
255
+ }
256
+
257
+ //組內逐把嘗試, 每把至多一次, 全敗即組盡遞補下一組
258
+ let nAttempts = (nk > 0) ? nk : 1
259
+ let skipGroup = false
260
+ for (let a = 0; a < nAttempts && !skipGroup; a++) {
261
+
262
+ //keyIndex, 無keys時為null
263
+ let keyIndex = (nk > 0) ? (cursor + a) % nk : null
264
+ let keyId = (keyIndex === null) ? id : `${id}#${keyIndex}`
265
+
266
+ //attemptOpt, 共用預設 <- 條目覆寫 <- 本把金鑰
267
+ let attemptOpt = { ...sharedOpt, ...entryOpt }
268
+ if (nk > 0) {
269
+ attemptOpt.key = keys[keyIndex]
270
+ }
271
+
272
+ //有效timeout, 有預算時以剩餘預算封頂
273
+ let effTimeout = get(attemptOpt, 'timeoutMs', null)
274
+ if (!ispint(effTimeout)) {
275
+ effTimeout = DEFAULT_TIMEOUT_MS
276
+ }
277
+ else {
278
+ effTimeout = cint(effTimeout)
279
+ }
280
+ if (deadline !== null) {
281
+ let remaining = deadline - Date.now()
282
+
283
+ //剩餘不足一次最低嘗試, 停止遞補回報預算用盡
284
+ if (remaining < minAttemptMs) {
285
+ emit({ type: 'budget-out', providerId: id, keyIndex, keyId, remainingMs: remaining })
286
+ tried.push({ providerId: id, keyIndex, keyId, outcome: 'budget-out' })
287
+ return { ...getErrorResult('budget exhausted'), tried }
288
+ }
289
+ effTimeout = Math.min(effTimeout, remaining)
290
+ }
291
+ attemptOpt.timeoutMs = effTimeout
292
+
293
+ //dispatch
294
+ emit({ type: 'try', providerId: id, keyIndex, keyId, kind, model })
295
+ let r = await dispatchAi(kind, prompt, attemptOpt)
296
+
297
+ //成功, 推進游標(額度均攤)並回傳
298
+ if (r.ok) {
299
+ if (nk > 0) {
300
+ state.cursors[id] = (keyIndex + 1) % nk
301
+ saveState()
302
+ }
303
+ emit({ type: 'ok', providerId: id, keyIndex, keyId, durationMs: r.durationMs })
304
+ tried.push({ providerId: id, keyIndex, keyId, outcome: 'ok', durationMs: r.durationMs })
305
+ return { ...r, providerId: id, keyIndex, kind, model, tried }
306
+ }
307
+
308
+ //失敗分流
309
+ lastResult = r
310
+ lastMeta = { providerId: id, keyIndex, kind, model }
311
+ if (isKeyIndependentFail(r)) {
312
+
313
+ //與金鑰無關, 整組跳過
314
+ emit({ type: 'skip-group', providerId: id, keyIndex, keyId, error: r.error })
315
+ tried.push({ providerId: id, keyIndex, keyId, outcome: 'skip-group', error: r.error, durationMs: r.durationMs })
316
+ skipGroup = true
317
+ }
318
+ else {
319
+
320
+ //其餘(含額度上限/金鑰無效/未分類), 換組內下一把, 不記憶不停用
321
+ emit({ type: 'next-key', providerId: id, keyIndex, keyId, error: r.error })
322
+ tried.push({ providerId: id, keyIndex, keyId, outcome: 'next-key', error: r.error, durationMs: r.durationMs })
323
+ }
324
+
325
+ }
326
+ }
327
+
328
+ //全數失敗, 回傳最後一筆失敗結果與完整歷程
329
+ let r = lastResult || getErrorResult('all providers failed')
330
+ return { ...r, ...(lastMeta || {}), tried }
331
+ }
332
+
333
+
334
+ export default dispatchAiFallback