diff --git a/deploy/check-shared-libs.sh b/deploy/check-shared-libs.sh index 325a218..7c1020a 100755 --- a/deploy/check-shared-libs.sh +++ b/deploy/check-shared-libs.sh @@ -7,14 +7,14 @@ set -euo pipefail A=plugins/opencode-mail-bridge B=plugins/dsh-mail-bridge fail=0 -for f in relay-dedup inbox-format session-snapshot workspace; do +for f in relay-dedup inbox-format session-snapshot workspace model-scope; do if ! diff -q "$A/lib/$f.js" "$B/lib/$f.js" >/dev/null 2>&1; then echo "共用模块已分叉:lib/$f.js" >&2 diff "$A/lib/$f.js" "$B/lib/$f.js" | head -20 >&2 fail=1 fi done -for f in inbox-format session-snapshot workspace; do +for f in inbox-format session-snapshot workspace model-scope; do if ! diff -q "$A/test/$f.test.mjs" "$B/test/$f.test.mjs" >/dev/null 2>&1; then echo "共用测试已分叉:test/$f.test.mjs" >&2 fail=1 diff --git a/docs/API.md b/docs/API.md index 0f91f54..b781022 100644 --- a/docs/API.md +++ b/docs/API.md @@ -307,6 +307,7 @@ POST /permission/request 请求人类决策 POST /attachments 上传附件 GET /attachments/{id} 下载附件 POST /sessions/{id}/sync 回写平台侧生成的会话标题/slug +GET /agent/models/allowed 读当前生效的模型范围(通常不需要——心跳已回传) ``` 发信与转发扣**本任务(会话)的往返预算**。 @@ -342,6 +343,28 @@ Gateway 只看得见邮件驱动的那部分,人直接在平台界面上开的 字段要求见 [插件适配指南](PLUGIN-GUIDE.md#四平台会话快照上报)(含 subagent 过滤、 slug 去重等规则)。 +心跳还可带 `models`(平台当前看得见的模型目录): + +```json +{ "models": [ + { "provider": "llmsproxy", "model": "AUTO", "display_name": "AUTO (smart routing)" } +] } +``` + +响应回传当前生效的模型范围,插件据此决定这一轮按什么顺序尝试: + +```json +{ + "status": "ok", "pending_mails": 0, "stats": {...}, + "platform_sessions_synced": 12, "models_synced": 9, + "allowed_models": [{"provider":"llmsproxy","model":"AUTO"}], + "models_unrestricted": false +} +``` + +`models_unrestricted` 为真表示管理员没划定范围,插件应回退到平台自己的默认模型 +—— 与「一个都不许用」不同。 + ### 标记已读 ```bash @@ -362,6 +385,31 @@ curl -X POST {host}/api/v1/mail/read -H "Authorization: Bearer $AGENT_KEY" - 「全部标掉」排除已归档会话:那些邮件在收件箱里看不到, 标了只会让计数与用户看到的对不上 +### 每个 Agent 可用的模型范围 + +``` +GET /admin/agents/{name}/models 目录(带已选标记与 rank)+ stale +PUT /admin/agents/{name}/models 保存选择,数组顺序即优先级 +``` + +```bash +curl -X PUT {host}/api/v1/admin/agents/dsh/models -b cookie.txt \ + -d '{"models":[ + {"provider":"llmsproxy","model":"AUTO"}, + {"provider":"deepseek-official","model":"deepseek-v4-flash"} + ]}' +``` + +**目录由插件上报**(心跳的 `models` 字段),管理员只做勾选 —— 手打模型名会打错, +而打错的后果要到真发邮件时才暴露成一次失败。 + +- **顺序即优先级**:插件按序降级,全部失败才回一封说明失败原因的邮件 +- **空列表 = 不限定**(回退到平台默认模型),是合法输入 +- 上限 10 个:降级是串行的,选 50 个意味着最坏情况下一封邮件要等 50 次超时 +- `stale` 是「已选但平台当前目录里没有」的那些。目录与选择分两张表存 —— + 模型从平台目录消失(上游临时下线)时管理员的选择必须留存, + 否则模型回来还得重配一遍 + ### 免配额通道:harness 代劳的转发 **配额约束的是模型的自主发信,不是 harness 的转发。** 插件代劳搬运的两类消息不占额度: diff --git a/docs/PHASE7-REMAINING.md b/docs/PHASE7-REMAINING.md index aae8d00..0683601 100644 --- a/docs/PHASE7-REMAINING.md +++ b/docs/PHASE7-REMAINING.md @@ -40,7 +40,7 @@ SSE 连接的 handler 在首次连接时从缓冲区头部开始(客户端传 **现状**:只有浅色主题,深夜使用刺眼。 **范围**:tailwind dark: 前缀覆盖主要组件。 -### P1 — 每平台可用模型范围(进行中) +### P1 — 每平台可用模型范围 ✅ **需求**:配置页面为每个 Agent 平台划定「邮箱调用场景下可用的模型范围」, 端侧插件按范围**逐个降级尝试**,全部失败时把失败原因封装成邮件回复。 @@ -56,9 +56,22 @@ SSE 连接的 handler 在首次连接时从缓冲区头部开始(客户端传 整行删掉会连带把管理员的选择也删了,模型回来还得重配一遍。分开存之后 「选了什么」是持久的,目录只决定「这一项现在是否可用」。 -**待做**: -- [ ] handler + 路由:注册时接收目录、管理员读写选择 -- [ ] 插件在注册时上报目录(opencode 有 `/config/providers`,DSH 有 `llm.listModels`) -- [ ] 插件按 rank 顺序尝试,记录每次失败的原因 -- [ ] 全部失败 → 发一封说明失败原因的邮件(走免配额通道) -- [ ] 前端配置页:复选框 + 拖拽排序(rank 即优先级) +**已完成(全部)**: +- [x] handler + 路由:`GET/PUT /admin/agents/{name}/models`、`GET /agent/models/allowed` +- [x] **目录上报走心跳**而不是另设端点:模型清单会在运行中变, + 心跳本来就是 30 秒一次的现成通道;另设一个 POST 等于给「目录是谁写的」 + 留两个答案 +- [x] 心跳响应回传 `allowed_models`:管理员改了范围后最多一个周期生效,不必重启 +- [x] `lib/model-scope.js`:目录整理(两平台)、`modelAttemptOrder`、`renderFailureReport` +- [x] 插件按 rank 逐个尝试,全部失败发一封说明原因的邮件(走免配额通道) +- [x] 前端 `ModelScopePanel`:勾选 + 上下移调序 + stale 标记 + +**最难的一点**(两个平台都踩了):**模型失败不是同步抛出的**。 +`promptAsync()` 立即返回、`ctx.agents.create()` 不校验模型,只包 try/catch +第二个模型永远不会被试到。要等异步结论: + +- opencode → `session.error` 事件 +- DSH → `turn/end` 的 `reason.kind === 'error'` + +DSH 还有个陷阱:`assistant/chunk` 的 `finish` 子类型也带错误, +把任意 chunk 当成功会让无效 provider 判成走通(实测踩过)。 diff --git a/docs/PLUGIN-GUIDE.md b/docs/PLUGIN-GUIDE.md index 226daef..7212f76 100644 --- a/docs/PLUGIN-GUIDE.md +++ b/docs/PLUGIN-GUIDE.md @@ -254,7 +254,95 @@ POST /api/v1/sessions/{id}/sync { alias?, title? } --- -## 五、平台差异对照 +## 五、模型范围与降级尝试 + +管理员在配置页为每个平台划定「邮件场景下可用的模型」。插件按顺序逐个尝试, +全部失败才回一封说明失败原因的邮件。 + +### 上报目录 + +随心跳带 `models`(与会话快照同一个请求): + +```json +{ "models": [ + { "provider": "llmsproxy", "model": "AUTO", "display_name": "AUTO (smart routing)" } +] } +``` + +**为什么随心跳而不是只在注册时报一次**:模型清单会在运行中变(换 provider +配置、上游上下线、换 API key)。只在注册时报的话目录会静静变陈,管理员在配置页 +选中一个平台其实调不到的模型,失败要到真发邮件时才暴露。 + +与 `platform_sessions` 同一约定:拉不到目录时**省略该字段**(保留服务端现有目录), +传空数组会把配置页清成空白。 + +| 平台 | 目录来源 | +|---|---| +| opencode | `client.config.providers()` → `providers[].models` 是**对象**,键是 model id | +| DSH | `ctx.llm.listProviders()` 再逐个 `listModels(provider)` | + +### 读取生效范围 + +心跳响应回传 `allowed_models`(按优先级)与 `models_unrestricted`。 +插件存在模块级变量里,下次投递时用 —— 管理员改了范围后最多一个心跳周期 +(30 秒)生效,不需要重启。 + +也有 `GET /agent/models/allowed`,但那是给没有心跳循环的第三方客户端与排查用的。 + +### 尝试顺序 + +共用实现 `lib/model-scope.js` 的 `modelAttemptOrder(allowed, envDefault)`: + +| 情形 | 返回 | +|---|---| +| 管理员划定了范围 | 按 rank 顺序的路由列表 | +| 没划定,但配了 `AGENTMAIL_REPLY_*` | 环境变量那一个 | +| 都没有 | `[undefined]` —— 交给平台自己选 | + +**范围优先于环境变量**:范围是运行时可改的策略,环境变量是部署时的兜底。 +反过来的话管理员在配置页改了却不生效,得去改 service 文件重启。 + +**范围为空时返回 `[undefined]` 而不是 `[]`**:返回空数组会让调用方一次都不试, +而「管理员没配」的正确含义是不限定,不是「一个都不许用」。 + +### 判定一次尝试是否成功 —— 这里最容易错 + +**两个平台的模型失败都不是同步抛出的。** 只包一个 try/catch 的话第二个模型 +永远不会被试到: + +| 平台 | 提交调用 | 失败从哪来 | +|---|---|---| +| opencode | `promptAsync()` 立即返回 | `session.error` 事件 | +| DSH | `ctx.agents.create()` 不校验模型 | `turn/end` 的 `reason.kind === 'error'` | + +DSH 侧还有一个陷阱:**`assistant/chunk` 本身不能当成功信号**,它的 `finish` +子类型也带错误 —— + +```json +{"chunk": {"type": "finish", "reason": {"kind": "error", "failure": {"code": "NO_ADAPTER"}}}} +``` + +实测踩过一次「无效 provider 却判成功」正是因为把任意 chunk 当成了走通。 +判据要落在 chunk 的类型上:`finish` 看 reason,其余(`block-start`、 +`text-delta`、`tool-call-delta`…)才意味着模型真的在产出。 + +**超时按成功处理**:模型可能只是很慢(首 token 前要装载上下文), +把慢当成失败会在换模型的同时把已经在跑的那一轮丢掉。窗口取 60 秒。 + +DSH 侧换模型要**换会话 id**(`<原 id>-r1`)并 `dispose()` 失败那个 agent: +复用同一个 id 会让重试接在一条已经出错的会话后面,而不 dispose 的话 +`agent/status` 还会为那个死会话触发一次自动转发。 + +### 全部失败时必须发信 + +模型一次都没跑起来时会话里没有任何 assistant 消息,自动转发因此什么也不会发 +—— 发件人只会看到邮件发出去后再无音讯。 + +`renderFailureReport(failures, subject)` 生成正文(逐条列出路由与原因, +并指出去哪里调整)。这封信带 `relay: 'summary'` 走免配额通道: +它是插件的故障报告,不是模型的自主发信。 + +## 六、平台差异对照 | 关注点 | opencode | DeepSeek Harness | |---|---|---| @@ -265,12 +353,14 @@ POST /api/v1/sessions/{id}/sync { alias?, title? } | 一轮结束 | `session.idle` 事件 | `agent/status` → `idle` | | 权限钩子 | `permission.ask`(同步,不能等) | `approval/request`(异步 waterfall,能等) | | 会话列表 | `client.session.list()` | `ctx.sessionQuery.listSessions()` | +| 模型目录 | `client.config.providers()` | `ctx.llm.listProviders()` + `listModels()` | +| 模型失败信号 | `session.error` 事件 | `turn/end` 的 `reason.kind==='error'` | | 别名来源 | `session.slug` | 模型标题派生 | | 日志可见性 | `console.error` | `console.error`(`ctx.logger` 不进 journalctl) | --- -## 六、踩过的坑 +## 七、踩过的坑 按「排查成本」降序。新接平台时先扫一遍这一节。 @@ -342,7 +432,7 @@ opencode 还有个额外问题:**插件是懒加载的**,进程起来了插 --- -## 七、新平台适配清单 +## 八、新平台适配清单 ``` [ ] 1. 认证与连接 @@ -373,13 +463,20 @@ opencode 还有个额外问题:**插件是懒加载的**,进程起来了插 [ ] 转不出去就让位给本地 UI [ ] 拆插件时未决询问 fail closed -[ ] 6. 命名与快照 +[ ] 6. 模型范围(复用 lib/model-scope.js) + [ ] 心跳带 models(拉不到就省略,别传空数组) + [ ] 心跳响应读回 allowed_models + [ ] 按 modelAttemptOrder 逐个尝试 + [ ] **等异步结论**再判成功/失败(失败不是同步抛的!) + [ ] 全部失败 → renderFailureReport + relay:'summary' 发信 + +[ ] 7. 命名与快照 [ ] alias/title 回写 POST /sessions/{id}/sync [ ] slug 去掉 . @ / 等寻址分隔符 [ ] 心跳带 platform_sessions(复用 lib/session-snapshot.js) [ ] 过滤 subagent、slug 去重 -[ ] 7. 工程 +[ ] 8. 工程 [ ] 可测逻辑放 lib/,入口保持最小 [ ] 纯函数测试纳入 deploy/install.sh 的门禁 [ ] 端到端:发一封 → 会话建在正确 cwd → 自动回信 → 别名可续谈 @@ -387,7 +484,7 @@ opencode 还有个额外问题:**插件是懒加载的**,进程起来了插 --- -## 八、共用模块 +## 九、共用模块 `lib/` 下的文件在两个插件里**逐字节相同**,接新平台时直接拷。 它们只依赖 node 内置模块,不碰任何平台 SDK。 @@ -398,6 +495,7 @@ opencode 还有个额外问题:**插件是懒加载的**,进程起来了插 | `inbox-format.js` | 收件箱渲染 + 已读策略 | | `session-snapshot.js` | 平台会话快照整理(含 subagent 过滤、slug 派生) | | `workspace.js` | 寻址 path 位 → 可用的 cwd | +| `model-scope.js` | 模型目录整理 + 降级顺序 + 失败报告 | | `message.js` | DSH 的消息构造与会话日志读取(DSH 专用) | `test/` 下对应的测试文件同样逐字节共用。 diff --git a/gateway/cmd/server/main.go b/gateway/cmd/server/main.go index 61ac6b0..21d32fd 100644 --- a/gateway/cmd/server/main.go +++ b/gateway/cmd/server/main.go @@ -110,6 +110,9 @@ func main() { r.Get("/attachments/{id}", handler.DownloadAttachment) // 平台侧会话标题/slug 回写本侧(平台叫什么,本侧就叫什么) r.Post("/sessions/{id}/sync", handler.SyncSession) + // 邮件场景下的可用模型范围。上报走心跳(agent/heartbeat 的 models 字段), + // 这里只读 —— 给非插件的第三方客户端与排查用。 + r.Get("/agent/models/allowed", handler.GetAllowedModels) }) // ---- 人类登录态 ---- @@ -180,6 +183,10 @@ func main() { // Agent 发信配额 r.Get("/admin/quotas", handler.AdminListQuotas) r.Put("/admin/quotas/{name}", handler.AdminSetQuota) + + // 邮件场景下每个 Agent 可用的模型范围(勾选平台上报的目录) + r.Get("/admin/agents/{name}/models", handler.AdminListAgentModels) + r.Put("/admin/agents/{name}/models", handler.AdminSetAgentModels) }) }) diff --git a/gateway/internal/handler/agents.go b/gateway/internal/handler/agents.go index b3530dd..a0a8d19 100644 --- a/gateway/internal/handler/agents.go +++ b/gateway/internal/handler/agents.go @@ -31,6 +31,18 @@ type heartbeatRequest struct { // 空数组 = 平台侧确实一条会话都没有(清空镜像)。 // 拿不到会话列表的插件应当省略该字段,而不是传空数组把镜像抹掉。 PlatformSessions []repo.PlatformSession `json:"platform_sessions"` + + // Models 是平台当前看得见的模型目录,供配置页勾选。 + // + // 随心跳上报而不是只在注册时上报:模型清单会在运行中变 + // (换 provider 配置、上游上下线、换了 API key)。只在注册时报一次的话, + // 目录会静静变陈,而管理员在配置页上看到的是上次重启时的快照 —— + // 选中一个平台已经调不到的模型,失败要到真发邮件时才暴露。 + // + // 与 PlatformSessions 同一约定:nil = 本次不上报(保留现有目录), + // 空数组 = 平台确实一个模型都拿不到。拿不到目录时必须省略: + // 清空目录会让配置页变成空白,管理员以为该平台没有任何可用模型。 + Models []repo.CatalogModel `json:"models"` } // POST /api/v1/agent/register @@ -143,6 +155,14 @@ func HeartbeatAgent(w http.ResponseWriter, r *http.Request) { } } + // 模型目录同理:写失败只让配置页看到的目录陈一轮,下一次心跳会补上。 + syncedModels := -1 + if req.Models != nil { + if err := repo.ReplaceModelCatalog(r.Context(), agentName, req.Models); err == nil { + syncedModels = len(req.Models) + } + } + // 心跳回传该 Agent 的累计统计与新任务默认预算。 // // 不再回传「剩余额度」:额度属于具体任务(会话)而不属于 Agent, @@ -161,6 +181,17 @@ func HeartbeatAgent(w http.ResponseWriter, r *http.Request) { if syncedSessions >= 0 { resp["platform_sessions_synced"] = syncedSessions } + if syncedModels >= 0 { + resp["models_synced"] = syncedModels + } + // 回传当前生效的模型范围,插件无需另起一个请求去读。 + // + // 随心跳回传而不是让插件自己轮询:管理员在配置页改了范围后, + // 插件最多一个心跳周期(30 秒)就能看到新值,不需要重启。 + if allowed, aErr := repo.ListAllowedModels(r.Context(), agentName); aErr == nil { + resp["allowed_models"] = allowed + resp["models_unrestricted"] = len(allowed) == 0 + } JSON(w, http.StatusOK, resp) } diff --git a/gateway/internal/handler/models_scope.go b/gateway/internal/handler/models_scope.go new file mode 100644 index 0000000..b417c42 --- /dev/null +++ b/gateway/internal/handler/models_scope.go @@ -0,0 +1,131 @@ +package handler + +import ( + "net/http" + "strings" + + "github.com/agentmail/gateway/internal/middleware" + "github.com/agentmail/gateway/internal/repo" + "github.com/go-chi/chi/v5" +) + +// ---------- 邮件场景下的可用模型 ---------- +// +// GET /agent/models/allowed 读取被允许的模型(Agent 凭证) +// GET /admin/agents/{name}/models 管理员读目录 + 已选 +// PUT /admin/agents/{name}/models 管理员保存选择与优先级 +// +// **目录上报走心跳**(见 agents.go 的 heartbeatRequest.Models),不另设端点: +// 模型清单会在运行中变(换 provider 配置、上游上下线、换 API key), +// 心跳本来就是 30 秒一次的现成通道。另设一个 POST 等于给「目录是谁写的」 +// 这个问题留两个答案,排查时要同时看两处。 +// +// 生效的模型范围同样随心跳响应回传(allowed_models),因此插件通常不需要调 +// 下面这个 GET —— 它是给非插件的第三方客户端(没有心跳循环)与排查用的。 + +// GET /api/v1/agent/models/allowed —— 插件读取被允许的模型 +// +// 返回按优先级排序的列表。空列表表示**不限定**,插件应回退到平台自己的默认模型 +// —— 与「一个都不许用」不同,后者等于让 Agent 彻底哑掉,不该是一次误配的后果。 +func GetAllowedModels(w http.ResponseWriter, r *http.Request) { + agentName := middleware.GetAgentName(r) + if agentName == "" { + Error(w, http.StatusUnauthorized, "Unauthorized") + return + } + models, err := repo.ListAllowedModels(r.Context(), agentName) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to list allowed models") + return + } + JSON(w, http.StatusOK, map[string]any{ + "models": models, + // unrestricted 明确表达「没配 = 不限」,省得插件自己去判断空数组的含义 + "unrestricted": len(models) == 0, + }) +} + +// GET /api/v1/admin/agents/{name}/models —— 管理员读目录(带已选标记) +func AdminListAgentModels(w http.ResponseWriter, r *http.Request) { + name := strings.TrimSpace(chi.URLParam(r, "name")) + if name == "" { + Error(w, http.StatusBadRequest, "Missing agent name") + return + } + catalog, err := repo.ListModelCatalog(r.Context(), name) + if err != nil { + Error(w, http.StatusInternalServerError, "Failed to list model catalog") + return + } + // 已选但已不在目录里的模型要单独给出来:平台可能临时下线了某个模型, + // 界面上不显示的话管理员会以为自己没选过它,而它其实还在被插件尝试。 + stale, err := repo.ListStaleAllowedModels(r.Context(), name) + if err != nil { + stale = []repo.ModelRef{} + } + JSON(w, http.StatusOK, map[string]any{ + "agent_name": name, + "catalog": catalog, + "stale": stale, + }) +} + +// PUT /api/v1/admin/agents/{name}/models —— 管理员保存选择 +// +// 入参顺序即优先级(rank)。插件按这个顺序逐个尝试,全部失败才回一封失败邮件。 +func AdminSetAgentModels(w http.ResponseWriter, r *http.Request) { + name := strings.TrimSpace(chi.URLParam(r, "name")) + if name == "" { + Error(w, http.StatusBadRequest, "Missing agent name") + return + } + + var req struct { + Models []repo.ModelRef `json:"models"` + } + if err := Decode(r, &req); err != nil { + Error(w, http.StatusBadRequest, "Invalid JSON") + return + } + if len(req.Models) > maxAllowedModels { + Error(w, http.StatusBadRequest, + "选定的模型过多(上限 "+itoa(maxAllowedModels)+" 个)") + return + } + + if err := repo.SetAllowedModels(r.Context(), name, req.Models); err != nil { + Error(w, http.StatusInternalServerError, "Failed to save allowed models") + return + } + // 回传保存后的实际结果而不是回显入参:repo 层会跳过重复项与空字段, + // 回显入参会让前端以为那些也存下来了。 + saved, err := repo.ListAllowedModels(r.Context(), name) + if err != nil { + saved = []repo.ModelRef{} + } + JSON(w, http.StatusOK, map[string]any{ + "status": "saved", + "models": saved, + }) +} + +// maxAllowedModels 限制管理员能选多少个模型。 +// +// 降级尝试是串行的:选 50 个意味着最坏情况下一封邮件要等 50 次模型调用超时。 +// 十个已经足够表达「主力 + 几个备选」。 +const maxAllowedModels = 10 + +// itoa 避免为一个数字引入 strconv 导入(本文件只此一处用到)。 +func itoa(n int) string { + if n == 0 { + return "0" + } + var b [20]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + return string(b[i:]) +} diff --git a/gateway/internal/repo/models_scope.go b/gateway/internal/repo/models_scope.go index 36e3d44..7b3f38e 100644 --- a/gateway/internal/repo/models_scope.go +++ b/gateway/internal/repo/models_scope.go @@ -157,6 +157,40 @@ func ListAllowedModels(ctx context.Context, agentName string) ([]ModelRef, error 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 的邮件场景可用模型,入参顺序即优先级。 // // 允许传空列表:那表示「不限定」——插件回退到平台自己的默认模型。 diff --git a/gateway/internal/repo/models_scope_test.go b/gateway/internal/repo/models_scope_test.go new file mode 100644 index 0000000..985a6de --- /dev/null +++ b/gateway/internal/repo/models_scope_test.go @@ -0,0 +1,299 @@ +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) +} diff --git a/plugins/dsh-mail-bridge/lib/model-scope.d.ts b/plugins/dsh-mail-bridge/lib/model-scope.d.ts new file mode 100644 index 0000000..af808f5 --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/model-scope.d.ts @@ -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; diff --git a/plugins/dsh-mail-bridge/lib/model-scope.js b/plugins/dsh-mail-bridge/lib/model-scope.js new file mode 100644 index 0000000..5ee21e0 --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/model-scope.js @@ -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; +} diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index f413709..4c22c62 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -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(); const mailDrivenSessions = new Set(); const mailContexts = new Map(); const relayedSummaries = new Map(); + +// 管理员在配置页划定的可用模型范围(按优先级)。随心跳响应更新。 +// 空数组 = 不限定,回退到环境变量或平台默认。 +let allowedModels: { provider: string; model: string }[] = []; const syncedTitles = new Map(); // 权限询问:DSH 的 approval/request 是 waterfall 钩子,插件把它转成邮件问人, @@ -259,15 +268,17 @@ export function apply(ctx: any, config: PluginConfig): void { } async function beat(): Promise { - let body: Record = {}; - const entries = await collectSessions(); + const body: Record = {}; + 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 { + 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; + } + + // 先建映射再 followup:turn 可能在 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 模式)─── diff --git a/plugins/dsh-mail-bridge/test/model-scope.test.mjs b/plugins/dsh-mail-bridge/test/model-scope.test.mjs new file mode 100644 index 0000000..58f0980 --- /dev/null +++ b/plugins/dsh-mail-bridge/test/model-scope.test.mjs @@ -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 个/); +}); diff --git a/plugins/opencode-mail-bridge/index.js b/plugins/opencode-mail-bridge/index.js index 389c192..dbb606f 100644 --- a/plugins/opencode-mail-bridge/index.js +++ b/plugins/opencode-mail-bridge/index.js @@ -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); diff --git a/plugins/opencode-mail-bridge/lib/model-scope.js b/plugins/opencode-mail-bridge/lib/model-scope.js new file mode 100644 index 0000000..5ee21e0 --- /dev/null +++ b/plugins/opencode-mail-bridge/lib/model-scope.js @@ -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; +} diff --git a/plugins/opencode-mail-bridge/test/model-scope.test.mjs b/plugins/opencode-mail-bridge/test/model-scope.test.mjs new file mode 100644 index 0000000..58f0980 --- /dev/null +++ b/plugins/opencode-mail-bridge/test/model-scope.test.mjs @@ -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 个/); +}); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index ac3598e..c7c0a03 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -412,6 +412,45 @@ export async function adminSetDefaultRounds(agentName: string, defaultRounds: nu ); } +// ---------- 邮件场景下可用的模型范围 ---------- + +/** 平台上报的一个模型,带「是否已被选入」标记。 */ +export interface CatalogModel { + provider: string; + model: string; + display_name?: string; + allowed: boolean; + /** 仅 allowed 为真时有意义,越小越先试 */ + rank?: number; +} + +export interface ModelRoute { + provider: string; + model: string; +} + +/** + * 读某 Agent 的模型目录。 + * + * `stale` 是「已选但平台当前目录里没有」的那些 —— 平台可能临时下线了某个模型, + * 而管理员的选择是持久的。界面上不显示会让人以为自己没选过它。 + */ +export async function adminGetAgentModels(agentName: string) { + return request<{ agent_name: string; catalog: CatalogModel[]; stale: ModelRoute[] }>( + 'GET', + `/admin/agents/${encodeURIComponent(agentName)}/models` + ); +} + +/** 保存选择。数组顺序即优先级(插件按这个顺序降级尝试)。 */ +export async function adminSetAgentModels(agentName: string, models: ModelRoute[]) { + return request<{ status: string; models: ModelRoute[] }>( + 'PUT', + `/admin/agents/${encodeURIComponent(agentName)}/models`, + { models } + ); +} + // ---------- Sessions ---------- export async function getHumanSessions() { diff --git a/web/src/components/AdminUsersPage.tsx b/web/src/components/AdminUsersPage.tsx index 7625879..fcb31a6 100644 --- a/web/src/components/AdminUsersPage.tsx +++ b/web/src/components/AdminUsersPage.tsx @@ -2,11 +2,12 @@ import { useCallback, useEffect, useState } from 'react'; import * as api from '../api/client'; import NavToggle from './NavToggle'; import type { AdminScopes, User } from '../types'; -import { CheckIcon, LockIcon, UsersIcon, ChevronRightIcon, KeyIcon, BotIcon } from './icons'; +import { CheckIcon, LockIcon, UsersIcon, ChevronRightIcon, KeyIcon, BotIcon, CpuIcon } from './icons'; import KeyPanel from './KeyPanel'; import QuotaPanel from './QuotaPanel'; +import ModelScopePanel from './ModelScopePanel'; -type Tab = 'users' | 'keys' | 'quotas'; +type Tab = 'users' | 'keys' | 'quotas' | 'models'; export default function AdminUsersPage() { const [tab, setTab] = useState('users'); @@ -111,6 +112,10 @@ export default function AdminUsersPage() { 默认预算 + setTab('models')}> + + 模型范围 +
{notice && {notice}} {tab === 'users' && ( @@ -122,7 +127,11 @@ export default function AdminUsersPage() { {error &&

{error}

} - {tab === 'quotas' ? ( + {tab === 'models' ? ( +
+ +
+ ) : tab === 'quotas' ? (
@@ -421,3 +430,24 @@ function Field({ label, hint, children }: { label: string; hint?: string; childr
); } + +/** + * 模型范围页。 + * + * Agent 列表复用 `/admin/quotas` —— 它返回的就是全部已注册 Agent。 + * 另开一个「列出 Agent」接口只会多一条做同一件事的路径。 + */ +function ModelScopeTab() { + const [agents, setAgents] = useState([]); + const [err, setErr] = useState(''); + + useEffect(() => { + api + .adminListAgentStats() + .then(res => setAgents(res.quotas)) + .catch(e => setErr(e instanceof Error ? e.message : String(e))); + }, []); + + if (err) return

{err}

; + return ; +} diff --git a/web/src/components/ModelScopePanel.tsx b/web/src/components/ModelScopePanel.tsx new file mode 100644 index 0000000..bebcbac --- /dev/null +++ b/web/src/components/ModelScopePanel.tsx @@ -0,0 +1,321 @@ +import { useCallback, useEffect, useState } from 'react'; +import * as api from '../api/client'; +import type { AgentStats, CatalogModel, ModelRoute } from '../api/client'; +import { CheckIcon, SpinnerIcon, ChevronRightIcon, CpuIcon } from './icons'; + +/** + * 每个 Agent 平台在**邮件场景**下可用的模型范围。 + * + * 为什么是勾选而不是手打模型名:模型清单是平台侧的事实(opencode 的 provider + * 配置、DSH 的 llm 适配器注册),手打就会打错,而打错的后果要到真发邮件时 + * 才暴露成一次失败。插件随心跳上报它当前看得见的目录,这里只做勾选。 + * + * 顺序即优先级:插件按这个顺序逐个尝试,全部失败才回一封说明失败原因的邮件。 + * 一个都不选 = 不限定,回退到平台自己的默认模型 —— 与「一个都不许用」不同。 + */ +export default function ModelScopePanel({ agents }: { agents: AgentStats[] }) { + const [expanded, setExpanded] = useState(null); + + if (agents.length === 0) { + return ( +
+ +
暂无已注册的 Agent
+
+ ); + } + + return ( +
+ +
+ {agents.map(a => ( + setExpanded(expanded === a.agent_name ? null : a.agent_name)} + /> + ))} +
+
+ ); +} + +function PanelHeader({ count }: { count: number }) { + return ( + <> +
+ +

Agent 模型范围

+ {count > 0 && {count}} +
+

+ 划定每个平台在邮件场景下可用的模型。勾选顺序即尝试顺序 —— 插件按序降级, + 全部失败才回一封说明失败原因的邮件。 +
+ 一个都不选 = 不限定,用平台自己的默认模型。清单由插件随心跳上报。 +

+ + ); +} + +function AgentModelRow({ + agentName, + expanded, + onToggle +}: { + agentName: string; + expanded: boolean; + onToggle: () => void; +}) { + const [catalog, setCatalog] = useState([]); + const [stale, setStale] = useState([]); + // picks 是有序的:数组下标就是 rank + const [picks, setPicks] = useState([]); + const [saved, setSaved] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [err, setErr] = useState(''); + + const load = useCallback(async () => { + setLoading(true); + setErr(''); + try { + const res = await api.adminGetAgentModels(agentName); + setCatalog(res.catalog); + setStale(res.stale || []); + // 已选项按 rank 排出初始顺序 + const chosen = res.catalog + .filter(m => m.allowed) + .sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0)) + .map(keyOf); + // 已选但已不在目录里的仍要保留:不显示会让人以为没选过, + // 而保存时若把它们丢掉就等于静默改了配置 + const staleKeys = (res.stale || []).map(keyOf); + const all = [...chosen, ...staleKeys.filter(k => !chosen.includes(k))]; + setPicks(all); + setSaved(all); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, [agentName]); + + useEffect(() => { + if (expanded) load(); + }, [expanded, load]); + + const toggle = (key: string) => { + setPicks(prev => (prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key])); + }; + + const move = (key: string, delta: number) => { + setPicks(prev => { + const i = prev.indexOf(key); + const j = i + delta; + if (i < 0 || j < 0 || j >= prev.length) return prev; + const next = [...prev]; + [next[i], next[j]] = [next[j], next[i]]; + return next; + }); + }; + + const save = async () => { + setSaving(true); + setErr(''); + try { + const res = await api.adminSetAgentModels(agentName, picks.map(parseKey)); + // 用服务端返回的结果而不是本地 picks:repo 层会跳过重复与空字段, + // 直接信本地状态会让界面显示保存成功而实际存下来的不同 + const persisted = res.models.map(keyOf); + setPicks(persisted); + setSaved(persisted); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(false); + } + }; + + const dirty = picks.join('|') !== saved.join('|'); + const staleKeys = stale.map(keyOf); + + return ( +
+ + + {expanded && ( +
+ {loading && ( +
+ + 加载中 +
+ )} + {err &&

{err}

} + + {!loading && catalog.length === 0 && staleKeys.length === 0 && ( +

+ 该平台还没有上报模型清单。插件会在心跳时上报(约 30 秒一次)—— + 若长时间为空,检查插件是否在运行、以及它能否读到平台的 provider 配置。 +

+ )} + + {picks.length > 0 && ( +
+

+ 尝试顺序(自上而下) +

+
+ {picks.map((key, i) => { + const meta = catalog.find(m => keyOf(m) === key); + const isStale = !meta && staleKeys.includes(key); + return ( +
+ {i + 1} + {key} + {isStale && ( + + 已失效 + + )} +
+ + + +
+ ); + })} +
+
+ )} + + {catalog.length > 0 && ( +
+

+ 平台上报的模型({catalog.length}) +

+
+ {catalog.map(m => { + const key = keyOf(m); + const on = picks.includes(key); + return ( + + ); + })} +
+
+ )} + + {(catalog.length > 0 || picks.length > 0) && ( +
+ {picks.length === 0 && ( + + 未选 = 不限定,用平台默认模型 + + )} +
+ {dirty && ( + + )} + +
+ )} +
+ )} +
+ ); +} + +/** provider/model 拼成一个稳定的键,用于勾选状态与顺序。 */ +function keyOf(m: { provider: string; model: string }): string { + return `${m.provider}/${m.model}`; +} + +/** + * 键拆回 provider 与 model。 + * + * 按**第一个** `/` 切:model id 里可能含 `/`(如 `org/model-name`), + * 而 provider id 不含。按最后一个切会把 provider 切错。 + */ +function parseKey(key: string): ModelRoute { + const i = key.indexOf('/'); + if (i < 0) return { provider: key, model: '' }; + return { provider: key.slice(0, i), model: key.slice(i + 1) }; +} diff --git a/web/src/components/icons.tsx b/web/src/components/icons.tsx index 37e0e66..896d170 100644 --- a/web/src/components/icons.tsx +++ b/web/src/components/icons.tsx @@ -320,3 +320,13 @@ export function CardViewIcon({ className = 'w-4 h-4' }: P) { ); } + +export function CpuIcon({ className = 'w-4 h-4' }: P) { + return ( + + + + + + ); +}