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