resolveProviders.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 isestr from 'wsemi/src/isestr.mjs'
import isearr from 'wsemi/src/isearr.mjs'
import strFindSimilar from 'wsemi/src/strFindSimilar.mjs'


// resolveProviders.mjs — 把providers定義檔展開為可直接使用的條目
//
// 【為何需要】providers.mjs之條目以envVar間接引用金鑰(機密不落設定檔, 只寫變數名),
//   而dispatchAiFallback只認keys陣列——本函數負責envVar → keys之展開,
//   缺對應環境變數者停用該條目並列入skipped回報(不中斷、不throw),
//   訂閱登入態條目(claude/codex等無envVar)原樣通過。
//
// 【自選】opt.pick給id陣列即可只取用部分條目, 且依pick之順序回傳
//   (順序即dispatchAiFallback之優先序); 查無之id列入missing回報。
//
// 【後處理單一入口: opt.exes與opt.patch】消費端常需於展開後對條目做兩類覆寫:
//   ①逐kind注入CLI執行檔絕對路徑(Windows排程於session 0執行時PATH可能不含npm
//     全域目錄, 靠指令名會ENOENT); ②逐id覆寫任意欄位(如各家實測耗時相差近10倍,
//     timeoutMs須逐條指定)。此前只能於呼叫端自行map, 而陣列版(providers)與表格版
//     (table)若各自後處理, 極易只套用其一——使用端殷鑑(2026-08-14): 逐條timeout
//     只加在陣列版, 工作流走表格版全部落回全域預設而不自知。後處理收進本函數,
//     兩種輸出同源產出, 結構性杜絕單邊套用。


/**
 * 展開providers定義條目:envVar → keys,並可依id自選子集
 *
 * 特點:
 * 條目之envVar依env來源(預設process.env)展開為keys陣列(變數值以逗號分隔多把),envVar欄位自輸出移除;
 * 缺對應環境變數(或值為空)之條目停用並列入skipped,不中斷不throw;
 * 無envVar之條目(訂閱登入態CLI)原樣通過;條目已自帶有效keys者以keys為準;
 * opt.pick可依id自選子集並以pick順序回傳(順序即遞補優先序);
 * opt.exes依kind注入CLI執行檔路徑、opt.patch依id淺合併覆寫欄位——皆於展開後施作,
 * providers與table同源產出故必然一致;
 * 輸入陣列與條目皆不被改動(輸出為淺拷貝)
 *
 * @param {Array} providers 輸入providers條目物件陣列(如providers.mjs之預設匯出)
 * @param {Object} [opt={}] 輸入設定物件,預設{}
 * @param {Object} [opt.env=process.env] 輸入金鑰來源物件(變數名 → 逗號分隔之金鑰字串),預設process.env。建議以readEnvFile('./.env')讀成物件傳入,不污染process.env
 * @param {Array} [opt.pick=null] 輸入自選id字串陣列,依此順序回傳對應條目,查無之id列入missing,預設null代表全取(依原順序)
 * @param {Object} [opt.exes=null] 輸入kind對執行檔絕對路徑之物件(如{claude:'C:/.../claude.exe'}),命中kind之條目補上exe欄位(條目已自帶exe者不覆寫——條目層設定優先),預設null代表不注入
 * @param {Object} [opt.patch=null] 輸入id對部分欄位之物件(如{'claude:sonnet':{timeoutMs:360000}}),命中id之條目淺合併覆寫,於exes之後施作故可覆寫exe,預設null代表不覆寫
 * @returns {Object} 回傳物件,內含providers(可直接餵dispatchAiFallback之條目陣列)、table(id對條目之物件,可直接餵dispatchAiWkf)、skipped(缺環境變數而停用之{id,envVar}陣列)、missing(pick查無之id字串陣列)、hints(missing id對最接近可用id之拼寫提示物件,pick查無多半是打錯字,附最接近id供直接定位;僅為相似度最高者非保證正解,無missing時為空物件)
 * @example
 *
 * import resolveProviders from './src/resolveProviders.mjs'
 * import readEnvFile from './src/readEnvFile.mjs'
 * import providersAll from './src/providers.mjs'
 *
 * //金鑰放.env(變數值以逗號分隔多把), 以readEnvFile讀成物件, 不污染process.env
 * let env = readEnvFile('./.env')
 *
 * //全取: envVar展開為keys, 缺環境變數者列入skipped
 * let { providers, table, skipped } = resolveProviders(providersAll, { env })
 * console.log(providers.length, skipped)
 * // => 17 []
 *
 * //pick打錯字時, missing附拼寫提示hints(最接近之可用id)
 * let rm = resolveProviders(providersAll, { env, pick: ['poolside/laguna-s-2.1'] })
 * console.log(rm.missing, rm.hints)
 * // => [ 'poolside/laguna-s-2.1' ] { 'poolside/laguna-s-2.1': 'poolside:laguna-s-2.1' }
 *
 * //自選: 依pick順序回傳(順序即遞補優先序), 可直接餵dispatchAiFallback
 * let r2 = resolveProviders(providersAll, { env, pick: ['agnes:agnes-3.0-flash', 'claude:sonnet'] })
 * console.log(r2.providers.map((p) => p.id))
 * // => [ 'agnes:agnes-3.0-flash', 'claude:sonnet' ]
 *
 * //後處理: exes逐kind注入執行檔、patch逐id覆寫欄位, 陣列與table同步生效
 * let r3 = resolveProviders(providersAll, {
 *     env,
 *     pick: ['agnes:agnes-3.0-flash', 'claude:sonnet'],
 *     exes: { claude: 'C:/Users/x/.local/bin/claude.exe' },
 *     patch: { 'claude:sonnet': { timeoutMs: 360000 } },
 * })
 * console.log(r3.table['claude:sonnet'].exe, r3.table['claude:sonnet'].timeoutMs)
 * // => 'C:/Users/x/.local/bin/claude.exe' 360000
 *
 * //table可直接餵dispatchAiWkf之providers定義表
 * //let wkf = dispatchAiWkf({ providers: r2.table, defaults: { timeoutMs: 1200000 } })
 *
 */
