mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-05 07:02:29 +00:00
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:
@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user