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 个失败」)
- 生产已部署,前端「模型范围」页可用
This commit is contained in:
2026-09-02 21:34:55 +08:00
parent 7c9be9fd58
commit 89356d4a9b
20 changed files with 2172 additions and 69 deletions

View File

@ -0,0 +1,138 @@
/**
* 平台模型目录的整理与降级选择 —— 所有平台插件共用。
*
* 两个职责:
* 1. 把各平台的 provider/model 结构整理成统一的上报格式(随心跳发给 Gateway
* 2. 按管理员划定的范围决定「先试哪个、再试哪个」
*
* 为什么随心跳上报而不是只在注册时报一次:模型清单会在运行中变(换 provider
* 配置、上游上下线、换 API key。只在注册时报的话目录会静静变陈而管理员
* 在配置页上看到的是上次重启时的快照 —— 选中一个平台已经调不到的模型,
* 失败要到真发邮件时才暴露。
*/
/** 单次上报的模型数上限。与服务端的 maxCatalogModels 一致。 */
export const MAX_CATALOG = 300;
/**
* 把 opencode 的 `/config/providers` 响应整理成上报格式。
*
* @param {any} config `client.config.providers()` 的结果
* @returns {object[]} `[{ provider, model, display_name }]`
*/
export function snapshotOpencodeModels(config) {
const providers = Array.isArray(config?.providers) ? config.providers : [];
const out = [];
for (const p of providers) {
const provider = typeof p?.id === 'string' ? p.id : '';
if (!provider) continue;
// models 是对象而非数组:键是 model id值是元数据
const models = p?.models && typeof p.models === 'object' ? p.models : {};
for (const [id, meta] of Object.entries(models)) {
if (!id) continue;
out.push({
provider,
model: id,
display_name: typeof meta?.name === 'string' ? meta.name : '',
});
}
}
return dedupeAndCap(out);
}
/**
* 把 DSH 的 provider/model 列表整理成上报格式。
*
* DSH 侧要先 `ctx.llm.listProviders()` 再对每个 provider `listModels()`
* 因此这里收的是已经拍平的结果。
*
* @param {any[]} entries `[{ provider, id, name }]`
* @returns {object[]}
*/
export function snapshotDshModels(entries) {
const list = Array.isArray(entries) ? entries : [];
const out = [];
for (const m of list) {
const provider = typeof m?.provider === 'string' ? m.provider : '';
const model = typeof m?.id === 'string' ? m.id : '';
if (!provider || !model) continue;
out.push({
provider,
model,
display_name: typeof m?.name === 'string' ? m.name : '',
});
}
return dedupeAndCap(out);
}
/**
* 决定这一轮按什么顺序尝试模型。
*
* 三种情形:
*
* 1. **管理员划定了范围** → 按 rank 顺序(服务端已排好),逐个降级
* 2. **没划定范围**`allowed` 为空)→ 返回 `[undefined]`
* 表示「用平台自己的默认模型试一次」。**不是**空数组:
* 空数组会让调用方一次都不试,等于让 Agent 彻底哑掉,
* 而「管理员没配」的正确含义是不限定。
* 3. **插件配了 `AGENTMAIL_REPLY_PROVIDER`/`MODEL`** → 那是部署方的显式指定,
* 优先于「平台默认」,但**不优先于管理员划定的范围**
* 范围是运行时可改的策略,环境变量是部署时的兜底。
*
* @param {readonly {provider: string, model: string}[]} allowed 管理员划定的范围(按 rank
* @param {{provider?: string, model?: string}|undefined} envDefault 环境变量指定的模型
* @returns {(({provider: string, model: string})|undefined)[]} 依次尝试的候选;
* `undefined` 表示这一次不指定模型、交给平台
*/
export function modelAttemptOrder(allowed, envDefault) {
const list = Array.isArray(allowed) ? allowed.filter(m => m?.provider && m?.model) : [];
if (list.length > 0) return list.map(m => ({ provider: m.provider, model: m.model }));
if (envDefault?.provider && envDefault?.model) {
return [{ provider: envDefault.provider, model: envDefault.model }];
}
return [undefined];
}
/**
* 把多次尝试的失败原因整理成一封邮件正文。
*
* 全部失败时必须发这封信:模型一次都没跑起来,会话里没有任何 assistant 消息,
* 自动转发因此什么也不会发 —— 发件人只会看到邮件发出去后再无音讯。
*
* @param {{provider?: string, model?: string, error: string}[]} failures 每次尝试的失败
* @param {string} subject 原邮件主题
* @returns {string} Markdown 正文
*/
export function renderFailureReport(failures, subject) {
const list = Array.isArray(failures) ? failures : [];
const lines = [
`本次未能处理「${subject || '(无主题)'}」:划定范围内的模型全部调用失败。`,
'',
`已尝试 ${list.length} 个:`,
'',
];
list.forEach((f, i) => {
const route = f?.provider && f?.model ? `${f.provider}/${f.model}` : '(平台默认模型)';
lines.push(`${i + 1}. **${route}**`);
// 缩进四格让报错原文成为代码块,避免其中的 Markdown 字符影响排版
lines.push(` ${String(f?.error ?? '未知错误').replace(/\n/g, '\n ')}`);
});
lines.push('');
lines.push('可能的原因模型已下线、API key 失效、上游限流,或该 provider 未在平台侧配置。');
lines.push('调整可用模型范围:配置页 → Agent 模型范围。');
return lines.join('\n');
}
/** 去重provider/model 组合)并截断。 */
function dedupeAndCap(list) {
const seen = new Set();
const out = [];
for (const m of list) {
const key = `${m.provider}/${m.model}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(m);
if (out.length >= MAX_CATALOG) break;
}
return out;
}