Files
MailUI4Agents/plugins/dsh-mail-bridge/test/model-scope.test.mjs
JianFeeeee 89356d4a9b feat: 每平台可用模型范围 + 降级尝试 + 失败回报
配置页为每个 Agent 平台划定「邮件场景下可用的模型」,插件按顺序逐个尝试,
全部失败把原因封装成邮件回复。目录由插件上报、管理员只做勾选 —— 手打模型名
会打错,而打错的后果要到真发邮件时才暴露成一次失败。

## 目录上报走心跳,不另设端点

模型清单会在运行中变(换 provider 配置、上游上下线、换 API key)。
只在注册时报一次的话目录会静静变陈,管理员在配置页选中一个平台其实调不到的
模型。心跳本来就是 30 秒一次的现成通道;另设一个 POST 等于给「目录是谁写的」
留两个答案,排查时要同时看两处。

心跳响应回传 `allowed_models`,因此管理员改了范围后最多一个周期生效,
不必重启插件。

与 platform_sessions 同一约定:拉不到目录时**省略字段**(保留现有目录),
传空数组会把配置页清成空白。

## 目录与选择分两张表

模型会从平台目录里消失(上游临时下线、换了 provider 配置)。合成一张带
allowed 标记的表时,整行被删就连带把管理员的选择也删了,模型回来还得重配一遍。
分开存之后「选了什么」是持久的,目录只决定「这一项现在是否可用」;
已选但不在目录里的标为 stale 显示出来 —— 不显示会让人以为自己没选过它。

## 最难的一点:模型失败不是同步抛出的

两个平台都踩了。`promptAsync()` 立即返回、`ctx.agents.create()` 不校验模型,
只包 try/catch 的话第二个模型永远不会被试到 —— 第一个无效模型会被判成成功。

必须等异步结论:
- opencode → `session.error` 事件(event 钩子在 deliverMail 之外,
  因此用 turnWatchers 表把两者接起来)
- DSH → `turn/end` 的 `reason.kind === 'error'`

DSH 还有个陷阱:**`assistant/chunk` 不能当成功信号**,它的 `finish` 子类型
也带错误 —— `{chunk:{type:'finish',reason:{kind:'error',failure:{code:'NO_ADAPTER'}}}}`。
实测「无效 provider 却判成功」正是因为把任意 chunk 当成了走通。判据要落在
chunk 的类型上:finish 看 reason,其余才意味着模型真的在产出。

超时按成功处理(60 秒窗口):模型可能只是很慢,把慢当成失败会在换模型的同时
把已经在跑的那一轮丢掉。

DSH 换模型要换会话 id(`<原 id>-r1`)并 dispose 失败那个 agent:复用同一个 id
会让重试接在一条已经出错的会话后面,不 dispose 则 agent/status 还会为那个
死会话触发一次自动转发。

## 其他决策

- **范围优先于环境变量**:范围是运行时可改的策略,`AGENTMAIL_REPLY_*` 是部署时
  的兜底。反过来的话管理员在配置页改了却不生效,得去改 service 文件重启
- **范围为空返回 `[undefined]` 而非 `[]`**:空数组会让调用方一次都不试,
  而「管理员没配」的正确含义是不限定,不是「一个都不许用」
- **上限 10 个**:降级是串行的,选 50 个意味着最坏情况下一封邮件要等 50 次超时
- 前端 key 按**第一个** `/` 切分 provider/model:model id 可能含 `/`
  (如 `org/model-name`),按最后一个切会把 provider 切错
- 保存后用服务端返回的结果刷新界面而非回显入参:repo 层会跳过重复与空字段

## 验证

- Go 10 个新测试(含「模型从目录消失后选择必须留存」的直接回归)
- 两插件各 18 个模型范围测试,共 180 个
- 端到端四轮:正常路由 → 全部无效(收到失败回报邮件,used_rounds 保持 0
  确认走了免配额通道)→ DSH 降级(fake-a 失败 → llmsproxy/AUTO 成功)→
  opencode 降级(nonexistent/bad 失败 → AUTO 成功,日志确认「前 1 个失败」)
- 生产已部署,前端「模型范围」页可用
2026-09-02 21:34:55 +08:00

208 lines
7.0 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,
modelAttemptOrder,
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);
});
// ─── 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 个/);
});