function resolveProviders(providers, opt = {}) {

    //env, 無效回退process.env(瀏覽器環境無process則空物件)
    let env = get(opt, 'env', null)
    if (!isobj(env)) {
        env = (typeof process !== 'undefined' && process && isobj(process.env)) ? process.env : {}
    }

    //providersRaw
    let providersRaw = isarr(providers) ? providers.filter(isobj) : []

    //pick, 依id自選並依pick順序回傳
    let pick = get(opt, 'pick', null)
    let missing = []
    let selected = providersRaw
    if (isearr(pick)) {
        selected = []
        for (let id of pick) {
            let entry = providersRaw.find((p) => get(p, 'id', null) === id)
            if (entry) {
                selected.push(entry)
            }
            else {
                missing.push(String(id))
            }
        }
    }

    //hints, missing之拼寫提示: 自全部條目id中找最接近者——實務上pick查無多半是打錯字
    //(如'poolside/laguna'誤作分隔符), 附上最接近id可直接定位; 僅為相似度最高者, 非保證正解
    let hints = {}
    if (missing.length > 0) {
        let ids = providersRaw.map((p) => get(p, 'id', null)).filter(isestr)
        for (let mid of missing) {
            hints[mid] = (ids.length > 0) ? get(strFindSimilar(mid, ids), 'bestMatch.target', null) : null
        }
    }

    //展開envVar → keys
    let out = []
    let skipped = []
    for (let entry of selected) {
        let envVar = get(entry, 'envVar', null)

        //無envVar(訂閱登入態CLI)或已自帶有效keys, 原樣通過(僅移除envVar欄位)
        if (!isestr(envVar) || isearr(get(entry, 'keys', null))) {
            out.push(omit(entry, ['envVar']))
            continue
        }

        //envVar → keys, 變數值以逗號分隔多把
        let keys = String(get(env, envVar, '') || '').split(',').map((k) => k.trim()).filter(Boolean)
        if (keys.length === 0) {

            //缺環境變數: 停用該條目並回報, 不中斷(設計providers原則§四.4)
            skipped.push({ id: get(entry, 'id', ''), envVar })
            continue
        }
        out.push({ ...omit(entry, ['envVar']), keys })
    }

    //後處理: exes(逐kind)與patch(逐id), 於table組建「之前」施作——
    //providers與table由同一份out產出, 兩種輸出必然一致(檔頭之單邊套用殷鑑)
    let exes = get(opt, 'exes', null)
    let patch = get(opt, 'patch', null)
    if (isobj(exes) || isobj(patch)) {
        out = out.map((entry) => {
            let e = entry

            //exes: 條目已自帶exe者不覆寫(條目層設定優先於全域注入)
            let exe = isobj(exes) ? get(exes, get(e, 'kind', ''), null) : null
            if (isestr(exe) && !isestr(get(e, 'exe', null))) {
                e = { ...e, exe }
            }

            //patch: 依id淺合併, 於exes之後施作故可覆寫exe
            let pt = isobj(patch) ? get(patch, get(e, 'id', ''), null) : null
            if (isobj(pt)) {
                e = { ...e, ...pt }
            }

            return e
        })
    }

    //table, id → 條目, 可直接作dispatchAiWkf之providers定義表
    let table = {}
    for (let entry of out) {
        table[entry.id] = entry
    }

    return { providers: out, table, skipped, missing, hints }
}


export default resolveProviders