Files
MailUI4Agents/plugins/dsh-mail-bridge/lib/model-scope.js
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

139 lines
5.5 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.

/**
* 平台模型目录的整理与降级选择 —— 所有平台插件共用。
*
* 两个职责:
* 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;
}