在这之前 /calendar/* 全挂在 UserAuth 后面,Agent 密钥一律 401。 于是「明天九点提醒我看 CI」只能靠插件进程里的 setTimeout —— 进程一重启 定时器就消失,那条提醒静默不见且无处留痕。放进 Gateway 后由数据库与 调度器保证:插件重启、Agent 换机器、甚至换平台都不影响。 更重要的是它让**跨 Agent 的任务交接**成立:模型可以给 dsh 设一条 「明天交周报」的提醒。这件事模型自己做不到 —— 它没法让另一个进程在未来 某刻醒来。 三处收紧: **1. 只看/只改自己建的。** 别人的日程里可能有它无权知道的会议与地址。不存在与不属于我都回 **404** 而非 403 —— 后者会泄漏「这个 id 存在」,让 Agent 能枚举出别人有多少条日程。 **2. 不能设给人类。** 理由是投递通道不对等。Agent 之间的提醒是任务信号:收到就干活、干完回信。 发给人的提醒是打扰 —— 进收件箱、触发未读徽标,而人**无法回信让它停下** (提醒是日历实体不是对话),只能去 WebUI 里找出那条事件删掉。 一个 Agent 建条「每 10 分钟提醒 jianf 检查进度」的代价远大于收益 (它其实可以直接 send_mail)。 **3. 速率 + 总量双闸。** 速率(20 次/小时,独立桶)压住「短时间暴建」,压不住「每小时建 19 条、 连建一周」—— 而日历事件是**长效**的,一条每日重复提醒会一直发下去。 攒下 300 条之后即使停止建新的,每天仍有 300 封提醒涌出来。 所以加 maxActiveEventsPerAgent=50,并在列表响应里回传 active_limit: 模型看到 42/50 就知道该清理,只在撞墙时才报错等于让它一直蒙在鼓里。 其他设计点: - **PUT 是部分更新**(人类端点是整体替换)。调用方是模型 —— 要求它每次 回传全部字段,漏一个就把提醒正文或收件人清空,而那种破坏没有任何报错。 全部字段用 *T,nil = 没传 = 保持原值。 - 一次性事件设在过去拦掉(会立刻触发,几乎总是时区或年份写错); 重复事件不拦 —— 「每天 9 点」从昨天开始是合理写法。 - 收件人省略时默认给自己:最常见的用法,每次要求写出自己的名字只会让 模型忘记然后拿到 400。 顺带:两个 handler 文件各写了一份手写 itoa(models_scope 那份还漏了负数), 统一成 strconv.Itoa。 测试 repo 9 例:创建者过滤、收件人≠创建者不返回、总量计数、 速率桶与会话桶独立、按 Agent 隔离、失败归还名额。 生产实测 11 项全过(含 403/400/404/429 各条边界)。
117 lines
4.2 KiB
Go
117 lines
4.2 KiB
Go
package handler
|
||
|
||
import (
|
||
"net/http"
|
||
"strconv"
|
||
"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 !DecodeBody(w, r, &req) {
|
||
return
|
||
}
|
||
if len(req.Models) > maxAllowedModels {
|
||
Error(w, http.StatusBadRequest,
|
||
"选定的模型过多(上限 "+strconv.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
|