dispatchAiFallback.mjs

import get from 'lodash-es/get.js'
import omit from 'lodash-es/omit.js'
import isarr from 'wsemi/src/isarr.mjs'
import isobj from 'wsemi/src/isobj.mjs'
import isfun from 'wsemi/src/isfun.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import ispint from 'wsemi/src/ispint.mjs'
import cint from 'wsemi/src/cint.mjs'
import dispatchAi from './dispatchAi.mjs'
import getErrorResult from './getErrorResult.mjs'
import castPintOr from './castPintOr.mjs'
import dfTimeoutMs from './dfTimeoutMs.mjs'
import { BODY_NOT_JSON } from './describeNonJsonBody.mjs'


// dispatchAiFallback.mjs — 多供應商自動遞補層
//
// 【兩層策略】群組之間依providers宣告順序(優先序), 群組之內(keys多把)以游標輪替(額度均攤)。
//
// 【失敗分流】只分兩路:
//   與金鑰無關之失敗(TIMEOUT/ENOENT/參數錯誤/驗證失敗/未知kind/截斷/工具不支援/本體非JSON) → 整組跳過——
//   同組各金鑰共用同一exe與model, 換金鑰必然再敗一次, 純屬空耗;
//   本體非JSON(REST之HTTP 200但無法解析, 見describeNonJsonBody.mjs)屬傳輸或閘道狀態, 2026-09-24依安裝方實例納入
//   (換第二把金鑰只是以剩餘預算重打至逾時); 「JSON缺欄位」不在此列, 仍換金鑰(部分閘道以200回帳號層級錯誤);
//   截斷(結果之truncated為true, 僅REST文字類可判, 見checkTruncation.mjs)與工具不支援(TOOL_CALLS_UNSUPPORTED)
//   皆屬模型對同一請求之產出性質, 2026-09-24起納入(前者由複審指出同模型換金鑰再截斷一次;
//   後者之既有測試標題即寫「不逐把空耗」而斷言卻為逐把換金鑰, 一併更正);
//   REST之status=failed(服務回錯)不屬截斷, 維持換金鑰;
//   其餘失敗(含額度上限/金鑰無效/服務回錯等一切未分類者) → 換組內下一把, 不記憶、不停用。
//   不可把正確性建立在「錯誤分類器必須窮盡」之上——實測各家額度/金鑰錯誤訊息
//   含中文(无效的令牌)與無特徵字串(UnknownError), 正則涵蓋不了; 而額度視窗有5小時滾動、
//   逐小時、逐日等多種形態, 「命中即停用到當日結束」會把已復活的金鑰冰到隔天。
//   故跨次執行不設停用清單: 額度恢復的偵測就是「下次再打一次」, 代價僅一次快速失敗。
//
// 【跨次記憶只有游標】成功後游標推進至下一把, 令額度在同組多把金鑰間自動均攤;
//   死金鑰的代價也被游標攤平——這輪從key1敗轉key2成功後游標停在key3, 下輪不會先碰key1。
//
// 【供應商冷卻(選用, cooldownMs>0啟用, 預設關閉)】多階段工作流的每一階段都會從鏈首
//   重新探測同一家已失效的供應商——限流時每階段各踩一輪429、卡死時各燒一次完整逾時
//   (使用端實測: 一次107秒的多階段請求中72秒耗在重複踩同一組429, 啟用冷卻後降至15秒)。
//   設計與「金鑰停用清單」(已否決)的關鍵差異: 以「條目」為單位、短視窗、且「只降序不移除」
//   ——冷卻中的條目移到鏈尾而非移除, 前面全敗時照樣會被嘗試, 故不存在把已恢復服務冰住的問題;
//   任一次成功立即解除。內建觸發限於限流(HTTP 429, 僅api-openai-compat可靠偵測; CLI類之
//   限流埋在stderr文字中, 各家字樣不同且隨版本漂移, 本套件不維護簽章表)與逾時(TIMEOUT開頭,
//   各kind皆可)兩類——其餘失敗已有換金鑰換家機制處理, 納入反而誤傷。
//   CLI類限流之偵測採依賴注入: 呼叫端於實測中觀察到穩定字樣時, 以coolDetect(r)=>Boolean
//   自行判定(收到完整失敗結果含stderr), 命中即視同冷卻觸發——簽章表由觀察到字樣的呼叫端
//   維護, 漏判僅退回現狀(每階段重探一次), 誤判也只是降尾非移除, 兩邊代價都有上限。
//   狀態存於state.cooling, 與cursors同走store持久化。
//
// 【中止(shouldStop)】呼叫端(如server於客戶端斷線後)可注入shouldStop()=>Boolean,
//   於「每次嘗試之間」檢查, true即停止遞補回報ABORTED——把「斷線後仍空耗整條鏈」
//   縮成「至多再耗當前這一家」。檢查點只此一處: 工作流各層經omit轉傳自動獲得,
//   中止後每個後續呼叫進門即回ABORTED, 整條工作流自然快速收束, 不需逐層實作。
//   不中止進行中之嘗試(不殺子進程/不斷開請求), 此為已知設計取捨(避免侵入execCli層)。
//
// 【組盡事件(group-exhausted)】逐次事件(try/next-key/skip-group等)不帶呼叫識別, 而同一onEvent常被並行呼叫共用
//   (如runFanout之各席位)。呼叫端要判斷「某條目在這次呼叫裡整組試完仍無成交」(如健康層據以降序)時,
//   若以金鑰數與逐把失敗次數重建, 並行呼叫跨越一次成交就會多計或少計——游標只在成交時推進,
//   在途呼叫與新呼叫的起點不同(2026-09-24下游w-knowledge-extract實測重現), 且重建本身依賴本層之
//   游標推進時機、每把至多一次、金鑰濾法三項內部性質。故由本層於本組未成交而試完時直接發出,
//   每次呼叫每組恰一次; 成交、預算用盡、中止(本組未試完)皆不發——後兩者屬呼叫端的時間或意願, 非該組故障。
//   組邊界只發事件, 不寫入tried(tried為逐次嘗試歷程, 其長度即嘗試次數)。
//
// 【meta保留鍵】「剔除自用鍵後原樣轉傳」令條目即調校點, 但呼叫端放進條目/opt的任何
//   自有欄位都會被靜默轉傳——保留meta一鍵保證永不轉傳, 呼叫端要掛分類/標籤/註記
//   一律放meta, 與轉傳機制永久絕緣(工作流各層之規格物件同此約定)。
//
// 【時間預算】budgetMs限制整輪遞補的總時長, 剩餘預算會壓進每次呼叫的timeoutMs,
//   防止多家連續卡逾時而撞破外部排程的執行上限。
//
// ══ 條目id之設計規則(呼叫端負責, 本套件不解讀其內容) ══
//
//   id於本套件內只有兩個用途: 游標的物件鍵(state.cursors[id])與日誌標籤
//   (providerId、keyId=`${id}#${keyIndex}`)。不查表、不比對、無格式要求,
//   純粹是呼叫端的命名空間——故「什麼算同一個供應商」由呼叫端定義, 本套件不猜。
//
//   ① id須能區分到「模型」而非只到「廠商」
//      ✗ id:'claude' —— 日後要同時掛sonnet與opus就無法並存, 且日誌看不出用了哪個模型
//      ✓ id:'claude:sonnet' / id:'claude:opus'
//
//   ② 同一模型經不同路徑取得時, id須帶上路徑, 且前綴用「具體路徑名」不用泛稱
//      同一個laguna可經Poolside官方REST、OpenRouter、opencode CLI三條路,
//      三者額度池與故障域各自獨立, 屬三個供應商:
//      ✓ 'poolside:laguna-s-2.1' / 'or:poolside/laguna-s-2.1:free' / 'oc:poolside/poolside/laguna-s-2.1'
//      ✗ 'api:laguna-s-2.1' —— 泛稱api:在同模型有多個REST閘道時會撞名, 且日誌看不出走哪個閘道
//      慣用前綴: CLI類=oc:/agy:/claude:/codex:(即kind或CLI名); REST類=閘道名(zen:/agnes:/poolside:/or:/nv:)
//
//   ③ id務必給且務必唯一
//      未給時本套件回退為「陣列索引字串」——索引是位置不是身分, 日後於鏈中插入條目
//      會讓後續條目繼承他人的游標進度(輪替張冠李戴), 故正式設定一律明給。
//      兩個條目同id則共用同一游標且日誌無法區分, 屬設定錯誤。
//
//   ④ 同一組金鑰用於多個條目時, 各條目游標獨立
//      例如agnes的CLI版與REST版共用同一批金鑰時, 兩者各自從游標起點輪替,
//      同一把金鑰可能被連續使用而另一把閒置(帳號額度未均攤)。
//      要讓它們共享輪替進度就給相同id(代價: 日誌無法區分兩者);
//      要能區分就分開命名(代價: 額度不均攤)。此取捨由呼叫端依實際需求決定。


