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

300 lines
9.1 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"
"testing"
)
// 目录与选择分两张表,是为了让「已选」在模型从平台目录里消失后仍然留存。
// 这个测试钉住那个行为 —— 合并成一张带 allowed 标记的表就会失败。
func TestAllowedModelsSurviveCatalogChurn(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
if err := ReplaceModelCatalog(ctx, "dsh", []CatalogModel{
{Provider: "llmsproxy", Model: "AUTO", DisplayName: "AUTO"},
{Provider: "llmsproxy", Model: "claude-sonnet-4-6"},
}); err != nil {
t.Fatalf("首次上报目录: %v", err)
}
if err := SetAllowedModels(ctx, "dsh", []ModelRef{
{Provider: "llmsproxy", Model: "AUTO"},
}); err != nil {
t.Fatalf("保存选择: %v", err)
}
// 平台侧 AUTO 临时下线,只上报另一个
if err := ReplaceModelCatalog(ctx, "dsh", []CatalogModel{
{Provider: "llmsproxy", Model: "claude-sonnet-4-6"},
}); err != nil {
t.Fatalf("二次上报目录: %v", err)
}
allowed, err := ListAllowedModels(ctx, "dsh")
if err != nil {
t.Fatalf("ListAllowedModels: %v", err)
}
if len(allowed) != 1 || allowed[0].Model != "AUTO" {
t.Fatalf("模型从目录消失后选择也被删了:%+v —— "+
"两张表分开存的意义就在于此", allowed)
}
// 它应当被标为 stale界面上才能提示「已选但平台没上报」
stale, err := ListStaleAllowedModels(ctx, "dsh")
if err != nil {
t.Fatalf("ListStaleAllowedModels: %v", err)
}
if len(stale) != 1 || stale[0].Model != "AUTO" {
t.Errorf("应有 1 个 stale实际 %+v", stale)
}
// 模型回来后不该再是 stale也不需要重新勾选
if err := ReplaceModelCatalog(ctx, "dsh", []CatalogModel{
{Provider: "llmsproxy", Model: "AUTO"},
{Provider: "llmsproxy", Model: "claude-sonnet-4-6"},
}); err != nil {
t.Fatalf("三次上报: %v", err)
}
stale2, _ := ListStaleAllowedModels(ctx, "dsh")
if len(stale2) != 0 {
t.Errorf("模型回来后不该再是 stale%+v", stale2)
}
}
// 目录整表替换:平台下线的模型必须从配置页消失,
// 否则管理员会勾选一个平台其实调不到的模型。
func TestReplaceModelCatalogIsFullReplace(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
if err := ReplaceModelCatalog(ctx, "opencode", []CatalogModel{
{Provider: "p", Model: "a"},
{Provider: "p", Model: "b"},
}); err != nil {
t.Fatalf("首次: %v", err)
}
if err := ReplaceModelCatalog(ctx, "opencode", []CatalogModel{
{Provider: "p", Model: "a"},
}); err != nil {
t.Fatalf("二次: %v", err)
}
got, err := ListModelCatalog(ctx, "opencode")
if err != nil {
t.Fatalf("ListModelCatalog: %v", err)
}
if len(got) != 1 || got[0].Model != "a" {
t.Fatalf("整表替换失效:%+v", got)
}
}
// ListModelCatalog 要在同一次查询里标出「已选」与 rank
// 前端才能画出带勾选与顺序的清单。
func TestListModelCatalogMarksAllowedAndRank(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
if err := ReplaceModelCatalog(ctx, "dsh", []CatalogModel{
{Provider: "p", Model: "first", DisplayName: "第一"},
{Provider: "p", Model: "second"},
{Provider: "p", Model: "unpicked"},
}); err != nil {
t.Fatalf("上报: %v", err)
}
// 顺序即优先级second 排前面
if err := SetAllowedModels(ctx, "dsh", []ModelRef{
{Provider: "p", Model: "second"},
{Provider: "p", Model: "first"},
}); err != nil {
t.Fatalf("保存: %v", err)
}
got, err := ListModelCatalog(ctx, "dsh")
if err != nil {
t.Fatalf("ListModelCatalog: %v", err)
}
byModel := map[string]CatalogModel{}
for _, m := range got {
byModel[m.Model] = m
}
if !byModel["second"].Allowed || byModel["second"].Rank != 0 {
t.Errorf("second 应为 rank 0 的已选项:%+v", byModel["second"])
}
if !byModel["first"].Allowed || byModel["first"].Rank != 1 {
t.Errorf("first 应为 rank 1 的已选项:%+v", byModel["first"])
}
if byModel["unpicked"].Allowed {
t.Error("unpicked 不该被标为已选")
}
if byModel["first"].DisplayName != "第一" {
t.Errorf("display_name 未带出:%q", byModel["first"].DisplayName)
}
}
// 顺序就是插件的降级顺序,必须原样保存。
func TestSetAllowedModelsPreservesOrder(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
want := []ModelRef{
{Provider: "c", Model: "3"},
{Provider: "a", Model: "1"},
{Provider: "b", Model: "2"},
}
if err := SetAllowedModels(ctx, "dsh", want); err != nil {
t.Fatalf("SetAllowedModels: %v", err)
}
got, err := ListAllowedModels(ctx, "dsh")
if err != nil {
t.Fatalf("ListAllowedModels: %v", err)
}
if len(got) != len(want) {
t.Fatalf("数量不符:%d vs %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("第 %d 项顺序错:%+v期望 %+v —— "+
"顺序就是插件的降级顺序,不能按字典序重排", i, got[i], want[i])
}
}
}
// 空列表表示「不限定」,是合法输入。
// 报错会让「取消所有限定」变成一件做不到的事。
func TestSetAllowedModelsAcceptsEmpty(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
if err := SetAllowedModels(ctx, "dsh", []ModelRef{{Provider: "p", Model: "m"}}); err != nil {
t.Fatalf("先设一个: %v", err)
}
if err := SetAllowedModels(ctx, "dsh", []ModelRef{}); err != nil {
t.Fatalf("清空应当合法: %v", err)
}
got, _ := ListAllowedModels(ctx, "dsh")
if len(got) != 0 {
t.Errorf("清空后应为空,实际 %+v", got)
}
}
// 重复项跳过而不报错:它对最终顺序没有影响,
// 为一次无害的重复让整次保存失败只会让人以为配置没生效。
func TestSetAllowedModelsSkipsDuplicates(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
err := SetAllowedModels(ctx, "dsh", []ModelRef{
{Provider: "p", Model: "m"},
{Provider: "p", Model: "m"},
{Provider: "p", Model: "other"},
})
if err != nil {
t.Fatalf("重复项不该报错: %v", err)
}
got, _ := ListAllowedModels(ctx, "dsh")
if len(got) != 2 {
t.Fatalf("应保留 2 项,实际 %+v", got)
}
// rank 要连续:跳过重复项后不该在序号上留空洞
if got[0].Model != "m" || got[1].Model != "other" {
t.Errorf("顺序错:%+v", got)
}
}
// 字段不全的项跳过:半条记录在配置页上是一个点不动的空复选框。
func TestModelCatalogSkipsIncomplete(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
if err := ReplaceModelCatalog(ctx, "dsh", []CatalogModel{
{Provider: "", Model: "m"},
{Provider: "p", Model: ""},
{Provider: " ", Model: " "},
{Provider: "p", Model: "ok"},
}); err != nil {
t.Fatalf("上报: %v", err)
}
got, _ := ListModelCatalog(ctx, "dsh")
if len(got) != 1 || got[0].Model != "ok" {
t.Fatalf("应只留 1 项:%+v", got)
}
}
// 目录里重复的 provider/model 不该让整次事务失败(主键冲突)。
func TestReplaceModelCatalogDedupes(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
if err := ReplaceModelCatalog(ctx, "dsh", []CatalogModel{
{Provider: "p", Model: "m", DisplayName: "第一次"},
{Provider: "p", Model: "m", DisplayName: "第二次"},
}); err != nil {
t.Fatalf("重复不该报错: %v", err)
}
got, _ := ListModelCatalog(ctx, "dsh")
if len(got) != 1 {
t.Fatalf("应去重到 1 项:%+v", got)
}
if got[0].DisplayName != "第一次" {
t.Errorf("应保留第一条:%q", got[0].DisplayName)
}
}
// 各 Agent 的目录与选择互不影响。
func TestModelScopeIsolatedPerAgent(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
if err := ReplaceModelCatalog(ctx, "dsh", []CatalogModel{{Provider: "p", Model: "dsh-only"}}); err != nil {
t.Fatalf("dsh 上报: %v", err)
}
if err := ReplaceModelCatalog(ctx, "opencode", []CatalogModel{{Provider: "p", Model: "oc-only"}}); err != nil {
t.Fatalf("opencode 上报: %v", err)
}
if err := SetAllowedModels(ctx, "dsh", []ModelRef{{Provider: "p", Model: "dsh-only"}}); err != nil {
t.Fatalf("dsh 选择: %v", err)
}
ocCatalog, _ := ListModelCatalog(ctx, "opencode")
if len(ocCatalog) != 1 || ocCatalog[0].Model != "oc-only" {
t.Fatalf("opencode 的目录被污染:%+v", ocCatalog)
}
if ocCatalog[0].Allowed {
t.Error("dsh 的选择串到 opencode 上了")
}
ocAllowed, _ := ListAllowedModels(ctx, "opencode")
if len(ocAllowed) != 0 {
t.Errorf("opencode 不该有已选项:%+v", ocAllowed)
}
}
// 上报数量超上限时截断而不报错:平台把上游几千个模型全列出来是它的自由,
// 但配置页上几千个复选框对人没有用。
func TestReplaceModelCatalogCaps(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
many := make([]CatalogModel, maxCatalogModels+50)
for i := range many {
many[i] = CatalogModel{Provider: "p", Model: string(rune('a'+i%26)) + itoaTest(i)}
}
if err := ReplaceModelCatalog(ctx, "dsh", many); err != nil {
t.Fatalf("超量上报不该报错: %v", err)
}
got, _ := ListModelCatalog(ctx, "dsh")
if len(got) != maxCatalogModels {
t.Errorf("应截断到 %d实际 %d", maxCatalogModels, len(got))
}
}
func itoaTest(n int) string {
if n == 0 {
return "0"
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}