mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-05 07:02:29 +00:00
此前 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>
296 lines
9.8 KiB
Go
296 lines
9.8 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"llmsproxy/internal/config"
|
|
"llmsproxy/internal/scheduler"
|
|
)
|
|
|
|
// handleKeysAPI manages gateway keys: GET /api/keys (admin: all keys),
|
|
// GET /api/keys/me (own key for any role), POST /api/keys (admin: create),
|
|
// PUT /api/keys/{key} (admin: update), DELETE /api/keys/{key} (admin: remove).
|
|
func (g *Gateway) handleKeysAPI(w http.ResponseWriter, r *http.Request) {
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/keys")
|
|
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
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
if path != "" {
|
|
// A GET on a specific key is almost always a client that meant to
|
|
// DELETE or PUT it but let fetch default to GET. Name the verbs
|
|
// instead of only pointing back at the collection endpoint.
|
|
writeError(w, http.StatusNotFound, "not_found",
|
|
"no such endpoint; use GET /api/keys to list, DELETE /api/keys/{key} to remove, PUT /api/keys/{key} to update")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"keys": g.core.ListKeys()})
|
|
case http.MethodPost:
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
Role string `json:"role"`
|
|
Models []config.ModelScope `json:"models"`
|
|
Note string `json:"note"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
|
return
|
|
}
|
|
body.Role = config.NormalizeRole(body.Role)
|
|
rec, err := g.core.CreateKey(body.Name, body.Role, body.Models, body.Note)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "key_error", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "key": rec})
|
|
case http.MethodPut, http.MethodPatch:
|
|
if path == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "key required")
|
|
return
|
|
}
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
Role string `json:"role"`
|
|
Models []config.ModelScope `json:"models"`
|
|
Note string `json:"note"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
|
return
|
|
}
|
|
// Each scope entry carries its own token/request caps, so replacing the
|
|
// scope replaces the budgets with it — there is no separate key-wide
|
|
// quota that could drift out of sync with the models.
|
|
rec, err := g.core.UpdateKey(path, body.Name, body.Role, body.Models, body.Note)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "key_error", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "key": rec})
|
|
case http.MethodDelete:
|
|
if path == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "key required")
|
|
return
|
|
}
|
|
if path == reqKey(r.Context()) {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "cannot delete the key you are logged in with")
|
|
return
|
|
}
|
|
ok, err := g.core.DeleteKey(path)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "key_error", err.Error())
|
|
return
|
|
}
|
|
if !ok {
|
|
writeError(w, http.StatusNotFound, "not_found", "key not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
|
default:
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
|
}
|
|
}
|
|
|
|
// handleKeyMe returns the authenticated key's own record (users see only
|
|
// themselves; admins can use this as a convenience too).
|
|
func (g *Gateway) handleKeyMe(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
|
|
}
|
|
if !rec.Seed {
|
|
for _, s := range g.core.GatewayKeys() {
|
|
if s == rec.Key {
|
|
rec.Seed = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"key": rec})
|
|
}
|
|
|
|
// allowedModels returns the model scope for the request's key; nil means
|
|
// unrestricted (admin keys and user keys without an explicit scope).
|
|
func (g *Gateway) allowedModels(ctx context.Context) []config.ModelScope {
|
|
if reqRole(ctx) == "admin" {
|
|
return nil
|
|
}
|
|
rec, ok := g.core.FindKey(reqKey(ctx))
|
|
if !ok {
|
|
return nil
|
|
}
|
|
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) {
|
|
if r.Method == http.MethodGet {
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"rules": g.core.AutoRules(),
|
|
"image_rules": g.core.AutoImageRules(),
|
|
"states": g.core.AutoSlotStates(),
|
|
})
|
|
return
|
|
}
|
|
if reqRole(r.Context()) != "admin" {
|
|
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodPut, http.MethodPost:
|
|
var body struct {
|
|
Rules []config.ModelScope `json:"rules"`
|
|
ImageRules []config.ModelScope `json:"image_rules"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
|
return
|
|
}
|
|
if body.Rules != nil {
|
|
if err := g.core.SaveAutoRules(body.Rules); err != nil {
|
|
writeError(w, http.StatusBadRequest, "auto_error", err.Error())
|
|
return
|
|
}
|
|
}
|
|
if body.ImageRules != nil {
|
|
if err := g.core.SaveAutoImageRules(body.ImageRules); err != nil {
|
|
writeError(w, http.StatusBadRequest, "auto_error", err.Error())
|
|
return
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ok": true,
|
|
"rules": g.core.AutoRules(),
|
|
"image_rules": g.core.AutoImageRules(),
|
|
})
|
|
default:
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
|
}
|
|
}
|