//fallback層自用之設定鍵, 其餘鍵作為各attempt之共用預設原樣轉傳
//meta為保留鍵: 呼叫端掛自有資訊(分類/標籤/註記)用, 保證永不轉傳(見檔頭【meta保留鍵】)
let FALLBACK_KEYS = ['providers', 'budgetMs', 'minAttemptMs', 'cooldownMs', 'shouldStop', 'coolDetect', 'store', 'onEvent', 'meta']


//providers條目自用之設定鍵, 其餘鍵(含kind)即該條目之opt原樣轉傳對應轉接器
let ENTRY_KEYS = ['id', 'keys', 'meta']


//預設值
let DEFAULT_MIN_ATTEMPT_MS = 20000
let DEFAULT_TIMEOUT_MS = dfTimeoutMs //全套件統一預設300000


//memoryState, 未注入store時之行程內預設狀態(跨呼叫有效, 重啟歸零)
let memoryState = { cursors: {} }


/**
 * 初始化游標與冷卻狀態(store有效即載入持久化狀態, 否則用行程內記憶體)
 *
 * @param {Object} store 輸入狀態持久化物件{get,set},無效代表用行程內記憶體
 * @returns {Object} 回傳物件,內含state(狀態物件,保證有cursors與cooling)與saveState(寫回函數,store無效或寫入失敗皆靜默)
 */
