Files
MailUI4Agents/gateway/internal/repo/models_scope.go
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

239 lines
7.8 KiB
Go
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.

package repo
import (
"context"
"strings"
"github.com/agentmail/gateway/internal/db"
)
// ---------- 邮件场景下的可用模型 ----------
//
// 两张表,两种真相:
//
// agent_model_catalog —— 平台**上报**它当前看得见哪些模型(注册时整表替换)
// agent_allowed_models —— 管理员**选定**其中哪些能在邮件场景下用rank 即优先级
//
// 为什么不合成一张带 allowed 标记的表:模型会从平台目录里消失(换了 provider 配置、
// 上游临时下线),那时整行被删掉就连带把管理员的选择也删了,模型回来还得重配一遍。
// 分开存之后,「选了什么」是持久的,目录只决定「这一项现在是否可用」。
//
// 为什么让平台上报而不是在 Gateway 里配一张静态表:模型清单是平台侧的事实 ——
// opencode 的 provider 配置、DSH 的 llm 适配器注册,都可能随时变。
// Gateway 猜不出来,猜错的后果是管理员在配置页选了一个平台其实调不到的模型。
// ModelRef 是一次「provider + model」路由。
type ModelRef struct {
Provider string `json:"provider"`
Model string `json:"model"`
}
// CatalogModel 是平台上报的一个可选模型。
type CatalogModel struct {
Provider string `json:"provider"`
Model string `json:"model"`
DisplayName string `json:"display_name,omitempty"`
// Allowed 表示它已被管理员选入邮件场景。
// 与目录合并后一起返回,前端才能画出「已勾选」的复选框。
Allowed bool `json:"allowed"`
// Rank 仅在 Allowed 为真时有意义,越小越先试。
Rank int `json:"rank,omitempty"`
}
// maxCatalogModels 限制单个 Agent 上报的模型数。
//
// 有平台会把上游的全部模型都列出来(实测 opencode 的一个 provider 就有几十个),
// 无上限的话一次注册能写进几千行,而配置页面上几千个复选框对人毫无用处。
const maxCatalogModels = 300
// ReplaceModelCatalog 整表替换某 Agent 上报的模型目录。
//
// 整表替换而非增量合并:目录是平台当前状态的快照,
// 增量合并会让已经下线的模型永远留在列表里,而那正是「选了却调不到」的来源。
//
// 事务包住删+插:中途失败留下一个空目录,会让配置页显示「该平台没有可用模型」
// 而管理员根本没做任何操作。
func ReplaceModelCatalog(ctx context.Context, agentName string, models []CatalogModel) error {
agentName = strings.TrimSpace(agentName)
if agentName == "" {
return nil
}
if len(models) > maxCatalogModels {
models = models[:maxCatalogModels]
}
tx, err := db.DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`DELETE FROM agent_model_catalog WHERE agent_name = $1`, agentName); err != nil {
return err
}
seen := map[string]bool{}
for _, m := range models {
p := strings.TrimSpace(m.Provider)
id := strings.TrimSpace(m.Model)
if p == "" || id == "" {
continue // 半条记录不如不要:它在配置页上是一个点不动的空复选框
}
key := p + "/" + id
if seen[key] {
continue
}
seen[key] = true
if _, err := tx.ExecContext(ctx,
`INSERT INTO agent_model_catalog (agent_name, provider, model, display_name, reported_at)
VALUES ($1, $2, $3, $4, NOW())`,
agentName, p, id, strings.TrimSpace(m.DisplayName)); err != nil {
return err
}
}
return tx.Commit()
}
// ListModelCatalog 返回某 Agent 的模型目录,并标出哪些已被选入邮件场景。
//
// LEFT JOIN 而不是两次查询:前端要的是一份「带勾选状态的清单」,
// 在 SQL 里合完比让前端自己对齐两个数组更难出错。
func ListModelCatalog(ctx context.Context, agentName string) ([]CatalogModel, error) {
rows, err := db.DB.QueryContext(ctx, `
SELECT c.provider, c.model, c.display_name,
CASE WHEN a.model IS NULL THEN 0 ELSE 1 END AS allowed,
COALESCE(a.rank, 0)
FROM agent_model_catalog c
LEFT JOIN agent_allowed_models a
ON a.agent_name = c.agent_name
AND a.provider = c.provider
AND a.model = c.model
WHERE c.agent_name = $1
ORDER BY c.provider, c.model
`, agentName)
if err != nil {
return nil, err
}
defer rows.Close()
out := []CatalogModel{}
for rows.Next() {
var m CatalogModel
var allowed int
if err := rows.Scan(&m.Provider, &m.Model, &m.DisplayName, &allowed, &m.Rank); err != nil {
return nil, err
}
m.Allowed = allowed == 1
out = append(out, m)
}
return out, rows.Err()
}
// ListAllowedModels 按 rank 返回该 Agent 在邮件场景下可用的模型。
//
// **不与目录做 JOIN**:目录是平台上次注册时的快照,插件重启前可能已经过期。
// 真正能不能调通只有插件试过才知道 —— 这也正是插件要按顺序降级的原因。
// 在这里用目录过滤,只会把「目录暂时没上报但其实可用」的模型挡掉。
func ListAllowedModels(ctx context.Context, agentName string) ([]ModelRef, error) {
rows, err := db.DB.QueryContext(ctx, `
SELECT provider, model FROM agent_allowed_models
WHERE agent_name = $1
ORDER BY rank ASC, provider ASC, model ASC
`, agentName)
if err != nil {
return nil, err
}
defer rows.Close()
out := []ModelRef{}
for rows.Next() {
var m ModelRef
if err := rows.Scan(&m.Provider, &m.Model); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// ListStaleAllowedModels 返回已选但已不在平台目录里的模型。
//
// 平台可能临时下线了某个模型(换了 provider 配置、上游故障),
// 而管理员的选择是持久的。界面上不显示这些项的话,管理员会以为自己
// 没选过它们 —— 而它们其实还在被插件尝试ListAllowedModels 不与目录 JOIN
func ListStaleAllowedModels(ctx context.Context, agentName string) ([]ModelRef, error) {
rows, err := db.DB.QueryContext(ctx, `
SELECT a.provider, a.model
FROM agent_allowed_models a
WHERE a.agent_name = $1
AND NOT EXISTS (
SELECT 1 FROM agent_model_catalog c
WHERE c.agent_name = a.agent_name
AND c.provider = a.provider
AND c.model = a.model
)
ORDER BY a.rank ASC
`, agentName)
if err != nil {
return nil, err
}
defer rows.Close()
out := []ModelRef{}
for rows.Next() {
var m ModelRef
if err := rows.Scan(&m.Provider, &m.Model); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// SetAllowedModels 整表替换某 Agent 的邮件场景可用模型,入参顺序即优先级。
//
// 允许传空列表:那表示「不限定」——插件回退到平台自己的默认模型。
// 这与「一个都不许用」不同,后者等于让 Agent 彻底哑掉,不该是一次误删的后果。
func SetAllowedModels(ctx context.Context, agentName string, picks []ModelRef) error {
agentName = strings.TrimSpace(agentName)
if agentName == "" {
return nil
}
tx, err := db.DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`DELETE FROM agent_allowed_models WHERE agent_name = $1`, agentName); err != nil {
return err
}
rank := 0
seen := map[string]bool{}
for _, m := range picks {
p := strings.TrimSpace(m.Provider)
id := strings.TrimSpace(m.Model)
if p == "" || id == "" {
continue
}
key := p + "/" + id
if seen[key] {
// 重复项直接跳过而不是报错:它对最终顺序没有影响,
// 为一次无害的重复让整次保存失败只会让人以为配置没生效。
continue
}
seen[key] = true
if _, err := tx.ExecContext(ctx,
`INSERT INTO agent_allowed_models (agent_name, provider, model, rank)
VALUES ($1, $2, $3, $4)`, agentName, p, id, rank); err != nil {
return err
}
rank++
}
return tx.Commit()
}