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:
@ -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
|
||||
|
||||
48
docs/API.md
48
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 的转发。** 插件代劳搬运的两类消息不占额度:
|
||||
|
||||
@ -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 判成走通(实测踩过)。
|
||||
|
||||
@ -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/` 下对应的测试文件同样逐字节共用。
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
131
gateway/internal/handler/models_scope.go
Normal file
131
gateway/internal/handler/models_scope.go
Normal file
@ -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:])
|
||||
}
|
||||
@ -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 的邮件场景可用模型,入参顺序即优先级。
|
||||
//
|
||||
// 允许传空列表:那表示「不限定」——插件回退到平台自己的默认模型。
|
||||
|
||||
299
gateway/internal/repo/models_scope_test.go
Normal file
299
gateway/internal/repo/models_scope_test.go
Normal file
@ -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)
|
||||
}
|
||||
25
plugins/dsh-mail-bridge/lib/model-scope.d.ts
vendored
Normal file
25
plugins/dsh-mail-bridge/lib/model-scope.d.ts
vendored
Normal 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;
|
||||
138
plugins/dsh-mail-bridge/lib/model-scope.js
Normal file
138
plugins/dsh-mail-bridge/lib/model-scope.js
Normal 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;
|
||||
}
|
||||
@ -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,17 +293,111 @@ 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);
|
||||
}
|
||||
const defaults = ctx.get('agentDefaultModel');
|
||||
const sel = defaults?.currentSelection?.();
|
||||
if (sel?.provider && sel.model) {
|
||||
return { provider: sel.provider, model: sel.model };
|
||||
|
||||
/** 收集本机 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 等这个会话的首轮真正跑起来,或者失败。
|
||||
*
|
||||
* **`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,20 +457,48 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
`只有在需要主动联系其他人、或要带附件时才调用 send_mail。`,
|
||||
].join('\n');
|
||||
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId,
|
||||
// 按管理员划定的范围逐个尝试建 agent,全部失败才回一封说明原因的邮件。
|
||||
//
|
||||
// 必须发那封信:agent 一次都没建起来时会话里没有任何 assistant 消息,
|
||||
// 自动转发因此什么也不会发 —— 发件人只会看到邮件发出去后再无音讯。
|
||||
//
|
||||
// DSH 与 opencode 的差异:这里失败在**建 agent** 时就暴露(模型路由是
|
||||
// agentOptions 的一部分),而 opencode 是在 promptAsync 时。
|
||||
const attempts = attemptOrder();
|
||||
const failures: { provider?: string; model?: string; error: string }[] = [];
|
||||
|
||||
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 },
|
||||
agentOptions: agentOpts,
|
||||
// route 为 undefined 表示不指定模型,交给平台自己选
|
||||
agentOptions: route ? { provider: route.provider, model: route.model } : {},
|
||||
// setup 留空:DSH 的 base bundle 已经注册了 agent-loop、llm、tools 等服务。
|
||||
// modelSelection 通过 agentOptions 传入即可 —— 挂载 preset 或
|
||||
// 模型路由通过 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: sessionId, directory: cwd });
|
||||
reverseMap.set(sessionId, mailSessionID);
|
||||
mailDrivenSessions.add(sessionId);
|
||||
sessionMap.set(mailSessionID, { dshSessionId: attemptSessionId, directory: cwd });
|
||||
reverseMap.set(attemptSessionId, mailSessionID);
|
||||
mailDrivenSessions.add(attemptSessionId);
|
||||
mailContexts.set(mailSessionID, {
|
||||
replyTo: data.from_name || '',
|
||||
subject: data.subject || '',
|
||||
@ -378,8 +506,51 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
});
|
||||
}
|
||||
|
||||
const watching = awaitFirstTurn(handle.agent);
|
||||
handle.agent.followup(userMessage(promptText));
|
||||
return { sessionID: sessionId, reused: false };
|
||||
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);
|
||||
|
||||
// 全部失败:把原因作为邮件回给发件人。走免配额通道 ——
|
||||
// 这是插件的故障报告,不是模型的自主发信。
|
||||
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 模式)───
|
||||
|
||||
207
plugins/dsh-mail-bridge/test/model-scope.test.mjs
Normal file
207
plugins/dsh-mail-bridge/test/model-scope.test.mjs
Normal 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 个/);
|
||||
});
|
||||
@ -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");
|
||||
|
||||
// 按管理员划定的范围逐个尝试,全部失败才回一封说明失败原因的邮件。
|
||||
//
|
||||
// 必须发那封信:模型一次都没跑起来时会话里没有任何 assistant 消息,
|
||||
// 自动转发因此什么也不会发 —— 发件人只会看到邮件发出去后再无音讯。
|
||||
//
|
||||
// **promptAsync 返回不代表模型跑起来了**(名字里的 Async 就是这个意思):
|
||||
// 无效 provider 的失败通过 `session.error` 事件到达,而不是它的 reject。
|
||||
// 因此只包 try/catch 的话第二个模型永远不会被试到 —— 用 awaitFirstTurn 等结论。
|
||||
const attempts = modelAttemptOrder(allowedModels, {
|
||||
provider: REPLY_PROVIDER,
|
||||
model: REPLY_MODEL,
|
||||
});
|
||||
const failures = [];
|
||||
|
||||
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: {
|
||||
model: { providerID: REPLY_PROVIDER, modelID: REPLY_MODEL },
|
||||
// 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);
|
||||
|
||||
138
plugins/opencode-mail-bridge/lib/model-scope.js
Normal file
138
plugins/opencode-mail-bridge/lib/model-scope.js
Normal 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;
|
||||
}
|
||||
207
plugins/opencode-mail-bridge/test/model-scope.test.mjs
Normal file
207
plugins/opencode-mail-bridge/test/model-scope.test.mjs
Normal 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 个/);
|
||||
});
|
||||
@ -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() {
|
||||
|
||||
@ -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<Tab>('users');
|
||||
@ -111,6 +112,10 @@ export default function AdminUsersPage() {
|
||||
<BotIcon className="w-4 h-4" />
|
||||
默认预算
|
||||
</TabButton>
|
||||
<TabButton active={tab === 'models'} onClick={() => setTab('models')}>
|
||||
<CpuIcon className="w-4 h-4" />
|
||||
模型范围
|
||||
</TabButton>
|
||||
<div className="flex-1" />
|
||||
{notice && <span className="text-xs text-green-600">{notice}</span>}
|
||||
{tab === 'users' && (
|
||||
@ -122,7 +127,11 @@ export default function AdminUsersPage() {
|
||||
|
||||
{error && <p className="mx-6 mt-3 text-xs text-red-600 bg-red-50 border border-red-100 rounded-md px-2.5 py-1.5">{error}</p>}
|
||||
|
||||
{tab === 'quotas' ? (
|
||||
{tab === 'models' ? (
|
||||
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
|
||||
<ModelScopeTab />
|
||||
</div>
|
||||
) : tab === 'quotas' ? (
|
||||
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
|
||||
<QuotaPanel />
|
||||
</div>
|
||||
@ -421,3 +430,24 @@ function Field({ label, hint, children }: { label: string; hint?: string; childr
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型范围页。
|
||||
*
|
||||
* Agent 列表复用 `/admin/quotas` —— 它返回的就是全部已注册 Agent。
|
||||
* 另开一个「列出 Agent」接口只会多一条做同一件事的路径。
|
||||
*/
|
||||
function ModelScopeTab() {
|
||||
const [agents, setAgents] = useState<api.AgentStats[]>([]);
|
||||
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 <p className="text-xs text-red-600">{err}</p>;
|
||||
return <ModelScopePanel agents={agents} />;
|
||||
}
|
||||
|
||||
321
web/src/components/ModelScopePanel.tsx
Normal file
321
web/src/components/ModelScopePanel.tsx
Normal file
@ -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<string | null>(null);
|
||||
|
||||
if (agents.length === 0) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<PanelHeader count={0} />
|
||||
<div className="text-xs text-gray-400 py-3">暂无已注册的 Agent</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<PanelHeader count={agents.length} />
|
||||
<div className="border border-gray-200 rounded-md divide-y divide-gray-100">
|
||||
{agents.map(a => (
|
||||
<AgentModelRow
|
||||
key={a.agent_name}
|
||||
agentName={a.agent_name}
|
||||
expanded={expanded === a.agent_name}
|
||||
onToggle={() => setExpanded(expanded === a.agent_name ? null : a.agent_name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelHeader({ count }: { count: number }) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<CpuIcon className="w-4 h-4 text-gray-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-900">Agent 模型范围</h3>
|
||||
{count > 0 && <span className="text-xs text-gray-400">{count}</span>}
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
划定每个平台在邮件场景下可用的模型。勾选顺序即尝试顺序 —— 插件按序降级,
|
||||
全部失败才回一封说明失败原因的邮件。
|
||||
<br />
|
||||
一个都不选 = 不限定,用平台自己的默认模型。清单由插件随心跳上报。
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentModelRow({
|
||||
agentName,
|
||||
expanded,
|
||||
onToggle
|
||||
}: {
|
||||
agentName: string;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<CatalogModel[]>([]);
|
||||
const [stale, setStale] = useState<ModelRoute[]>([]);
|
||||
// picks 是有序的:数组下标就是 rank
|
||||
const [picks, setPicks] = useState<string[]>([]);
|
||||
const [saved, setSaved] = useState<string[]>([]);
|
||||
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 (
|
||||
<div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full px-3 py-2.5 flex items-center gap-x-3 gap-y-1 flex-wrap text-left hover:bg-gray-50"
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={`w-3 h-3 text-gray-400 shrink-0 transition-transform ${expanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
<span className="text-xs font-mono text-gray-900 w-32 shrink-0 truncate">{agentName}</span>
|
||||
<span className="min-w-0 flex-1 text-[11px] text-gray-500">
|
||||
{saved.length === 0 ? '不限定(用平台默认模型)' : `${saved.length} 个模型,按序尝试`}
|
||||
</span>
|
||||
{staleKeys.length > 0 && (
|
||||
<span
|
||||
className="shrink-0 px-1 py-0.5 rounded bg-amber-100 text-amber-700 text-[9px]"
|
||||
title="已选但平台当前没有上报这些模型"
|
||||
>
|
||||
{staleKeys.length} 个已失效
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="px-3 pb-3 space-y-3 bg-gray-50 border-t border-gray-100">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400 pt-3">
|
||||
<SpinnerIcon className="w-3.5 h-3.5 animate-spin" />
|
||||
加载中
|
||||
</div>
|
||||
)}
|
||||
{err && <p className="text-xs text-red-600 pt-3">{err}</p>}
|
||||
|
||||
{!loading && catalog.length === 0 && staleKeys.length === 0 && (
|
||||
<p className="text-[11px] text-gray-500 pt-3">
|
||||
该平台还没有上报模型清单。插件会在心跳时上报(约 30 秒一次)——
|
||||
若长时间为空,检查插件是否在运行、以及它能否读到平台的 provider 配置。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{picks.length > 0 && (
|
||||
<div className="pt-3">
|
||||
<p className="text-[10px] font-medium text-gray-500 mb-1.5">
|
||||
尝试顺序(自上而下)
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{picks.map((key, i) => {
|
||||
const meta = catalog.find(m => keyOf(m) === key);
|
||||
const isStale = !meta && staleKeys.includes(key);
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`flex items-center gap-1.5 px-2 py-1 rounded border bg-white ${
|
||||
isStale ? 'border-amber-200' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span className="w-4 text-[10px] text-gray-400 shrink-0">{i + 1}</span>
|
||||
<span className="text-xs font-mono text-gray-800 truncate">{key}</span>
|
||||
{isStale && (
|
||||
<span
|
||||
className="shrink-0 text-[9px] text-amber-700"
|
||||
title="平台当前没有上报这个模型,可能已下线"
|
||||
>
|
||||
已失效
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => move(key, -1)}
|
||||
disabled={i === 0}
|
||||
title="上移"
|
||||
className="shrink-0 text-gray-400 hover:text-gray-900 disabled:opacity-30 text-xs px-1"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => move(key, 1)}
|
||||
disabled={i === picks.length - 1}
|
||||
title="下移"
|
||||
className="shrink-0 text-gray-400 hover:text-gray-900 disabled:opacity-30 text-xs px-1"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggle(key)}
|
||||
title="移除"
|
||||
className="shrink-0 text-gray-400 hover:text-red-600 text-xs px-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{catalog.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-gray-500 mb-1.5">
|
||||
平台上报的模型({catalog.length})
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{catalog.map(m => {
|
||||
const key = keyOf(m);
|
||||
const on = picks.includes(key);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => toggle(key)}
|
||||
title={m.display_name || key}
|
||||
className={`px-2 py-1 text-[11px] font-mono rounded border transition-colors ${
|
||||
on
|
||||
? 'bg-blue-50 border-blue-300 text-blue-700'
|
||||
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{key}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(catalog.length > 0 || picks.length > 0) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{picks.length === 0 && (
|
||||
<span className="text-[10px] text-gray-400">
|
||||
未选 = 不限定,用平台默认模型
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={() => setPicks(saved)}
|
||||
className="text-[11px] text-gray-500 hover:text-gray-900"
|
||||
>
|
||||
撤销
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={!dirty || saving}
|
||||
className="inline-flex items-center gap-1 px-3 py-1 text-[11px] rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
|
||||
>
|
||||
{saving ? (
|
||||
<SpinnerIcon className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<CheckIcon className="w-3 h-3" />
|
||||
)}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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) };
|
||||
}
|
||||
@ -320,3 +320,13 @@ export function CardViewIcon({ className = 'w-4 h-4' }: P) {
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CpuIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
<rect x="9" y="9" width="6" height="6" />
|
||||
<path d="M9 2v2M15 2v2M9 20v2M15 20v2M2 9h2M2 15h2M20 9h2M20 15h2" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user