function initState(store) {
    let useStore = isobj(store) && isfun(store.get) && isfun(store.set)
    let state = null
    if (useStore) {
        try {
            state = store.get()
        }
        catch {}
    }
    if (!isobj(state)) {
        state = useStore ? { cursors: {} } : memoryState
    }
    if (!isobj(state.cursors)) {
        state.cursors = {}
    }
    if (!isobj(state.cooling)) {
        state.cooling = {}
    }
    let saveState = () => {
        if (useStore) {
            try {
                store.set(state)
            }
            catch {}
        }
    }
    return { state, saveState }
}


/**
 * 依冷卻狀態重排providers:冷卻中的條目「只降序不移除」——移到鏈尾, 前面全敗時仍會被嘗試,
 * 故不存在把已恢復服務冰住的問題(此為與「金鑰停用清單」的關鍵差異, 後者已被否決)。
 * 僅追蹤有明給id之條目(索引式id會因重排而錯位); 過期紀錄順手清除並寫回
 *
 * @param {Array} providers 輸入供應商條目陣列
 * @param {Object} state 輸入狀態物件(取其cooling)
 * @param {Number} cooldownMs 輸入冷卻視窗毫秒正整數
 * @param {Function} saveState 輸入狀態寫回函數
 * @returns {Array} 回傳重排後之條目陣列(active在前, 冷卻中殿後, 各自保持原相對順序)
 */
function reorderByCooling(providers, state, cooldownMs, saveState) {
    let now = Date.now()
    let act = []
    let cool = []
    let dirty = false
    for (let p of providers) {
        let pid = get(p, 'id', null)
        let ts = isestr(pid) ? get(state.cooling, pid, null) : null
        if (ispint(ts) && (now - ts) < cooldownMs) {
            cool.push(p)
        }
        else {
            if (isestr(pid) && state.cooling[pid] !== undefined) {
                delete state.cooling[pid] //冷卻已過期, 清除
                dirty = true
            }
            act.push(p)
        }
    }
    if (dirty) {
        saveState()
    }
    return [...act, ...cool]
}


/**
 * 取結果之截斷資訊供tried各項記錄(REST文字類轉接器才帶, 其餘kind回空物件)
 *
 * @param {Object} r 輸入dispatchAi結果物件
 * @returns {Object} 回傳物件,含truncated與finishReason,或空物件
 */
