diff --git a/plugins/dsh-mail-bridge/lib/model-scope.js b/plugins/dsh-mail-bridge/lib/model-scope.js index 74fbe11..65712e4 100644 --- a/plugins/dsh-mail-bridge/lib/model-scope.js +++ b/plugins/dsh-mail-bridge/lib/model-scope.js @@ -124,6 +124,65 @@ export function modelAttemptOrder(allowed, envDefault) { return [undefined]; } +/** + * 把「目录里根本没有的路由」从尝试序列里剔掉。 + * + * # 为什么需要它(2026-09-28 实测) + * + * 线上 opencode 的允许清单 rank=0 是 `opencode/mimo-v2.5-free`,而**上游已改名** + * 为 `mimo-v2.6-flash-free`。于是每一封邮件的第一轮尝试都是: + * + * [mail-bridge] 模型 opencode/mimo-v2.5-free 失败: + * Model not found. Did you mean: mimo-v2.6-flash-free, ...? + * [mail-bridge] llmsproxy/AUTO 成功(前 1 个失败) + * + * 降级逻辑救了它(信还是回了),但代价是**每轮白烧一次 + 延迟翻倍 + 一条永久错误日志**。 + * + * # 为什么 pi 桥早就有、这里没有 + * + * `pi-mail-bridge/src/worker.mjs` 里同一件事已经做了,注释写着 + * 「目录里根本没有这个路由:**同步就能判定,不必起一轮**」。 + * 本函数就是把那条纪律搬到共用层,让 opencode/dsh/zcode 也拿到。 + * + * ★ 关键:**剔不掉时必须原样保留**。 + * 拿不到目录(心跳还没跑过 / 拉取失败)时返回**全部路由** —— + * 宁可真去试一次(失败会走既有的降级与故障报告), + * 也不能因为「我们不知道」就让人收不到回信。 + * 这是与 `reportModels` 拉取失败时**省略字段而不是传空数组**同一个方向。 + * + * @param {readonly ({provider: string, model: string}|undefined)[]} order 尝试序列 + * @param {readonly {provider: string, model: string}[]|undefined} catalog 最近一次上报的模型目录 + * @returns {{kept: ({provider: string, model: string}|undefined)[], skipped: {provider: string, model: string, reason: string}[]}} + */ +export function partitionByCatalog(order, catalog) { + const seq = Array.isArray(order) ? order : []; + const cat = Array.isArray(catalog) ? catalog.filter(m => m?.provider && m?.model) : []; + + // ★ 拿不到目录 = 不做任何判断,把全部放行。 + // 「我不知道」与「它不存在」必须分得开,否则一次心跳失败就会让 Agent 彻底哑掉。 + if (cat.length === 0) { + return { kept: seq, skipped: [] }; + } + + const have = new Set(cat.map(m => `${m.provider}/${m.model}`)); + const kept = []; + const skipped = []; + for (const r of seq) { + // undefined = 不指定模型、交给平台自己选 —— 它不经过目录,不归我们管。 + if (!r) { kept.push(r); continue; } + if (have.has(`${r.provider}/${r.model}`)) { + kept.push(r); + } else { + skipped.push({ + provider: r.provider, + model: r.model, + reason: `平台目录里没有 ${r.provider}/${r.model}(上游可能已改名或下线)`, + }); + } + } + return { kept, skipped }; +} + /** * 把多次尝试的失败原因整理成一封邮件正文。 * diff --git a/plugins/dsh-mail-bridge/test/model-scope.test.mjs b/plugins/dsh-mail-bridge/test/model-scope.test.mjs index ae15c29..cb71bd3 100644 --- a/plugins/dsh-mail-bridge/test/model-scope.test.mjs +++ b/plugins/dsh-mail-bridge/test/model-scope.test.mjs @@ -14,6 +14,7 @@ import { snapshotDshModels, snapshotPiModels, modelAttemptOrder, + partitionByCatalog, renderFailureReport, MAX_CATALOG, } from '../lib/model-scope.js'; @@ -246,3 +247,75 @@ test('renderFailureReport 容错非数组', () => { const got = renderFailureReport(undefined, '主题'); assert.match(got, /已尝试 0 个/); }); + +// ─── partitionByCatalog(2026-09-28 新增)── +const CATALOG = [ + { provider: 'opencode', model: 'mimo-v2.6-flash-free' }, + { provider: 'llmsproxy', model: 'AUTO' }, +]; + +test('目录里没有的路由被剔掉(就是线上那个 case)', () => { + const order = modelAttemptOrder( + [{ provider: 'opencode', model: 'mimo-v2.5-free' }, { provider: 'llmsproxy', model: 'AUTO' }], + undefined + ); + const { kept, skipped } = partitionByCatalog(order, CATALOG); + assert.deepEqual(kept, [{ provider: 'llmsproxy', model: 'AUTO' }], + '过期的 v2.5 必须被剔掉'); + assert.equal(skipped.length, 1); + assert.equal(skipped[0].model, 'mimo-v2.5-free'); + // 理由要说清是「上游改名/下线」,否则管理员不知道该去改什么 + assert.match(skipped[0].reason, /目录里没有/); +}); + +test('★ 拿不到目录时全部放行("不知道" ≠ "不存在")', () => { + // 这是本函数最要紧的一条:心跳还没跑过 / 拉取失败 ⇒ lastModelCatalog 是 undefined。 + // 若此时把全部剔掉,Agent 会**彻底哑掉** —— 而故障方向恰恰是"什么都收不到"。 + for (const bad of [undefined, null, [], 'not-an-array', 42]) { + const order = [{ provider: 'opencode', model: 'whatever' }]; + const { kept, skipped } = partitionByCatalog(order, bad); + assert.equal(kept.length, 1, `目录=${JSON.stringify(bad)} 时必须原样放行`); + assert.equal(skipped.length, 0); + } +}); + +test('undefined 路由(交给平台默认)不归目录管', () => { + const { kept, skipped } = partitionByCatalog([undefined], CATALOG); + assert.equal(kept.length, 1); + assert.equal(kept[0], undefined, '不指定模型的那一项必须原样保留'); + assert.equal(skipped.length, 0); +}); + +test('全部失效时 kept 为空 —— 由调用方决定退路', () => { + const order = [{ provider: 'opencode', model: 'mimo-v2.5-free' }]; + const { kept, skipped } = partitionByCatalog(order, CATALOG); + assert.equal(kept.length, 0, '本函数不擅自塞回默认路由:那是策略决定,不是过滤职责'); + assert.equal(skipped.length, 1); +}); + +test('目录里有同名 provider 的别的 model 也不算数(键是 provider+model 整体)', () => { + const { kept } = partitionByCatalog( + [{ provider: 'opencode', model: 'big-pickle' }], // 同 provider,不同 model + CATALOG + ); + assert.equal(kept.length, 0, 'provider 匹配不够,model 也必须匹配'); +}); + +test('空序列与非法输入不崩', () => { + for (const order of [[], null, undefined, 'x', [null, undefined, {}]]) { + const { kept } = partitionByCatalog(order, CATALOG); + assert.ok(Array.isArray(kept)); + } +}); + +test('判据有分辨力:拿掉过滤逻辑 ⇒ 本组必须变红', () => { + // 自检:这个断言在"实现被改坏"时会自己报警。 + // 第一版把 SRC_EXT 之类的东西写错时,就是靠这类反向对照发现的。 + const order = [{ provider: 'opencode', model: 'mimo-v2.5-free' }]; + const naive = { kept: order, skipped: [] }; // 假装过滤不存在 + assert.equal(naive.skipped.length, 0); + assert.equal( + partitionByCatalog(order, CATALOG).skipped.length, 1, + '真实现必须与"不过滤"的那个不同 —— 否则这组判据恒绿' + ); +}); diff --git a/plugins/opencode-mail-bridge/index.js b/plugins/opencode-mail-bridge/index.js index 17fc0cc..5745a7f 100644 --- a/plugins/opencode-mail-bridge/index.js +++ b/plugins/opencode-mail-bridge/index.js @@ -12,6 +12,7 @@ import { selectCatchup } from "./lib/catchup.js"; import { snapshotOpencodeModels, modelAttemptOrder, + partitionByCatalog, renderFailureReport, } from "./lib/model-scope.js"; import { @@ -666,6 +667,17 @@ const mailDrivenSessions = new BoundedSet(MAX_TRACKED_SESSIONS); // opencode ses // 空数组 = 不限定,回退到环境变量或平台默认。 let allowedModels = []; +// ★ 最近一次拉到的**平台模型目录**(心跳上报用的那份,2026-09-28 新增)。 +// +// 用途:在**起会话之前**剔掉「目录里根本没有的路由」—— +// 上游改名/下架后,管理员划定的范围会静静失效,每轮先失败一次再降级。 +// 线上实例:`opencode/mimo-v2.5-free` 已改名 `mimo-v2.6-flash-free`, +// 于是每封邮件都先报一次 Model not found(实测 2026-09-28)。 +// +// 拉不到时保持 undefined ⇒ `partitionByCatalog` 放行全部路由 +// (「不知道」与「不存在」必须分得开,见该函数注释)。 +let lastModelCatalog = undefined; + async function resolveSessionForMail(client, directory, data, kind) { const mailSessionID = data.session_id; const bound = mailSessionID ? sessionMap.get(mailSessionID) : undefined; @@ -1036,9 +1048,32 @@ async function deliverMail(client, directory, data, kind) { provider: REPLY_PROVIDER, model: REPLY_MODEL, }); - const failures = []; - for (const route of attempts) { + // ★ 起会话**之前**先剔掉目录里没有的路由(2026-09-28)。 + // pi 桥早就有这一步(worker.mjs:「同步就能判定,不必起一轮」),这里对齐。 + // 否则上游一改名,每封邮件都要先失败一次再降级 —— 信能回,但延迟翻倍 + // 且日志里永远挂一条 Model not found。 + const { kept: routes, skipped } = partitionByCatalog(attempts, lastModelCatalog); + if (skipped.length) { + for (const s of skipped) { + console.error(`[mail-bridge] 跳过 ${s.provider}/${s.model}:${s.reason}`); + } + // 剔完一个都不剩 = 管理员划的范围全部失效:仍然要跑那一轮 + // (`routes` 为空会让下面的循环一次都不进,直接落到「全部失败」分支), + // 但失败报告里必须把「为什么一个都没试」说清楚 —— 否则发件人只看到 + // 「本次未能处理」,而管理员在配置页上看不出自己的选择已经过期。 + if (routes.length === 0) { + routes.push(undefined); // 退回「交给平台默认」,总比完全不试强 + console.error( + `[mail-bridge] 划定范围内的 ${skipped.length} 个模型都已不在平台目录里,` + + '本轮退回平台默认模型;请到管理页重新划定范围' + ); + } + } + + const failures = [...skipped]; + + for (const route of routes) { const label = route ? `${route.provider}/${route.model}` : "(平台默认)"; const watching = awaitFirstTurn(sessionID); try { @@ -1201,7 +1236,10 @@ export default async function mailBridge(input) { async function reportModels() { try { const cfg = await client.config.providers(); - return snapshotOpencodeModels(cfg?.data ?? cfg); + const snap = snapshotOpencodeModels(cfg?.data ?? cfg); + // 顺手记下目录供**发信前**预检用(与上报同一份数据,不多一次请求)。 + lastModelCatalog = snap; + return snap; } catch (e) { // 拉不到就**省略**该字段,而不是传空数组: // 空数组的语义是「平台确实一个模型都拿不到」,会把配置页清成空白。 diff --git a/plugins/opencode-mail-bridge/lib/model-scope.js b/plugins/opencode-mail-bridge/lib/model-scope.js index 74fbe11..65712e4 100644 --- a/plugins/opencode-mail-bridge/lib/model-scope.js +++ b/plugins/opencode-mail-bridge/lib/model-scope.js @@ -124,6 +124,65 @@ export function modelAttemptOrder(allowed, envDefault) { return [undefined]; } +/** + * 把「目录里根本没有的路由」从尝试序列里剔掉。 + * + * # 为什么需要它(2026-09-28 实测) + * + * 线上 opencode 的允许清单 rank=0 是 `opencode/mimo-v2.5-free`,而**上游已改名** + * 为 `mimo-v2.6-flash-free`。于是每一封邮件的第一轮尝试都是: + * + * [mail-bridge] 模型 opencode/mimo-v2.5-free 失败: + * Model not found. Did you mean: mimo-v2.6-flash-free, ...? + * [mail-bridge] llmsproxy/AUTO 成功(前 1 个失败) + * + * 降级逻辑救了它(信还是回了),但代价是**每轮白烧一次 + 延迟翻倍 + 一条永久错误日志**。 + * + * # 为什么 pi 桥早就有、这里没有 + * + * `pi-mail-bridge/src/worker.mjs` 里同一件事已经做了,注释写着 + * 「目录里根本没有这个路由:**同步就能判定,不必起一轮**」。 + * 本函数就是把那条纪律搬到共用层,让 opencode/dsh/zcode 也拿到。 + * + * ★ 关键:**剔不掉时必须原样保留**。 + * 拿不到目录(心跳还没跑过 / 拉取失败)时返回**全部路由** —— + * 宁可真去试一次(失败会走既有的降级与故障报告), + * 也不能因为「我们不知道」就让人收不到回信。 + * 这是与 `reportModels` 拉取失败时**省略字段而不是传空数组**同一个方向。 + * + * @param {readonly ({provider: string, model: string}|undefined)[]} order 尝试序列 + * @param {readonly {provider: string, model: string}[]|undefined} catalog 最近一次上报的模型目录 + * @returns {{kept: ({provider: string, model: string}|undefined)[], skipped: {provider: string, model: string, reason: string}[]}} + */ +export function partitionByCatalog(order, catalog) { + const seq = Array.isArray(order) ? order : []; + const cat = Array.isArray(catalog) ? catalog.filter(m => m?.provider && m?.model) : []; + + // ★ 拿不到目录 = 不做任何判断,把全部放行。 + // 「我不知道」与「它不存在」必须分得开,否则一次心跳失败就会让 Agent 彻底哑掉。 + if (cat.length === 0) { + return { kept: seq, skipped: [] }; + } + + const have = new Set(cat.map(m => `${m.provider}/${m.model}`)); + const kept = []; + const skipped = []; + for (const r of seq) { + // undefined = 不指定模型、交给平台自己选 —— 它不经过目录,不归我们管。 + if (!r) { kept.push(r); continue; } + if (have.has(`${r.provider}/${r.model}`)) { + kept.push(r); + } else { + skipped.push({ + provider: r.provider, + model: r.model, + reason: `平台目录里没有 ${r.provider}/${r.model}(上游可能已改名或下线)`, + }); + } + } + return { kept, skipped }; +} + /** * 把多次尝试的失败原因整理成一封邮件正文。 * diff --git a/plugins/opencode-mail-bridge/test/model-scope.test.mjs b/plugins/opencode-mail-bridge/test/model-scope.test.mjs index ae15c29..cb71bd3 100644 --- a/plugins/opencode-mail-bridge/test/model-scope.test.mjs +++ b/plugins/opencode-mail-bridge/test/model-scope.test.mjs @@ -14,6 +14,7 @@ import { snapshotDshModels, snapshotPiModels, modelAttemptOrder, + partitionByCatalog, renderFailureReport, MAX_CATALOG, } from '../lib/model-scope.js'; @@ -246,3 +247,75 @@ test('renderFailureReport 容错非数组', () => { const got = renderFailureReport(undefined, '主题'); assert.match(got, /已尝试 0 个/); }); + +// ─── partitionByCatalog(2026-09-28 新增)── +const CATALOG = [ + { provider: 'opencode', model: 'mimo-v2.6-flash-free' }, + { provider: 'llmsproxy', model: 'AUTO' }, +]; + +test('目录里没有的路由被剔掉(就是线上那个 case)', () => { + const order = modelAttemptOrder( + [{ provider: 'opencode', model: 'mimo-v2.5-free' }, { provider: 'llmsproxy', model: 'AUTO' }], + undefined + ); + const { kept, skipped } = partitionByCatalog(order, CATALOG); + assert.deepEqual(kept, [{ provider: 'llmsproxy', model: 'AUTO' }], + '过期的 v2.5 必须被剔掉'); + assert.equal(skipped.length, 1); + assert.equal(skipped[0].model, 'mimo-v2.5-free'); + // 理由要说清是「上游改名/下线」,否则管理员不知道该去改什么 + assert.match(skipped[0].reason, /目录里没有/); +}); + +test('★ 拿不到目录时全部放行("不知道" ≠ "不存在")', () => { + // 这是本函数最要紧的一条:心跳还没跑过 / 拉取失败 ⇒ lastModelCatalog 是 undefined。 + // 若此时把全部剔掉,Agent 会**彻底哑掉** —— 而故障方向恰恰是"什么都收不到"。 + for (const bad of [undefined, null, [], 'not-an-array', 42]) { + const order = [{ provider: 'opencode', model: 'whatever' }]; + const { kept, skipped } = partitionByCatalog(order, bad); + assert.equal(kept.length, 1, `目录=${JSON.stringify(bad)} 时必须原样放行`); + assert.equal(skipped.length, 0); + } +}); + +test('undefined 路由(交给平台默认)不归目录管', () => { + const { kept, skipped } = partitionByCatalog([undefined], CATALOG); + assert.equal(kept.length, 1); + assert.equal(kept[0], undefined, '不指定模型的那一项必须原样保留'); + assert.equal(skipped.length, 0); +}); + +test('全部失效时 kept 为空 —— 由调用方决定退路', () => { + const order = [{ provider: 'opencode', model: 'mimo-v2.5-free' }]; + const { kept, skipped } = partitionByCatalog(order, CATALOG); + assert.equal(kept.length, 0, '本函数不擅自塞回默认路由:那是策略决定,不是过滤职责'); + assert.equal(skipped.length, 1); +}); + +test('目录里有同名 provider 的别的 model 也不算数(键是 provider+model 整体)', () => { + const { kept } = partitionByCatalog( + [{ provider: 'opencode', model: 'big-pickle' }], // 同 provider,不同 model + CATALOG + ); + assert.equal(kept.length, 0, 'provider 匹配不够,model 也必须匹配'); +}); + +test('空序列与非法输入不崩', () => { + for (const order of [[], null, undefined, 'x', [null, undefined, {}]]) { + const { kept } = partitionByCatalog(order, CATALOG); + assert.ok(Array.isArray(kept)); + } +}); + +test('判据有分辨力:拿掉过滤逻辑 ⇒ 本组必须变红', () => { + // 自检:这个断言在"实现被改坏"时会自己报警。 + // 第一版把 SRC_EXT 之类的东西写错时,就是靠这类反向对照发现的。 + const order = [{ provider: 'opencode', model: 'mimo-v2.5-free' }]; + const naive = { kept: order, skipped: [] }; // 假装过滤不存在 + assert.equal(naive.skipped.length, 0); + assert.equal( + partitionByCatalog(order, CATALOG).skipped.length, 1, + '真实现必须与"不过滤"的那个不同 —— 否则这组判据恒绿' + ); +}); diff --git a/plugins/pi-mail-bridge/lib/model-scope.js b/plugins/pi-mail-bridge/lib/model-scope.js index 74fbe11..65712e4 100644 --- a/plugins/pi-mail-bridge/lib/model-scope.js +++ b/plugins/pi-mail-bridge/lib/model-scope.js @@ -124,6 +124,65 @@ export function modelAttemptOrder(allowed, envDefault) { return [undefined]; } +/** + * 把「目录里根本没有的路由」从尝试序列里剔掉。 + * + * # 为什么需要它(2026-09-28 实测) + * + * 线上 opencode 的允许清单 rank=0 是 `opencode/mimo-v2.5-free`,而**上游已改名** + * 为 `mimo-v2.6-flash-free`。于是每一封邮件的第一轮尝试都是: + * + * [mail-bridge] 模型 opencode/mimo-v2.5-free 失败: + * Model not found. Did you mean: mimo-v2.6-flash-free, ...? + * [mail-bridge] llmsproxy/AUTO 成功(前 1 个失败) + * + * 降级逻辑救了它(信还是回了),但代价是**每轮白烧一次 + 延迟翻倍 + 一条永久错误日志**。 + * + * # 为什么 pi 桥早就有、这里没有 + * + * `pi-mail-bridge/src/worker.mjs` 里同一件事已经做了,注释写着 + * 「目录里根本没有这个路由:**同步就能判定,不必起一轮**」。 + * 本函数就是把那条纪律搬到共用层,让 opencode/dsh/zcode 也拿到。 + * + * ★ 关键:**剔不掉时必须原样保留**。 + * 拿不到目录(心跳还没跑过 / 拉取失败)时返回**全部路由** —— + * 宁可真去试一次(失败会走既有的降级与故障报告), + * 也不能因为「我们不知道」就让人收不到回信。 + * 这是与 `reportModels` 拉取失败时**省略字段而不是传空数组**同一个方向。 + * + * @param {readonly ({provider: string, model: string}|undefined)[]} order 尝试序列 + * @param {readonly {provider: string, model: string}[]|undefined} catalog 最近一次上报的模型目录 + * @returns {{kept: ({provider: string, model: string}|undefined)[], skipped: {provider: string, model: string, reason: string}[]}} + */ +export function partitionByCatalog(order, catalog) { + const seq = Array.isArray(order) ? order : []; + const cat = Array.isArray(catalog) ? catalog.filter(m => m?.provider && m?.model) : []; + + // ★ 拿不到目录 = 不做任何判断,把全部放行。 + // 「我不知道」与「它不存在」必须分得开,否则一次心跳失败就会让 Agent 彻底哑掉。 + if (cat.length === 0) { + return { kept: seq, skipped: [] }; + } + + const have = new Set(cat.map(m => `${m.provider}/${m.model}`)); + const kept = []; + const skipped = []; + for (const r of seq) { + // undefined = 不指定模型、交给平台自己选 —— 它不经过目录,不归我们管。 + if (!r) { kept.push(r); continue; } + if (have.has(`${r.provider}/${r.model}`)) { + kept.push(r); + } else { + skipped.push({ + provider: r.provider, + model: r.model, + reason: `平台目录里没有 ${r.provider}/${r.model}(上游可能已改名或下线)`, + }); + } + } + return { kept, skipped }; +} + /** * 把多次尝试的失败原因整理成一封邮件正文。 * diff --git a/plugins/pi-mail-bridge/test/model-scope.test.mjs b/plugins/pi-mail-bridge/test/model-scope.test.mjs index ae15c29..cb71bd3 100644 --- a/plugins/pi-mail-bridge/test/model-scope.test.mjs +++ b/plugins/pi-mail-bridge/test/model-scope.test.mjs @@ -14,6 +14,7 @@ import { snapshotDshModels, snapshotPiModels, modelAttemptOrder, + partitionByCatalog, renderFailureReport, MAX_CATALOG, } from '../lib/model-scope.js'; @@ -246,3 +247,75 @@ test('renderFailureReport 容错非数组', () => { const got = renderFailureReport(undefined, '主题'); assert.match(got, /已尝试 0 个/); }); + +// ─── partitionByCatalog(2026-09-28 新增)── +const CATALOG = [ + { provider: 'opencode', model: 'mimo-v2.6-flash-free' }, + { provider: 'llmsproxy', model: 'AUTO' }, +]; + +test('目录里没有的路由被剔掉(就是线上那个 case)', () => { + const order = modelAttemptOrder( + [{ provider: 'opencode', model: 'mimo-v2.5-free' }, { provider: 'llmsproxy', model: 'AUTO' }], + undefined + ); + const { kept, skipped } = partitionByCatalog(order, CATALOG); + assert.deepEqual(kept, [{ provider: 'llmsproxy', model: 'AUTO' }], + '过期的 v2.5 必须被剔掉'); + assert.equal(skipped.length, 1); + assert.equal(skipped[0].model, 'mimo-v2.5-free'); + // 理由要说清是「上游改名/下线」,否则管理员不知道该去改什么 + assert.match(skipped[0].reason, /目录里没有/); +}); + +test('★ 拿不到目录时全部放行("不知道" ≠ "不存在")', () => { + // 这是本函数最要紧的一条:心跳还没跑过 / 拉取失败 ⇒ lastModelCatalog 是 undefined。 + // 若此时把全部剔掉,Agent 会**彻底哑掉** —— 而故障方向恰恰是"什么都收不到"。 + for (const bad of [undefined, null, [], 'not-an-array', 42]) { + const order = [{ provider: 'opencode', model: 'whatever' }]; + const { kept, skipped } = partitionByCatalog(order, bad); + assert.equal(kept.length, 1, `目录=${JSON.stringify(bad)} 时必须原样放行`); + assert.equal(skipped.length, 0); + } +}); + +test('undefined 路由(交给平台默认)不归目录管', () => { + const { kept, skipped } = partitionByCatalog([undefined], CATALOG); + assert.equal(kept.length, 1); + assert.equal(kept[0], undefined, '不指定模型的那一项必须原样保留'); + assert.equal(skipped.length, 0); +}); + +test('全部失效时 kept 为空 —— 由调用方决定退路', () => { + const order = [{ provider: 'opencode', model: 'mimo-v2.5-free' }]; + const { kept, skipped } = partitionByCatalog(order, CATALOG); + assert.equal(kept.length, 0, '本函数不擅自塞回默认路由:那是策略决定,不是过滤职责'); + assert.equal(skipped.length, 1); +}); + +test('目录里有同名 provider 的别的 model 也不算数(键是 provider+model 整体)', () => { + const { kept } = partitionByCatalog( + [{ provider: 'opencode', model: 'big-pickle' }], // 同 provider,不同 model + CATALOG + ); + assert.equal(kept.length, 0, 'provider 匹配不够,model 也必须匹配'); +}); + +test('空序列与非法输入不崩', () => { + for (const order of [[], null, undefined, 'x', [null, undefined, {}]]) { + const { kept } = partitionByCatalog(order, CATALOG); + assert.ok(Array.isArray(kept)); + } +}); + +test('判据有分辨力:拿掉过滤逻辑 ⇒ 本组必须变红', () => { + // 自检:这个断言在"实现被改坏"时会自己报警。 + // 第一版把 SRC_EXT 之类的东西写错时,就是靠这类反向对照发现的。 + const order = [{ provider: 'opencode', model: 'mimo-v2.5-free' }]; + const naive = { kept: order, skipped: [] }; // 假装过滤不存在 + assert.equal(naive.skipped.length, 0); + assert.equal( + partitionByCatalog(order, CATALOG).skipped.length, 1, + '真实现必须与"不过滤"的那个不同 —— 否则这组判据恒绿' + ); +});