feat(keys): 每个密钥可配独立 AUTO 链,管理员代配

此前 AUTO 链是全局单值(cfg.Auto + Core.AutoChain()),所有用户共用一条链。
管理员无法为某个用户单独指定调度链。

按 per-key 覆盖 + 全局兜底实现:

- config.GWKey 增加 Auto 字段与 HasOwnAuto()。未配置即继承全局链,
  存量部署零改动,新密钥天然继承全局链。
- Core 把 buildAutoChain 的归一化逻辑抽成 chainForRules,全局链、
  生图链、per-key 链共用同一套编译,避免两处漂移。
- Core 增加 keyAutoChains 缓存 + AutoChainFor(key)。请求路径读缓存不
  加锁,与全局链查询一致。缓存整表原子替换,不会看到半成品。
- 请求侧 chat.go 改用 AutoChainFor(reqKey)。冷却仍在 Provider 上按
  model+source 共享:两条链指向同一个 slot 时共用冷却,与今天单链行为
  相同,也避免为 per-key 维度重构冷却而改变现有可观测语义。
- API:GET/PUT/DELETE /api/keys/{key}/auto(admin),GET
  /api/keys/me/auto(任意角色,只能读自己的)。空 PUT 与 DELETE 等价于
  "恢复继承",无法持久化一条会 503 的空链。写入时回报解析出的槽位数,
  让管理员当场看到模型名写错,而不是等用户下次请求 503。
- 源变更时一并重编译 per-key 链,加源后无需重启即可生效。

判据 18 条,5 个变异全部被抓住:AutoChainFor 忽略 key、清空后不重建
缓存、源变更不重建、空 PUT 落盘成空链、me/auto 误要求 admin。

UI 判据做变异时发现漏放:只查函数定义存在,删掉按钮后仍通过。已改为
断言 keyCanvasHtml 内的调用点。

端到端实测(真实 HTTP + 两个 mock 上游):admin 与 bob 初始同为 m-fast,
给 bob 配 m-cheap 后两者分流,重启后仍分流,DELETE 后 bob 回到 m-fast。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
This commit is contained in:
JianFeeeee
2026-10-03 07:45:54 +08:00
parent ba01da937b
commit 22decf2bd3
8 changed files with 897 additions and 12 deletions

View File

@ -7,6 +7,7 @@ import (
"strings"
"llmsproxy/internal/config"
"llmsproxy/internal/scheduler"
)
// handleKeysAPI manages gateway keys: GET /api/keys (admin: all keys),
@ -17,10 +18,22 @@ func (g *Gateway) handleKeysAPI(w http.ResponseWriter, r *http.Request) {
path = strings.Trim(path, "/")
role := reqRole(r.Context())
// /api/keys/me/auto — a user's own chain, readable without admin rights.
// Must be matched before the generic {key}/auto case below so it does not
// ask admin rights of a plain user asking about their own chain.
if path == "me/auto" {
g.handleKeyMeAuto(w, r)
return
}
if path == "me" {
g.handleKeyMe(w, r)
return
}
// /api/keys/{key}/auto — the per-key AUTO chain editor.
if i := strings.LastIndex(path, "/auto"); i > 0 && path[i+len("/auto"):] == "" {
g.handleKeyAutoAPI(w, r, path[:i])
return
}
if role != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
@ -139,6 +152,101 @@ func (g *Gateway) allowedModels(ctx context.Context) []config.ModelScope {
return rec.Models
}
// handleKeyAutoAPI manages one key's own AUTO chain:
// GET /api/keys/{key}/auto returns it, PUT replaces it, DELETE clears the
// override so the key falls back to the global chain. Admin only — a user can
// inspect their own chain through GET /api/keys/me/auto.
//
// An empty chain and a cleared chain are deliberately the same state: "inherit
// the global chain". That keeps the WebUI's "use global" toggle a plain DELETE
// with no sentinel value to carry through config.yaml.
func (g *Gateway) handleKeyAutoAPI(w http.ResponseWriter, r *http.Request, key string) {
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
rec, ok := g.core.FindKey(key)
if !ok {
writeError(w, http.StatusNotFound, "not_found", "key not found")
return
}
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]interface{}{
"key": key,
"auto": rec.Auto,
"inherits": !rec.HasOwnAuto(),
})
case http.MethodPut, http.MethodPatch:
var body struct {
Auto []config.ModelScope `json:"auto"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
if len(body.Auto) == 0 {
// Treat an empty PUT as "give up the override" so the endpoint
// cannot persist a chain that would 503 with no slots.
if err := g.core.SaveKeyAuto(key, nil); err != nil {
writeError(w, http.StatusBadRequest, "key_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "inherits": true})
return
}
if err := g.core.SaveKeyAuto(key, body.Auto); err != nil {
writeError(w, http.StatusBadRequest, "key_error", err.Error())
return
}
// Report whether the chain actually resolved to usable slots. An
// admin who typed a model that no longer exists should learn it here,
// not from the user's next 503.
chain, _ := g.core.AutoChainFor(key)
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true, "inherits": false, "slots": chainSlots(chain),
})
case http.MethodDelete:
if err := g.core.SaveKeyAuto(key, nil); err != nil {
writeError(w, http.StatusBadRequest, "key_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "inherits": true})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
}
}
// chainSlots counts the usable slots in a chain, for API feedback after an
// edit. A chain that compiled to zero slots is reported so the caller can warn
// instead of letting the next request fail with no_provider.
func chainSlots(ch *scheduler.Chain) int {
if ch == nil {
return 0
}
n := 0
for _, tn := range ch.Tiers {
n += len(tn.Slots)
}
return n
}
// handleKeyMeAuto lets a user read their own AUTO chain without admin rights.
func (g *Gateway) handleKeyMeAuto(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
return
}
rec, ok := g.core.FindKey(reqKey(r.Context()))
if !ok {
writeError(w, http.StatusUnauthorized, "invalid_api_key", "key not found")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"key": rec.Key, "auto": rec.Auto, "inherits": !rec.HasOwnAuto(),
})
}
// handleAutoAPI manages the AUTO scheduling slots: GET /api/auto returns the
// current rules; PUT /api/auto replaces them (admin only).
func (g *Gateway) handleAutoAPI(w http.ResponseWriter, r *http.Request) {