function finOf(r) {
    return (get(r, 'truncated', undefined) !== undefined) ? { truncated: r.truncated, finishReason: r.finishReason } : {}
}


/**
 * 判斷失敗結果是否與「哪一把金鑰」無關(換組內金鑰必然再敗, 應整組跳過)
 *
 * @param {Object} r 輸入dispatchAi失敗結果物件
 * @returns {Boolean} 回傳是否應整組跳過之布林值
 */
function isKeyIndependentFail(r) {

    let error = get(r, 'error', '')
    if (!isestr(error)) {
        error = ''
    }
    let code = get(r, 'code', null)

    //逾時, 該服務卡住, 同服務其他金鑰只會再空耗一次完整timeout
    if (error.indexOf('TIMEOUT') === 0) {
        return true
    }

    //執行檔不存在, 同組共用同一exe
    if (error.includes('ENOENT')) {
        return true
    }

    //參數錯誤, 同組共用同一組旗標
    if (code === 2) {
        return true
    }

    //輸出未過驗證, CLI正常結束(code=0)且模型有回應, 換金鑰仍是同一模型之產出習慣
    //注意判定依據是error字串而非code===0(code===0且ok===true是成功)
    if (error === 'OUTPUT_VALIDATION_FAILED') {
        return true
    }

    //截斷, 同模型同請求換金鑰必然再截斷(依機械旗標truncated判定, 不依errorType——REST之status=failed亦為incomplete但非截斷)
    if (get(r, 'truncated', false) === true) {
        return true
    }

    //模型回工具呼叫而api類不支援, 屬模型對同一請求之產出性質, 換金鑰仍是同樣產出
    if (error.indexOf('TOOL_CALLS_UNSUPPORTED') === 0) {
        return true
    }

    //HTTP 200但本體非JSON, 屬傳輸或閘道狀態, 換金鑰必然再敗(「JSON缺欄位」不在此列, 仍換金鑰; 見describeNonJsonBody.mjs)
    if (error.indexOf(BODY_NOT_JSON) === 0) {
        return true
    }

    //kind無效, 屬條目設定錯誤
    if (error.indexOf('unknown ai kind') === 0) {
        return true
    }

    return false
}


