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,25 @@
export interface CatalogEntry {
provider: string;
model: string;
display_name: string;
}
export interface ModelRoute {
provider: string;
model: string;
}
export declare const MAX_CATALOG: number;
export function snapshotOpencodeModels(config: any): CatalogEntry[];
export function snapshotDshModels(entries: readonly any[]): CatalogEntry[];
export function modelAttemptOrder(
allowed: readonly ModelRoute[] | undefined,
envDefault: { provider?: string; model?: string } | undefined
): (ModelRoute | undefined)[];
export function renderFailureReport(
failures: readonly { provider?: string; model?: string; error: string }[],
subject: string
): string;

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;
}

View File

@ -28,6 +28,11 @@ import {
modelTitle,
} from '../lib/message.js';
import { snapshotDshSessions, slugFromTitle } from '../lib/session-snapshot.js';
import {
snapshotDshModels,
modelAttemptOrder,
renderFailureReport,
} from '../lib/model-scope.js';
import { resolveWorkspaceCwd, ensureCwd, mailSessionFallback } from '../lib/workspace.js';
import {
renderInbox,
@ -121,6 +126,10 @@ const reverseMap = new Map<string, string>();
const mailDrivenSessions = new Set<string>();
const mailContexts = new Map<string, { replyTo: string; subject: string; mailID: string }>();
const relayedSummaries = new Map<string, string>();
// 管理员在配置页划定的可用模型范围(按优先级)。随心跳响应更新。
// 空数组 = 不限定,回退到环境变量或平台默认。
let allowedModels: { provider: string; model: string }[] = [];
const syncedTitles = new Map<string, string>();
// 权限询问DSH 的 approval/request 是 waterfall 钩子,插件把它转成邮件问人,
@ -259,15 +268,17 @@ export function apply(ctx: any, config: PluginConfig): void {
}
async function beat(): Promise<void> {
let body: Record<string, unknown> = {};
const entries = await collectSessions();
const body: Record<string, unknown> = {};
const [entries, models] = await Promise.all([collectSessions(), collectModels()]);
if (entries) {
body = {
platform_sessions: snapshotDshSessions(entries, (id) => mailDrivenSessions.has(id)),
};
body.platform_sessions = snapshotDshSessions(entries, (id) => mailDrivenSessions.has(id));
}
if (models) body.models = models;
try {
await client.post('/agent/heartbeat', body);
const res = await client.post('/agent/heartbeat', body);
// 生效的模型范围随心跳响应回传:管理员在配置页改了范围后,
// 插件最多一个周期30 秒)就能看到新值,不需要重启。
if (Array.isArray(res?.allowed_models)) allowedModels = res.allowed_models;
} catch {
// 心跳失败不报错:网络抖动很常见,下一轮会补上。
// 真的持续连不上时 Gateway 会把它判成离线,那才是可见的信号。
@ -282,16 +293,110 @@ export function apply(ctx: any, config: PluginConfig): void {
// ─── 获取默认模型 ───
function modelSelection(): { provider: string; model: string } | undefined {
if (REPLY_PROVIDER && REPLY_MODEL) {
return { provider: REPLY_PROVIDER, model: REPLY_MODEL };
/**
* 这一轮按什么顺序尝试模型。
*
* 优先级:管理员划定的范围 > 环境变量指定 > 平台自己的默认选择。
* 范围是运行时可改的策略,环境变量是部署时的兜底,因此前者优先。
*/
function attemptOrder(): ({ provider: string; model: string } | undefined)[] {
const envDefault = REPLY_PROVIDER && REPLY_MODEL
? { provider: REPLY_PROVIDER, model: REPLY_MODEL }
: ctx.get('agentDefaultModel')?.currentSelection?.();
return modelAttemptOrder(allowedModels, envDefault);
}
/** 收集本机 DSH 看得见的模型目录,供配置页勾选。 */
async function collectModels(): Promise<any[] | undefined> {
const llm = ctx.get('llm');
if (!llm?.listProviders || !llm?.listModels) return undefined;
try {
const flat: any[] = [];
for (const p of llm.listProviders()) {
const id = p?.provider ?? p?.id;
if (typeof id !== 'string' || !id) continue;
try {
const models = await llm.listModels(id);
for (const m of models) flat.push(m);
} catch {
// 单个 provider 拉不到不该拖掉其他 provider ——
// 上游故障通常只影响一家
}
}
return snapshotDshModels(flat);
} catch {
// 拉不到就省略该字段,而不是上报空数组把配置页清成空白
return undefined;
}
const defaults = ctx.get('agentDefaultModel');
const sel = defaults?.currentSelection?.();
if (sel?.provider && sel.model) {
return { provider: sel.provider, model: sel.model };
}
return undefined;
}
/**
* 等这个会话的首轮真正跑起来,或者失败。
*
* **`ctx.agents.create()` 不会因为模型无效而失败** —— 它只是记下 agentOptions。
* 真正的失败发生在之后的 turn 里,异步抛出:
*
* request/context {provider: "nonexistent", model: "x"}
* turn/end {reason: {kind: "error", error: {code: "NO_ADAPTER", ...}}}
*
* 因此降级尝试不能只包一个 try/catch —— 那样第二个模型永远不会被试到。
* 这里用 `session/event` 观察 turn 的走向。
*
* **`assistant/chunk` 本身不是成功信号**:它的 `finish` 子类型也带错误 ——
*
* {chunk: {type: 'finish', reason: {kind: 'error', failure: {code: 'NO_ADAPTER'}}}}
*
* 见过一次「无效 provider 却判成功」正是因为把任意 chunk 当成了走通。
* 判据要落在 chunk 的类型上:`finish` 看 reason其余`block-start`、
* `text-delta`、`tool-call-delta`…)才意味着模型真的在产出。
*
* 超时按「成功」处理:模型可能只是很慢(首 token 前要装载上下文),
* 把慢当成失败会在换模型的同时把已经在跑的那一轮丢掉。
*
* @param agent 刚建好的 agent
* @param timeoutMs 判定窗口
*/
function awaitFirstTurn(agent: any, timeoutMs = 60_000): Promise<{ ok: true } | { ok: false; error: string }> {
return new Promise((resolve) => {
let done = false;
const finish = (r: { ok: true } | { ok: false; error: string }) => {
if (done) return;
done = true;
clearTimeout(timer);
dispose?.();
resolve(r);
};
const timer = setTimeout(() => finish({ ok: true }), timeoutMs);
/** 把 DSH 的错误对象拼成一行可读文本。 */
const describe = (err: any): string =>
[err?.code, err?.message].filter(Boolean).join(': ') || '未知错误';
const dispose = ctx.on('session/event', (session: any, event: any) => {
if (session !== agent.session) return;
if (event?.type === 'assistant/chunk') {
const chunk = event.data?.chunk;
if (chunk?.type === 'finish') {
// finish 是这一步的收尾,可能成功也可能失败
if (chunk.reason?.kind === 'error') {
return finish({ ok: false, error: describe(chunk.reason.failure) });
}
return; // 正常收尾,等 turn/end 定论
}
// 其余 chunk 类型 = 模型真的在产出内容
return finish({ ok: true });
}
if (event?.type === 'turn/end') {
const reason = event.data?.reason;
if (reason?.kind === 'error') {
return finish({ ok: false, error: describe(reason.error) });
}
// 正常结束completed/canceled也算走通了 —— 有些轮次不产出 chunk
return finish({ ok: true });
}
});
});
}
// ─── 投递邮件到 DSH 会话 ───
@ -335,11 +440,6 @@ export function apply(ctx: any, config: PluginConfig): void {
`[dsh-mail-bridge] 工作目录 ${data.to_workspace} 不可用,回退到 ${cwd}`);
}
const selection = modelSelection();
const agentOpts = selection
? { provider: selection.provider, model: selection.model }
: {};
const promptText = kind === 'permission'
? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续。`
: [
@ -357,29 +457,100 @@ export function apply(ctx: any, config: PluginConfig): void {
`只有在需要主动联系其他人、或要带附件时才调用 send_mail。`,
].join('\n');
const handle = await ctx.agents.create({
sessionId,
meta: { cwd },
agentOptions: agentOpts,
// setup 留空DSH 的 base bundle 已经注册了 agent-loop、llm、tools 等服务。
// modelSelection 通过 agentOptions 传入即可 —— 挂载 preset 或
// installModelSelection 反而会让 turn 崩溃(实测)
setup: undefined,
});
// 按管理员划定的范围逐个尝试建 agent全部失败才回一封说明原因的邮件。
//
// 必须发那封信agent 一次都没建起来时会话里没有任何 assistant 消息,
// 自动转发因此什么也不会发 —— 发件人只会看到邮件发出去后再无音讯。
//
// DSH 与 opencode 的差异:这里失败在**建 agent** 时就暴露(模型路由是
// agentOptions 的一部分),而 opencode 是在 promptAsync 时
const attempts = attemptOrder();
const failures: { provider?: string; model?: string; error: string }[] = [];
if (mailSessionID) {
sessionMap.set(mailSessionID, { dshSessionId: sessionId, directory: cwd });
reverseMap.set(sessionId, mailSessionID);
mailDrivenSessions.add(sessionId);
mailContexts.set(mailSessionID, {
replyTo: data.from_name || '',
subject: data.subject || '',
mailID: data.mail_id || '',
});
for (let i = 0; i < attempts.length; i++) {
const route = attempts[i];
const label = route ? `${route.provider}/${route.model}` : '(平台默认)';
// 每次尝试用不同的会话 id失败那次的日志里已经有 turn/end error
// 复用同一个 id 会让重试接在一条已经出错的会话后面。
const attemptSessionId = i === 0 ? sessionId : `${sessionId}-r${i}`;
let handle: any;
try {
handle = await ctx.agents.create({
sessionId: attemptSessionId,
meta: { cwd },
// route 为 undefined 表示不指定模型,交给平台自己选
agentOptions: route ? { provider: route.provider, model: route.model } : {},
// setup 留空DSH 的 base bundle 已经注册了 agent-loop、llm、tools 等服务。
// 模型路由通过 agentOptions 传入即可 —— 挂载 preset 或
// installModelSelection 反而会让 turn 崩溃(实测)。
setup: undefined,
});
} catch (e: any) {
// create 本身很少失败(它不校验模型),但会话 id 冲突之类仍会抛
failures.push({ ...(route ?? {}), error: e?.message || String(e) });
ctx.logger.error(`[dsh-mail-bridge] 建会话失败 ${label}: ${e?.message || e}`);
continue;
}
// 先建映射再 followupturn 可能在 followup 返回前就产出事件,
// 而 agent/status 的处理要靠这些映射找到回信地址。
if (mailSessionID) {
sessionMap.set(mailSessionID, { dshSessionId: attemptSessionId, directory: cwd });
reverseMap.set(attemptSessionId, mailSessionID);
mailDrivenSessions.add(attemptSessionId);
mailContexts.set(mailSessionID, {
replyTo: data.from_name || '',
subject: data.subject || '',
mailID: data.mail_id || '',
});
}
const watching = awaitFirstTurn(handle.agent);
handle.agent.followup(userMessage(promptText));
const outcome = await watching;
if (outcome.ok) {
if (failures.length > 0) {
ctx.logger.info(`[dsh-mail-bridge] ${label} 成功(前 ${failures.length} 个失败)`);
}
return { sessionID: attemptSessionId, reused: false };
}
failures.push({ ...(route ?? {}), error: outcome.error });
ctx.logger.error(`[dsh-mail-bridge] 模型 ${label} 失败: ${outcome.error}`);
// 拆掉这一路的 agent 与映射,否则它会占着会话 id
// 而 agent/status 还会为这个死会话触发一次自动转发
reverseMap.delete(attemptSessionId);
mailDrivenSessions.delete(attemptSessionId);
try {
await handle.dispose();
} catch {
// dispose 失败不影响换下一个模型
}
}
if (mailSessionID) sessionMap.delete(mailSessionID);
handle.agent.followup(userMessage(promptText));
return { sessionID: sessionId, reused: false };
// 全部失败:把原因作为邮件回给发件人。走免配额通道 ——
// 这是插件的故障报告,不是模型的自主发信。
if (kind === 'mail' && data.from_name) {
try {
await client.post('/mail/send', {
to: data.from_name,
subject: `处理失败: ${data.subject || '(无主题)'}`,
body: renderFailureReport(failures, data.subject),
reply_to: data.mail_id || '',
relay: 'summary',
relay_key: `model-failure:${data.mail_id || sessionId}`,
});
ctx.logger.info(`[dsh-mail-bridge] 已回报模型调用失败给 ${data.from_name}`);
} catch (e: any) {
ctx.logger.error(`[dsh-mail-bridge] 失败回报也发不出去: ${e?.message || e}`);
}
}
throw new Error(
`划定范围内的 ${failures.length} 个模型全部失败:` +
failures.map(f => f.error).join(' | '));
}
// ─── SSE 监听(与 opencode-mail-bridge 相同的 fetch + reader 模式)───

View File

@ -0,0 +1,207 @@
/**
* 模型范围与降级尝试的测试。
*
* 最要紧的一条:范围为空时必须返回 `[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 个/);
});

View File

@ -7,6 +7,11 @@ import { join, dirname, basename } from "node:path";
// 都当成插件工厂,入口文件多导出一个东西就会 "Plugin export is not a function"。
import { snapshotOpencodeSessions } from "./lib/session-snapshot.js";
import { resolveWorkspaceCwd } from "./lib/workspace.js";
import {
snapshotOpencodeModels,
modelAttemptOrder,
renderFailureReport,
} from "./lib/model-scope.js";
import {
renderInbox,
idsToMarkRead,
@ -446,6 +451,10 @@ const relayedSummaries = new Map(); // opencode session id -> assistant message
// 用户在 TUI 里自己开的会话不该被搬进邮件系统。
const mailDrivenSessions = new Set(); // opencode session id
// 管理员在配置页划定的可用模型范围(按优先级)。随心跳响应更新。
// 空数组 = 不限定,回退到环境变量或平台默认。
let allowedModels = [];
async function resolveSessionForMail(client, directory, data, kind) {
const mailSessionID = data.session_id;
const bound = mailSessionID ? sessionMap.get(mailSessionID) : undefined;
@ -650,16 +659,116 @@ async function deliverMail(client, directory, data, kind) {
`只有在需要主动联系其他人、或要带附件时才调用 send_mail。`,
].join("\n");
await client.session.promptAsync({
path: { id: sessionID },
query: directory ? { directory } : undefined,
body: {
model: { providerID: REPLY_PROVIDER, modelID: REPLY_MODEL },
parts: [{ type: "text", text }],
},
// 按管理员划定的范围逐个尝试,全部失败才回一封说明失败原因的邮件。
//
// 必须发那封信:模型一次都没跑起来时会话里没有任何 assistant 消息,
// 自动转发因此什么也不会发 —— 发件人只会看到邮件发出去后再无音讯。
//
// **promptAsync 返回不代表模型跑起来了**(名字里的 Async 就是这个意思):
// 无效 provider 的失败通过 `session.error` 事件到达,而不是它的 reject。
// 因此只包 try/catch 的话第二个模型永远不会被试到 —— 用 awaitFirstTurn 等结论。
const attempts = modelAttemptOrder(allowedModels, {
provider: REPLY_PROVIDER,
model: REPLY_MODEL,
});
const failures = [];
return { sessionID, reused };
for (const route of attempts) {
const label = route ? `${route.provider}/${route.model}` : "(平台默认)";
const watching = awaitFirstTurn(sessionID);
try {
await client.session.promptAsync({
path: { id: sessionID },
query: directory ? { directory } : undefined,
body: {
// route 为 undefined 表示不指定模型,交给平台自己选
...(route ? { model: { providerID: route.provider, modelID: route.model } } : {}),
parts: [{ type: "text", text }],
},
});
} catch (e) {
// 同步就被拒(参数非法、会话不存在等)
watching.cancel();
failures.push({ ...(route ?? {}), error: e?.message || String(e) });
console.error(`[mail-bridge] 模型 ${label} 提交失败:`, e?.message || e);
continue;
}
const outcome = await watching.result;
if (outcome.ok) {
if (failures.length > 0) {
console.error(`[mail-bridge] ${label} 成功(前 ${failures.length} 个失败)`);
}
return { sessionID, reused };
}
failures.push({ ...(route ?? {}), error: outcome.error });
console.error(`[mail-bridge] 模型 ${label} 失败:`, outcome.error);
}
// 全部失败:把原因作为邮件回给发件人。走免配额通道 ——
// 这是插件的故障报告,不是模型的自主发信。
if (kind === "mail" && data.from_name) {
try {
await apiPost("/mail/send", {
to: data.from_name,
subject: `处理失败: ${data.subject || "(无主题)"}`,
body: renderFailureReport(failures, data.subject),
reply_to: data.mail_id || "",
relay: "summary",
relay_key: `model-failure:${data.mail_id || sessionID}`,
});
console.error(`[mail-bridge] 已回报模型调用失败给 ${data.from_name}`);
} catch (e) {
console.error("[mail-bridge] 失败回报也发不出去:", e?.message || e);
}
}
throw new Error(
`划定范围内的 ${failures.length} 个模型全部失败:` +
failures.map(f => f.error).join(" | "));
}
// ─── 首轮结果观察 ───
//
// opencode 的事件是通过插件的 event 钩子进来的,而 deliverMail 在钩子之外,
// 因此这里用一个「等待者」表event 钩子看到 session.error / session.idle 时
// 唤醒对应会话的等待者。
const turnWatchers = new Map(); // sessionID -> { resolve, timer }
/**
* 等这个会话的首轮跑起来或失败。
*
* 超时按「成功」处理:模型可能只是很慢(首 token 前要装载上下文),
* 把慢当成失败会在换模型的同时把已经在跑的那一轮丢掉。
*
* @param {string} sessionID opencode 会话 id
* @param {number} timeoutMs 判定窗口
*/
function awaitFirstTurn(sessionID, timeoutMs = 60000) {
let settle;
const result = new Promise(res => { settle = res; });
const finish = (r) => {
const w = turnWatchers.get(sessionID);
if (!w) return;
clearTimeout(w.timer);
turnWatchers.delete(sessionID);
w.resolve(r);
};
const timer = setTimeout(() => finish({ ok: true }), timeoutMs);
turnWatchers.set(sessionID, { resolve: settle, timer });
return {
result,
cancel: () => finish({ ok: true }),
};
}
/** event 钩子调用:这个会话的首轮有结论了。 */
function settleFirstTurn(sessionID, outcome) {
const w = turnWatchers.get(sessionID);
if (!w) return false;
clearTimeout(w.timer);
turnWatchers.delete(sessionID);
w.resolve(outcome);
return true;
}
export default async function mailBridge(input) {
@ -719,10 +828,40 @@ export default async function mailBridge(input) {
}
}
// 模型目录:随心跳上报,让配置页看到的清单跟着平台的实际状态走。
//
// 只在注册时报一次的话目录会静静变陈(换 provider 配置、上游上下线、
// 换 API key 都会让它失准),管理员会选中一个平台其实调不到的模型,
// 而失败要到真发邮件时才暴露。
async function reportModels() {
try {
const cfg = await client.config.providers();
return snapshotOpencodeModels(cfg?.data ?? cfg);
} catch (e) {
// 拉不到就**省略**该字段,而不是传空数组:
// 空数组的语义是「平台确实一个模型都拿不到」,会把配置页清成空白。
console.error("[mail-bridge] 模型目录读取失败:", e?.message || e);
return undefined;
}
}
const beat = async () => {
const platform_sessions = await reportSessions();
const body = platform_sessions ? { platform_sessions } : {};
apiPost("/agent/heartbeat", body).catch(() => {});
const [platform_sessions, models] = await Promise.all([
reportSessions(),
reportModels(),
]);
const body = {};
if (platform_sessions) body.platform_sessions = platform_sessions;
if (models) body.models = models;
try {
const res = await apiPost("/agent/heartbeat", body);
// 生效的模型范围随心跳响应回传:管理员在配置页改了范围后,
// 插件最多一个周期30 秒)就能看到新值,不需要重启。
if (Array.isArray(res?.allowed_models)) allowedModels = res.allowed_models;
} catch {
// 心跳失败不报错:网络抖动很常见,下一轮会补上。
// 真的持续连不上时 Gateway 会把它判成离线,那才是可见的信号。
}
};
beat();
const heartbeat = setInterval(beat, 30000);
@ -826,8 +965,25 @@ export default async function mailBridge(input) {
// 2) 一轮跑完 → 把最后那段话作为回信转出去(不消耗配额)。
// 用 session.idle 而不是 message.updated后者在流式生成中反复触发
// 转出去的会是半截话。
// 1.5) 模型调用出错 → 唤醒等待者,让 deliverMail 换下一个模型。
// promptAsync 已经返回过了,失败只能从这里得知。
if (event?.type === "session.error") {
const sid = event.properties?.sessionID;
const err = event.properties?.error;
const msg = err?.data?.message || err?.name || JSON.stringify(err ?? {});
if (sid && settleFirstTurn(sid, { ok: false, error: msg })) {
return; // 正在降级尝试中,不当作一次普通故障
}
if (sid && mailDrivenSessions.has(sid)) {
console.error(`[mail-bridge] 会话 ${sid} 出错:`, msg);
}
return;
}
if (event?.type === "session.idle") {
const sid = event.properties?.sessionID;
// 首轮跑到 idle = 这一路走通了(哪怕没产出文本)
if (sid) settleFirstTurn(sid, { ok: true });
if (!sid || !mailDrivenSessions.has(sid)) return;
try {
const res = await relaySummaryRef(sid);

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;
}

View File

@ -0,0 +1,207 @@
/**
* 模型范围与降级尝试的测试。
*
* 最要紧的一条:范围为空时必须返回 `[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 个/);
});