Files
MailUI4Agents/plugins/dsh-mail-bridge/test/model-scope.test.mjs
JianFeeeee 093c4dd06e fix(桥接): 模型清单过期时**起会话前**就剔掉(opencode 每轮白烧一次)
## 现象(线上实测,不是推演)

    [mail-bridge] 模型 opencode/mimo-v2.5-free 失败:
      Model not found. Did you mean: mimo-v2.6-flash-free, ling-3.0-flash-fin-free...?
    [mail-bridge] llmsproxy/AUTO 成功(前 1 个失败)

30 分钟内 9 次 —— **每一封邮件**的第一轮尝试都是它。

## 根因

`agent_allowed_models` 里 opencode 的 rank=0 仍是 `mimo-v2.5-free`,
而**上游已改名** `mimo-v2.6-flash-free`。查证:225 个 provider 的实时目录里
确有 `mimo-v2.6-flash-free`、无 v2.5;库里 `agent_model_catalog`
(心跳上报的快照,今天 01:28)也已含新名字。

⇒ 清单是管理员存下来的,**没有任何失效检测**。降级逻辑救了它(信还是回了),
但代价是**每轮白烧一次 + 延迟翻倍 + 一条永久错误日志**。

## 为什么不是「在服务端过滤掉」

`ListAllowedModels` 的注释明确否掉了这条路:
「不与目录做 JOIN:目录是平台上次注册时的快照……在这里用目录过滤,
只会把『目录暂时没上报但其实可用』的模型挡掉」。**这个判断是对的**,不改。

## 改法:桥侧预检(pi 桥早就有)

`pi-mail-bridge/src/worker.mjs:674-680` 同一件事已经做了,注释写着
「目录里根本没有这个路由:**同步就能判定,不必起一轮**」。
本提交把那条纪律提到共用层 `model-scope.js` 的 `partitionByCatalog`,
让 opencode / dsh / pi 共用。

**实测对比**(同一封信,修复前后):

    修复前:模型 mimo-v2.5-free 失败: Model not found…   ← 起了一轮会话才失败
    修复后:跳过 opencode/mimo-v2.5-free:平台目录里没有…  ← 同步拦下,零会话
            llmsproxy/AUTO 成功

## ★ 最要紧的一条:拿不到目录时**全部放行**

心跳还没跑过 / 拉取失败 ⇒ 目录是 `undefined`。此时若照样剔除,
Agent 会**彻底哑掉** —— 而失效方向恰是「什么都收不到」。

与 `reportModels` 拉取失败时**省略字段而不是传空数组**同一方向。
判据里对 `undefined / null / [] / 'not-an-array' / 42` 五种输入逐个断言。

`routes` 被剔空时**退回平台默认**并打日志说明「请到管理页重新划定范围」——
否则发件人只看到「本次未能处理」,而管理员看不出自己的选择已过期。

## 判据(并入共用测试,三桥同源,33/33 过)

7 格,含:线上那个 case、拿不到目录必须全放行、`undefined` 路由不归目录管、
全失效时 kept 为空(退路是策略决定不是过滤职责)、provider 同名 model 不同不算数。

变异验证两向都打红:
  ① 去掉「拿不到目录就全放行」的保护 ⇒ ★那格红
  ② 键只用 provider(半匹配)  ⇒ 4 格红
2026-09-28 09:40:15 +08:00