/**
 * 依供應商清單順序自動遞補調用AI,組內多金鑰以游標輪替
 *
 * 特點:
 * providers陣列順序即優先序,排前面的先用;
 * 條目本身即該次調用之opt(除id與keys外原樣透傳對應轉接器),與dispatchAi「條目直接當opt」同一約定;
 * 條目給予keys(多把金鑰)時以游標輪替,某把失敗自動換下一把,全數失敗才遞補下一組;
 * 與金鑰無關之失敗(逾時/執行檔不存在/參數錯誤/輸出未過驗證/未知kind/截斷/工具不支援/HTTP 200但本體非JSON)直接整組跳過,不逐把空耗;
 * 跨次執行僅記憶游標(經store注入持久化),不設金鑰停用清單——額度視窗形態多樣(5小時滾動/逐時/逐日),
 * 停用會把已恢復的金鑰閒置,而重探的代價僅一次快速失敗;
 * 本函數不會reject,一律以結果物件之ok與error欄位回報成敗
 *
 * @param {String} prompt 輸入提示詞字串,一律以stdin傳入子進程
 * @param {Object} [opt={}] 輸入設定物件,預設{}
 * @param {Array} opt.providers 輸入供應商條目物件陣列,順序即優先序。各條目除下列鍵外,其餘鍵(kind、model、exe、provider、config、sandbox、timeoutMs等)即該條目之opt原樣透傳對應轉接器
 * @param {String} [opt.providers[].id=條目索引字串] 輸入群組識別字串,游標以此為鍵、亦為日誌標籤,本套件不解讀其內容。須區分到「模型」而非只到「廠商」(如'claude:sonnet'而非'claude'),同一模型經不同路徑取得時須帶上路徑(如'poolside:laguna-s-2.1'與'or:poolside/laguna-s-2.1:free'),且務必唯一。省略時回退為陣列索引字串——索引是位置不是身分,日後插入條目會令後續條目繼承他人游標進度,故正式設定一律明給。詳見本檔檔頭之id設計規則
 * @param {Array} [opt.providers[].keys=[]] 輸入同一服務之多把API key字串陣列,逐次注入輪替(kind為opencode時須同時於條目給予provider),省略代表沿用CLI既有登入狀態之單一虛擬金鑰
 * @param {*} [opt.providers[].meta=undefined] 輸入呼叫端自有資訊(分類、標籤、註記),保留鍵保證永不轉傳對應轉接器——條目其餘鍵一律原樣轉傳,自有欄位放此鍵可與轉傳機制永久絕緣,預設undefined
 * @param {Number} [opt.budgetMs=null] 輸入整輪遞補之時間上限毫秒正整數,剩餘預算會壓進每次呼叫之timeoutMs,預設null代表不限
 * @param {Number} [opt.minAttemptMs=20000] 輸入單次嘗試之最低剩餘預算毫秒正整數,剩餘低於此值即停止嘗試回報budget exhausted,預設20000
 * @param {Object} [opt.store=null] 輸入狀態持久化物件{get:()=>state,set:(state)=>{}},state內含cursors(逐群組游標)與cooling(供應商冷卻時間戳,僅cooldownMs>0時使用),省略代表用行程內記憶體(跨呼叫有效,重啟歸零)。假定單行程序列調用,並行請自行加鎖
 * @param {Number} [opt.cooldownMs=0] 輸入供應商冷卻視窗毫秒非負整數,>0啟用:條目(限有明給id者)遭遇限流(HTTP 429,僅api類可偵測;CLI類可經coolDetect注入判定)或逾時(TIMEOUT開頭)後,於冷卻視窗內之後續呼叫中被移至鏈尾——只降序不移除,前面全敗時仍會被嘗試,任一次成功立即解除;注意啟用時「providers順序即優先序」會被暫時重排,此即本機制之目的;預設0代表不啟用
 * @param {Function} [opt.coolDetect=null] 輸入冷卻觸發判定函數(r)=>Boolean,收完整失敗結果物件(含stdout、stderr、code、error),回傳true即視同冷卻觸發(內建429/TIMEOUT觸發不受影響)——CLI類限流埋在stderr且各家字樣不同,簽章表由觀察到字樣的呼叫端維護,如(r)=>/FreeUsageLimitError/i.test(r.stderr||'');僅cooldownMs>0時有效,回調拋出例外視同false,預設null
 * @param {Function} [opt.shouldStop=null] 輸入中止判定函數()=>Boolean,於每次嘗試之間檢查,回傳true即停止遞補回報ABORTED(不中止進行中之嘗試)——供呼叫端於成果已無人接收時(如客戶端斷線)止損;經工作流層原樣轉傳,中止後各後續呼叫進門即回ABORTED令整條工作流快速收束;回調拋出例外視同false,預設null
 * @param {*} [opt.meta=undefined] 輸入呼叫端自有資訊,保留鍵保證永不轉傳各轉接器,預設undefined
 * @param {Function} [opt.onEvent=null] 輸入事件回調函數(ev)=>{},ev.type可為'try'、'ok'、'next-key'、'skip-group'、'budget-out'、'aborted'、'cooled'(冷卻觸發,帶error與cooldownMs,僅cooldownMs>0時出現)、'group-exhausted'(本組未成交而試完,每次呼叫每組恰一次,位於本組最後一個next-key或skip-group之後、下一組首個try之前;帶keys(有效金鑰數,0代表登入態之單一虛擬金鑰)、attempted(本組實際嘗試數)、by('all-keys'每把皆換鑰失敗,或'skip-group'以與金鑰無關之失敗收尾)、errorTypes(本組各次嘗試之errorType依序)與error(本組最後一次錯誤);成交、預算用盡、中止之組不發,亦不寫入tried);失敗事件(next-key/skip-group)另帶errorType、stdout(被拒回覆)與stderr(錯誤輸出)供診斷,後兩者於失敗路徑已由轉接器截斷;回調拋出例外不影響主流程,預設null
 * @param {Number} [opt.timeoutMs=300000] 輸入各attempt共用之逾時毫秒正整數,條目可覆寫,全套件統一預設300000
 * @param {String|Function} [opt.validate=undefined] 輸入各attempt共用之stdout驗證規則,條目可覆寫,預設undefined
 * @param {Boolean} [opt.acceptTruncated=false] 輸入是否接受REST文字類轉接器回報之截斷內容布林值(原樣轉傳轉接器),1.0.37起截斷於validate之前判失敗,validate內含搶救策略者須給true,預設false
 * @param {Number} [opt.maxRetries=0] 輸入各attempt共用之同家重試次數非負整數,韌性建議交給換家而非重試同一家,預設0
 * @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類無此欄;REST文字類轉接器另帶finishReason與truncated(是否截斷),同樣流出於結果與tried各項;本函數不會reject
 * @example
 * //need opencode, claude, codex cli in system PATH
 *
 * import dispatchAiFallback from './src/dispatchAiFallback.mjs'
 *
 * let test = async () => {
 *
 *     let r = await dispatchAiFallback('請只回覆兩個字:完成', {
 *         providers: [
 *             {
 *                 //id區分到模型且帶路徑: 同一模型經REST與CLI取得屬兩個供應商
 *                 id: 'agnes:agnes-3.0-flash',
 *                 kind: 'api-openai-compat',
 *                 baseURL: 'https://apihub.agnes-ai.com/v1',
 *                 model: 'agnes-3.0-flash',
 *                 keys: ['sk-aaa', 'sk-bbb'], //多把金鑰, 某把失敗自動換下一把
 *             },
 *             {
 *                 id: 'oc:opencode/muse-spark-1.3-contributor-free', //CLI版(有工具, 較慢)
 *                 kind: 'opencode',
 *                 model: 'opencode/muse-spark-1.3-contributor-free',
 *                 useStoredAuth: false, //opencode自家免費模型以匿名存取, 不沿用本機auth.json之登入
 *                 timeoutMs: 180000,
 *             },
 *             { id: 'claude:sonnet', kind: 'claude', model: 'sonnet' }, //以上全敗時遞補
 *             { id: 'codex:gpt-5.6-luna', kind: 'codex', model: 'gpt-5.6-luna', sandbox: 'read-only' },
 *         ],
 *         budgetMs: 600000,
 *         onEvent: (ev) => console.log(ev.type, ev.providerId, ev.keyIndex),
 *     })
 *     console.log(r.ok, r.providerId, r.keyIndex, r.tried.length)
 *     // => true 'agnes:agnes-3.0-flash' 0 1
 *
 * }
 * await test()
 *     .catch((err) => {
 *         console.log(err)
 *     })
 *
 */
async function dispatchAiFallback(prompt, opt = {}) {

    //check prompt, 於頂層先擋, 否則會逐組空跑同一錯誤
    if (!isestr(prompt)) {
        return { ...getErrorResult('prompt must be a non-empty string'), tried: [] }
    }

    //providers, 濾除非物件條目
    let providersRaw = get(opt, 'providers', null)
    let providers = isarr(providersRaw) ? providersRaw.filter(isobj) : []
    if (providers.length === 0) {
        return { ...getErrorResult('providers must be a non-empty array'), tried: [] }
    }

    //budgetMs, 無效視為不限
    let budgetMs = castPintOr(get(opt, 'budgetMs', null), null)
    let deadline = (budgetMs === null) ? null : Date.now() + budgetMs

    //minAttemptMs, 無效回退預設20000
    let minAttemptMs = castPintOr(get(opt, 'minAttemptMs', null), DEFAULT_MIN_ATTEMPT_MS)

    //state與saveState, store須同時具get與set函數才視為有效, 否則用行程內記憶體
    let { state, saveState } = initState(get(opt, 'store', null))

    //cooldownMs, 無效視為0=不啟用(現行行為零改變)
    let cooldownMs = castPintOr(get(opt, 'cooldownMs', null), 0)

    //coolDetect, 冷卻觸發之注入判定(CLI類限流簽章由呼叫端維護, 見檔頭), 僅cooldownMs>0時有意義
    let coolDetect = get(opt, 'coolDetect', null)
    if (!isfun(coolDetect)) {
        coolDetect = null
    }

    //shouldStop, 中止判定, 於每次嘗試之間檢查; 回調拋出例外視同false(不中止), 不得中斷主流程
    let shouldStop = get(opt, 'shouldStop', null)
    if (!isfun(shouldStop)) {
        shouldStop = null
    }
    let stopRequested = () => {
        if (shouldStop === null) {
            return false
        }
        try {
            return shouldStop() === true
        }
        catch {
            return false
        }
    }

    //供應商冷卻: 冷卻中的條目降至鏈尾(細節見reorderByCooling)
    if (cooldownMs > 0) {
        providers = reorderByCooling(providers, state, cooldownMs, saveState)
    }

    //onEvent, 回調拋出例外不得中斷主流程
    let onEvent = get(opt, 'onEvent', null)
    let emit = (ev) => {
        if (isfun(onEvent)) {
            try {
                onEvent(ev)
            }
            catch {}
        }
    }

    //sharedOpt, 剔除fallback層自用鍵後作為各attempt共用預設, 條目覆寫之
    let sharedOpt = omit(opt, FALLBACK_KEYS)

    let tried = []
    let lastResult = null
    let lastMeta = null

    //逐群組(宣告順序即優先序), 單向單輪不回頭
    for (let ig = 0; ig < providers.length; ig++) {
        let entry = providers[ig]

        //id, 無效回退條目索引字串; idExplicit供冷卻機制判別(索引式id不參與冷卻)
        let id = get(entry, 'id', null)
        let idExplicit = isestr(id)
        if (!idExplicit) {
            id = String(ig)
        }

        //kind與model僅供事件與回傳meta, kind有效性由dispatchAi判定
        let kind = get(entry, 'kind', null)
        let model = get(entry, 'model', null)

        //keys, 濾除非有效字串, 空陣列視同未給(登入態單一虛擬金鑰)
        let keysRaw = get(entry, 'keys', null)
        let keys = isarr(keysRaw) ? keysRaw.filter(isestr) : []
        let nk = keys.length

        //entryOpt, 剔除條目自用鍵後即該條目之opt
        let entryOpt = omit(entry, ENTRY_KEYS)

        //游標, 逐群組記錄, 以現行keys長度取模自癒(金鑰陣列改動時不出界)
        let cursor = 0
        if (nk > 0) {
            let c = get(state.cursors, id, 0)
            cursor = ispint(c) ? cint(c) % nk : 0
        }

        //組內逐把嘗試, 每把至多一次, 全敗即組盡遞補下一組
        let nAttempts = (nk > 0) ? nk : 1
        let skipGroup = false
        let groupStart = tried.length //本組於tried之起點, 供組盡事件取本組各次嘗試
        for (let a = 0; a < nAttempts && !skipGroup; a++) {

            //中止檢查(嘗試邊界): 成果已無人接收時止損, 不中止進行中之嘗試(見檔頭【中止】)
            if (stopRequested()) {
                emit({ type: 'aborted', providerId: id, keyIndex: null, keyId: id })
                tried.push({ providerId: id, keyIndex: null, keyId: id, outcome: 'aborted' })
                return { ...getErrorResult('ABORTED', 'aborted'), tried }
            }

            //keyIndex, 無keys時為null
            let keyIndex = (nk > 0) ? (cursor + a) % nk : null
            let keyId = (keyIndex === null) ? id : `${id}#${keyIndex}`

            //attemptOpt, 共用預設 <- 條目覆寫 <- 本把金鑰
            let attemptOpt = { ...sharedOpt, ...entryOpt }
            if (nk > 0) {
                attemptOpt.key = keys[keyIndex]
            }

            //有效timeout, 有預算時以剩餘預算封頂
            let effTimeout = castPintOr(get(attemptOpt, 'timeoutMs', null), DEFAULT_TIMEOUT_MS)
            if (deadline !== null) {
                let remaining = deadline - Date.now()

                //剩餘不足一次最低嘗試, 停止遞補回報預算用盡
                if (remaining < minAttemptMs) {
                    emit({ type: 'budget-out', providerId: id, keyIndex, keyId, remainingMs: remaining })
                    tried.push({ providerId: id, keyIndex, keyId, outcome: 'budget-out' })
                    return { ...getErrorResult('budget exhausted', 'budget'), tried }
                }
                effTimeout = Math.min(effTimeout, remaining)
            }
            attemptOpt.timeoutMs = effTimeout

            //dispatch
            emit({ type: 'try', providerId: id, keyIndex, keyId, kind, model })
            let r = await dispatchAi(kind, prompt, attemptOpt)

            //成功, 推進游標(額度均攤)並回傳; 任一次成功立即解除該家冷卻
            if (r.ok) {
                if (cooldownMs > 0 && idExplicit && state.cooling[id] !== undefined) {
                    delete state.cooling[id]
                    saveState()
                }
                if (nk > 0) {
                    state.cursors[id] = (keyIndex + 1) % nk
                    saveState()
                }
                emit({ type: 'ok', providerId: id, keyIndex, keyId, durationMs: r.durationMs })
                tried.push({ providerId: id, keyIndex, keyId, outcome: 'ok', durationMs: r.durationMs, ...(r.usage !== undefined ? { usage: r.usage } : {}), ...finOf(r) })
                return { ...r, providerId: id, keyIndex, kind, model, tried }
            }

            //失敗分流
            lastResult = r
            lastMeta = { providerId: id, keyIndex, kind, model }
            //冷卻觸發: 內建為限流(HTTP 429, 僅api類可偵測)與逾時(TIMEOUT開頭, CLI與api皆可)——
            //其餘失敗(金鑰無效/服務端錯誤)已有換金鑰換家機制處理, 納入冷卻反而誤傷;
            //CLI類限流簽章經coolDetect注入判定(呼叫端維護, 見檔頭), 拋出例外視同false
            if (cooldownMs > 0 && idExplicit) {
                let isCoolTrigger = (r.code === 429) || (isestr(r.error) && r.error.indexOf('TIMEOUT') === 0)
                if (!isCoolTrigger && coolDetect !== null) {
                    try {
                        isCoolTrigger = coolDetect(r) === true
                    }
                    catch {}
                }
                if (isCoolTrigger) {
                    state.cooling[id] = Date.now()
                    saveState()
                    emit({ type: 'cooled', providerId: id, keyIndex, keyId, error: r.error, cooldownMs })
                }
            }

            //失敗事件與tried一併帶被拒回覆(stdout)與錯誤輸出(stderr), 供呼叫端診斷失敗原因
            //(如驗證失敗時模型究竟回了什麼); 兩者於失敗路徑已由轉接器截斷(≤500/1000字元), 不會過大
            if (isKeyIndependentFail(r)) {

                //與金鑰無關, 整組跳過
                emit({ type: 'skip-group', providerId: id, keyIndex, keyId, error: r.error, errorType: r.errorType, stdout: r.stdout, stderr: r.stderr })
                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 } : {}), ...finOf(r) })
                skipGroup = true
            }
            else {

                //其餘(含額度上限/金鑰無效/未分類), 換組內下一把, 不記憶不停用
                emit({ type: 'next-key', providerId: id, keyIndex, keyId, error: r.error, errorType: r.errorType, stdout: r.stdout, stderr: r.stderr })
                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 } : {}), ...finOf(r) })
            }

        }

        //組盡: 走到這裡即本組未成交且已試完(成交、預算用盡、中止皆於迴圈內回傳), 每次呼叫每組恰發一次(見檔頭【組盡事件】)
        let tg = tried.slice(groupStart)
        emit({
            type: 'group-exhausted',
            providerId: id,
            keyIndex: null,
            keyId: id,
            keys: nk, //有效金鑰數(同上方濾法), 0代表登入態之單一虛擬金鑰
            attempted: tg.length, //本組實際送出之嘗試數
            by: skipGroup ? 'skip-group' : 'all-keys', //以與金鑰無關之失敗收尾, 或每把皆換鑰失敗
            errorTypes: tg.map((t) => t.errorType), //本組各次嘗試之errorType, 依嘗試順序
            error: get(lastResult, 'error', ''), //本組最後一次嘗試之錯誤
        })
    }

    //全數失敗, 回傳最後一筆失敗結果(含其errorType)與完整歷程
    let r = lastResult || getErrorResult('all providers failed', 'exec')
    return { ...r, ...(lastMeta || {}), tried }
}


export default dispatchAiFallback