322 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 模型范围与降级尝试的测试。
*
* 最要紧的一条:范围为空时必须返回 `[undefined]`(试一次平台默认)而不是 `[]`。
* 返回空数组会让调用方一次都不试,等于「管理员没配」就把 Agent 彻底哑掉。
*
* node --test 'test/*.test.mjs'
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
snapshotOpencodeModels,
snapshotDshModels,
snapshotPiModels,
modelAttemptOrder,
partitionByCatalog,
renderFailureReport,
MAX_CATALOG,
} from '../lib/model-scope.js';
// ─── opencode 目录 ───
const ocConfig = {
providers: [
{
id: 'llmsproxy',
models: {
AUTO: { name: 'AUTO (smart routing)' },
'claude-sonnet-4-6': { name: 'claude-sonnet-4-6' },
},
},
{ id: 'huawei', models: { 'deepseek-v4-flash': { name: 'dpkv4' } } },
],
};
test('opencode 目录拍平 provider × model', () => {
const got = snapshotOpencodeModels(ocConfig);
assert.equal(got.length, 3);
assert.deepEqual(got[0], {
provider: 'llmsproxy',
model: 'AUTO',
display_name: 'AUTO (smart routing)',
});
});
test('models 是对象而非数组(键是 model id)', () => {
// 实测 opencode 的 /config/providers 返回 { models: { "AUTO": {...} } }。
// 当成数组处理会得到零条目而不是报错。
const got = snapshotOpencodeModels(ocConfig);
assert.ok(got.some(m => m.model === 'claude-sonnet-4-6'));
});
test('无 id 的 provider 被跳过', () => {
const got = snapshotOpencodeModels({
providers: [{ models: { a: {} } }, { id: 'ok', models: { b: {} } }],
});
assert.equal(got.length, 1);
assert.equal(got[0].provider, 'ok');
});
test('缺 name 时 display_name 为空串而不是 undefined', () => {
const got = snapshotOpencodeModels({ providers: [{ id: 'p', models: { m: {} } }] });
assert.equal(got[0].display_name, '');
});
test('opencode 目录容错:结构缺失不崩', () => {
assert.deepEqual(snapshotOpencodeModels(undefined), []);
assert.deepEqual(snapshotOpencodeModels({}), []);
assert.deepEqual(snapshotOpencodeModels({ providers: 'oops' }), []);
assert.deepEqual(snapshotOpencodeModels({ providers: [{ id: 'p', models: null }] }), []);
});
// ─── DSH 目录 ───
test('DSH 目录用 provider + id', () => {
const got = snapshotDshModels([
{ provider: 'llmsproxy', id: 'AUTO', name: 'AUTO' },
{ provider: 'deepseek', id: 'chat', name: 'DeepSeek Chat' },
]);
assert.equal(got.length, 2);
assert.deepEqual(got[1], { provider: 'deepseek', model: 'chat', display_name: 'DeepSeek Chat' });
});
test('DSH 目录跳过缺 provider 或 id 的条目', () => {
const got = snapshotDshModels([
{ provider: '', id: 'x' },
{ provider: 'p', id: '' },
{ provider: 'p', id: 'ok' },
]);
assert.equal(got.length, 1);
assert.equal(got[0].model, 'ok');
});
test('重复的 provider/model 组合去重', () => {
const got = snapshotDshModels([
{ provider: 'p', id: 'm', name: '第一次' },
{ provider: 'p', id: 'm', name: '第二次' },
]);
assert.equal(got.length, 1);
assert.equal(got[0].display_name, '第一次');
});
test('目录截断到 MAX_CATALOG', () => {
const many = Array.from({ length: MAX_CATALOG + 20 }, (_, i) => ({
provider: 'p', id: `m${i}`, name: `M${i}`,
}));
assert.equal(snapshotDshModels(many).length, MAX_CATALOG);
});
// ─── pi 目录 ───
test('pi 目录用 provider + id', () => {
const got = snapshotPiModels([
{ provider: 'llmsproxy', id: 'AUTO', name: 'AUTO' },
{ provider: 'anthropic', id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6' },
]);
assert.equal(got.length, 2);
assert.deepEqual(got[1], {
provider: 'anthropic',
model: 'claude-sonnet-4-6',
display_name: 'Claude Sonnet 4.6',
});
});
test('pi 目录跳过缺 provider 或 id 的条目', () => {
const got = snapshotPiModels([
{ provider: '', id: 'x' },
{ provider: 'p' },
{ provider: 'p', id: 'ok' },
]);
assert.equal(got.length, 1);
assert.equal(got[0].model, 'ok');
});
test('pi 目录容错:非数组不崩', () => {
assert.deepEqual(snapshotPiModels(undefined), []);
assert.deepEqual(snapshotPiModels(null), []);
assert.deepEqual(snapshotPiModels('oops'), []);
});
test('pi 目录同样受 MAX_CATALOG 截断', () => {
// 本机 pi 的完整目录有 1221 个模型(getModels),远超上限。
// 桥实际上报的是 getAvailable() 的结果(只有带凭证的),但截断仍要生效。
const many = Array.from({ length: MAX_CATALOG + 50 }, (_, i) => ({
provider: 'p', id: `m${i}`, name: `M${i}`,
}));
assert.equal(snapshotPiModels(many).length, MAX_CATALOG);
});
// ─── modelAttemptOrder ───
test('管理员划定范围时按 rank 顺序尝试', () => {
const got = modelAttemptOrder(
[{ provider: 'a', model: '1' }, { provider: 'b', model: '2' }],
{ provider: 'env', model: 'x' }
);
assert.deepEqual(got, [
{ provider: 'a', model: '1' },
{ provider: 'b', model: '2' },
]);
});
test('不变量:范围为空时返回 [undefined] 而不是 []', () => {
// 返回空数组会让调用方一次都不试 —— 「管理员没配」的正确含义是不限定,
// 不是「一个都不许用」。后者等于让 Agent 彻底哑掉。
const got = modelAttemptOrder([], undefined);
assert.equal(got.length, 1, `应有一次尝试,实际 ${got.length}`);
assert.equal(got[0], undefined, 'undefined 表示交给平台自己选');
});
test('范围为空但有环境变量时用环境变量', () => {
const got = modelAttemptOrder([], { provider: 'llmsproxy', model: 'AUTO' });
assert.deepEqual(got, [{ provider: 'llmsproxy', model: 'AUTO' }]);
});
test('不变量:范围优先于环境变量', () => {
// 范围是运行时可改的策略,环境变量是部署时的兜底。
// 反过来的话管理员在配置页改了范围却不生效,得去改 service 文件重启。
const got = modelAttemptOrder(
[{ provider: 'chosen', model: 'm' }],
{ provider: 'env', model: 'x' }
);
assert.equal(got.length, 1);
assert.equal(got[0].provider, 'chosen');
});
test('过滤掉范围里字段不全的项', () => {
const got = modelAttemptOrder(
[{ provider: 'a', model: '' }, { provider: '', model: '1' }, { provider: 'ok', model: 'm' }],
undefined
);
assert.deepEqual(got, [{ provider: 'ok', model: 'm' }]);
});
test('环境变量只给一半时不采用', () => {
assert.deepEqual(modelAttemptOrder([], { provider: 'p' }), [undefined]);
assert.deepEqual(modelAttemptOrder([], { model: 'm' }), [undefined]);
});
test('modelAttemptOrder 容错非数组', () => {
assert.deepEqual(modelAttemptOrder(undefined, undefined), [undefined]);
assert.deepEqual(modelAttemptOrder('oops', undefined), [undefined]);
});
// ─── renderFailureReport ───
test('失败报告列出每次尝试的路由与原因', () => {
const got = renderFailureReport(
[
{ provider: 'llmsproxy', model: 'AUTO', error: '429 Too Many Requests' },
{ provider: 'huawei', model: 'dpk', error: 'connect ECONNREFUSED' },
],
'缓存选型'
);
assert.match(got, /缓存选型/);
assert.match(got, /已尝试 2 个/);
assert.match(got, /llmsproxy\/AUTO/);
assert.match(got, /429 Too Many Requests/);
assert.match(got, /huawei\/dpk/);
assert.match(got, /ECONNREFUSED/);
});
test('没有路由信息时标为平台默认模型', () => {
const got = renderFailureReport([{ error: 'boom' }], '主题');
assert.match(got, /平台默认模型/);
});
test('失败报告给出可操作的下一步', () => {
// 只报错误不说怎么办,收信的人只能来问。
const got = renderFailureReport([{ error: 'x' }], '主题');
assert.match(got, /配置页/);
});
test('多行报错缩进后不破坏 Markdown 排版', () => {
const got = renderFailureReport([{ error: 'line1\nline2' }], '主题');
// 第二行也要带缩进,否则会脱离代码块、其中的字符被当作 Markdown 解析
assert.match(got, / line1\n line2/);
});
test('空主题有兜底', () => {
assert.match(renderFailureReport([{ error: 'x' }], ''), /\(无主题\)/);
assert.match(renderFailureReport([{ error: 'x' }], undefined), /\(无主题\)/);
});
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,
'真实现必须与"不过滤"的那个不同 —— 否则这组判据恒绿'
);
});