From 3408c9cb1fc26c60a70d8a86aa70befb80d5ab76 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 9 Aug 2026 10:01:40 +0800 Subject: [PATCH] feat(keys): role-based gateway keys with admin management UI and per-user model scope --- internal/config/config.go | 13 ++ internal/config/store.go | 55 +++++ internal/core/core.go | 92 ++++++++ internal/gateway/api.go | 13 +- internal/gateway/chat.go | 100 +++++++- internal/gateway/keys.go | 129 +++++++++++ internal/gateway/server.go | 80 ++++--- internal/gateway/stats.go | 71 ++++-- internal/gateway/ui/index.html | 388 +++++++++++++++++++++++++++++++- internal/scheduler/scheduler.go | 42 ++-- 10 files changed, 901 insertions(+), 82 deletions(-) create mode 100644 internal/gateway/keys.go diff --git a/internal/config/config.go b/internal/config/config.go index 1d20699..8364adc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -121,4 +121,17 @@ func (c *Config) ApplyDefaults() error { // RuntimeConfig is the persisted web-UI editable slice (sources added/edited). type RuntimeConfig struct { Sources []Source `json:"sources"` + Keys []GWKey `json:"keys,omitempty"` +} + +// GWKey is a gateway API key persisted in the runtime store. Role is "admin" +// (full management) or "user" (sees only its own key); Models is the allowed +// model whitelist (nil/empty = all models). +type GWKey struct { + Key string `json:"key"` + Role string `json:"role"` + Name string `json:"name,omitempty"` + Models []string `json:"models,omitempty"` + Note string `json:"note,omitempty"` + CreatedAt int64 `json:"created_at,omitempty"` } diff --git a/internal/config/store.go b/internal/config/store.go index 5e3e1cd..46f1014 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -82,4 +82,59 @@ func (s *Store) persistLocked() error { return err } return os.WriteFile(s.path, b, 0644) +} + +// ListKeys returns the persisted gateway keys. +func (s *Store) ListKeys() []GWKey { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]GWKey, len(s.data.Keys)) + copy(out, s.data.Keys) + return out +} + +// KeyByValue looks up a gateway key record by its secret value. +func (s *Store) KeyByValue(key string) (GWKey, bool) { + s.mu.Lock() + defer s.mu.Unlock() + for _, k := range s.data.Keys { + if k.Key == key { + return k, true + } + } + return GWKey{}, false +} + +// SaveKey upserts a gateway key record and persists. +func (s *Store) SaveKey(k GWKey) error { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.data.Keys { + if s.data.Keys[i].Key == k.Key { + s.data.Keys[i] = k + return s.persistLocked() + } + } + s.data.Keys = append(s.data.Keys, k) + return s.persistLocked() +} + +// DeleteKey removes a gateway key record and persists. +func (s *Store) DeleteKey(key string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + kept := s.data.Keys[:0] + removed := false + for _, k := range s.data.Keys { + if k.Key == key { + removed = true + continue + } + kept = append(kept, k) + } + if !removed { + return false, nil + } + s.data.Keys = kept + return true, s.persistLocked() } \ No newline at end of file diff --git a/internal/core/core.go b/internal/core/core.go index ff46a09..7fbb3f4 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -4,9 +4,12 @@ package core import ( + "crypto/rand" + "encoding/hex" "fmt" "os" "path/filepath" + "time" "llmsproxy/internal/config" "llmsproxy/internal/lua" @@ -44,12 +47,47 @@ func NewFromConfig(cfg *config.Config) (*Core, error) { return nil, fmt.Errorf("runtime store: %w", err) } c.scheduler = scheduler.New(buildRetries(cfg)) + if err := c.seedKeys(); err != nil { + return nil, err + } if err := c.rebuildRegistry(); err != nil { return nil, err } return c, nil } +// seedKeys migrates the static config gateway_keys into the runtime store as +// admin keys (once), so later UI-created keys can share the same store. +func (c *Core) seedKeys() error { + existing := map[string]bool{} + for _, k := range c.store.ListKeys() { + existing[k.Key] = true + } + changed := false + for i, raw := range c.cfg.GatewayKeys { + if raw == "" || existing[raw] { + continue + } + name := "admin" + if i > 0 { + name = fmt.Sprintf("admin-%d", i+1) + } + if err := c.store.SaveKey(config.GWKey{ + Key: raw, + Role: "admin", + Name: name, + CreatedAt: time.Now().Unix(), + }); err != nil { + return err + } + changed = true + } + if changed { + return c.store.Load() + } + return nil +} + func buildRetries(cfg *config.Config) int { return len(cfg.Sources) // allow fallback across all sources } @@ -67,6 +105,60 @@ func (c *Core) GatewayKeys() []string { return c.cfg.GatewayKeys } func (c *Core) Listen() string { return c.cfg.Listen } +// ---- gateway key management (web UI) ---- + +// ListKeys returns all gateway keys (admin view). +func (c *Core) ListKeys() []config.GWKey { return c.store.ListKeys() } + +// FindKey looks up a gateway key record by its secret value. +func (c *Core) FindKey(key string) (config.GWKey, bool) { return c.store.KeyByValue(key) } + +// CreateKey builds a new random gateway key and persists it. +func (c *Core) CreateKey(name, role string, models []string, note string) (config.GWKey, error) { + key := make([]byte, 16) + if _, err := rand.Read(key); err != nil { + return config.GWKey{}, err + } + rec := config.GWKey{ + Key: "sk-gw-" + hex.EncodeToString(key), + Role: role, + Name: name, + Models: models, + Note: note, + CreatedAt: time.Now().Unix(), + } + if rec.Role == "" { + rec.Role = "user" + } + if err := c.store.SaveKey(rec); err != nil { + return config.GWKey{}, err + } + return rec, nil +} + +// UpdateKey mutates a key's name/role/model scope and persists it. +func (c *Core) UpdateKey(key, name, role string, models []string, note string) (config.GWKey, error) { + rec, ok := c.store.KeyByValue(key) + if !ok { + return config.GWKey{}, fmt.Errorf("key not found") + } + if name != "" { + rec.Name = name + } + if role == "admin" || role == "user" { + rec.Role = role + } + rec.Models = models + rec.Note = note + if err := c.store.SaveKey(rec); err != nil { + return config.GWKey{}, err + } + return rec, nil +} + +// DeleteKey removes a key record; returns false if it did not exist. +func (c *Core) DeleteKey(key string) (bool, error) { return c.store.DeleteKey(key) } + // Config exposes the underlying configuration (read-only usage). func (c *Core) Config() *config.Config { return c.cfg } diff --git a/internal/gateway/api.go b/internal/gateway/api.go index 36b3254..7b4436e 100644 --- a/internal/gateway/api.go +++ b/internal/gateway/api.go @@ -4,6 +4,7 @@ import ( "encoding/json" "io" "net/http" + "strconv" "strings" "llmsproxy/internal/config" @@ -122,5 +123,15 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) { return } limit := 500 - writeJSON(w, http.StatusOK, g.stats.Snapshot(limit)) + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 5000 { + limit = n + } + } + key := r.URL.Query().Get("key") + if reqRole(r.Context()) != "admin" { + // user keys may only see their own usage + key = keyID(reqKey(r.Context())) + } + writeJSON(w, http.StatusOK, g.stats.Snapshot(limit, key)) } diff --git a/internal/gateway/chat.go b/internal/gateway/chat.go index 7adee6d..c83464f 100644 --- a/internal/gateway/chat.go +++ b/internal/gateway/chat.go @@ -79,12 +79,17 @@ func isAuto(m string) bool { // resolveCands picks the ordered candidate providers for a requested model. // toolCalling requests are anchored: they resolve to exactly one provider // (highest-priority available) so a tool-call round never switches models. -func (g *Gateway) resolveCands(req *chatRequest) ([]*provider.Provider, string) { +func (g *Gateway) resolveCands(ctx context.Context, req *chatRequest) ([]*provider.Provider, string) { model := req.Model if model == "" { model = g.core.DefaultModel() } cands, effective := g.resolveByModel(model) + cands = chatOnly(cands) + allow := g.allowedModels(ctx) + if allow != nil { + cands = filterCandsByModels(cands, allow) + } if !toolRequest(req) { return cands, effective } @@ -100,6 +105,42 @@ func (g *Gateway) resolveCands(req *chatRequest) ([]*provider.Provider, string) return []*provider.Provider{first}, eff } +// filterCandsByModels keeps only providers exposing at least one model of the +// whitelist (used for user keys with a restricted model scope). +func filterCandsByModels(cands []*provider.Provider, allow []string) []*provider.Provider { + allowed := make(map[string]bool, len(allow)) + for _, m := range allow { + allowed[m] = true + } + out := make([]*provider.Provider, 0, len(cands)) + for _, p := range cands { + for _, id := range p.Models() { + if allowed[id] { + out = append(out, p) + break + } + } + } + return out +} + +// intersectModels restricts a model list to the whitelist (preserving order). +func intersectModels(models, allow []string) []string { + allowed := make(map[string]bool, len(allow)) + for _, m := range allow { + allowed[m] = true + } + out := make([]string, 0, len(models)) + seen := map[string]bool{} + for _, m := range models { + if allowed[m] && !seen[m] { + seen[m] = true + out = append(out, m) + } + } + return out +} + func (g *Gateway) resolveByModel(model string) ([]*provider.Provider, string) { if isAuto(model) { return g.core.Registry().Resolve("AUTO"), "" @@ -107,6 +148,22 @@ func (g *Gateway) resolveByModel(model string) ([]*provider.Provider, string) { return g.core.Registry().Resolve(model), model } +// chatOnly keeps providers that expose at least one chat-capable model, so a +// chat/AUTO request never lands on an image-only source (or borrows its image +// model id). Explicit image-kind requests stay on the imageOnly path. +func chatOnly(cands []*provider.Provider) []*provider.Provider { + out := make([]*provider.Provider, 0, len(cands)) + for _, p := range cands { + for _, id := range p.Models() { + if m := p.ModelByID(id); m == nil || m.Kind != "image" { + out = append(out, p) + break + } + } + } + return out +} + // toolRequest reports whether the request participates in a tool-call round. func toolRequest(req *chatRequest) bool { if len(req.Tools) > 0 || req.ToolChoice != nil { @@ -138,7 +195,13 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) { if model == "" { model = g.core.DefaultModel() } - cands, effective := g.resolveCands(&req) + if !isAuto(model) { + if allow := g.allowedModels(r.Context()); allow != nil && !containsStr(allow, model) { + writeError(w, http.StatusForbidden, "model_not_allowed", fmt.Sprintf("model %q is not allowed for this key", model)) + return + } + } + cands, effective := g.resolveCands(r.Context(), &req) if len(cands) == 0 { writeError(w, http.StatusServiceUnavailable, "no_provider", "no LLM source configured") return @@ -237,10 +300,19 @@ func firstSource(cands []*provider.Provider) string { return "" } +func containsStr(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands []*provider.Provider, req *types.ChatRequest, effective string, rec *Req) { rec.LatMs = 0 t0 := time.Now() - resp, err := g.core.Scheduler().Chat(ctx, scheduler.FromRegistry(cands), req) + resp, usedSrc, usedModel, err := g.core.Scheduler().Chat(ctx, scheduler.FromRegistry(cands), req) rec.LatMs = time.Since(t0).Milliseconds() if err != nil { rec.OK = false @@ -254,7 +326,8 @@ func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands [ rec.Status = http.StatusOK rec.Prompt = int64(resp.TokenUsage.Prompt) rec.Compl = int64(resp.TokenUsage.Completion) - rec.Source = firstSource(cands) + rec.Source = usedSrc + rec.Model = usedModel g.writeRec(rec) msg := RespMessage{Role: "assistant", Content: resp.Content} if resp.ReasoningContent != "" { @@ -296,7 +369,7 @@ func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands [ rec.LatMs = time.Since(t0).Milliseconds() g.writeRec(rec) }() - chunks, err := g.core.Scheduler().ChatStream(ctx, scheduler.FromRegistry(cands), req) + chunks, _, usedModel, err := g.core.Scheduler().ChatStream(ctx, scheduler.FromRegistry(cands), req) if err != nil { rec.OK = false rec.Status = http.StatusBadGateway @@ -304,6 +377,9 @@ func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands [ writeError(w, http.StatusBadGateway, "upstream_error", err.Error()) return } + if usedModel != "" { + rec.Model = usedModel + } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") @@ -383,8 +459,17 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) { if model == "" { model = g.core.DefaultModel() } + if !isAuto(model) { + if allow := g.allowedModels(r.Context()); allow != nil && !containsStr(allow, model) { + writeError(w, http.StatusForbidden, "model_not_allowed", fmt.Sprintf("model %q is not allowed for this key", model)) + return + } + } cands, _ := g.resolveByModel(model) cands = imageOnly(cands) + if allow := g.allowedModels(r.Context()); allow != nil { + cands = filterCandsByModels(cands, allow) + } if len(cands) == 0 { writeError(w, http.StatusServiceUnavailable, "no_provider", "no image source configured") return @@ -393,7 +478,7 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) { defer done() rec := &Req{Key: keyID(reqKey(r.Context())), Type: "image", Model: model, Source: firstSource(cands), OK: false} t0 := time.Now() - resp, err := g.core.Scheduler().Image(r.Context(), scheduler.FromRegistry(cands), &req) + resp, usedSrc, err := g.core.Scheduler().Image(r.Context(), scheduler.FromRegistry(cands), &req) rec.LatMs = time.Since(t0).Milliseconds() if err != nil { rec.Status = http.StatusBadGateway @@ -402,6 +487,9 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadGateway, "upstream_error", err.Error()) return } + if usedSrc != "" { + rec.Source = usedSrc + } rec.OK = true rec.Status = http.StatusOK rec.Compl = int64(len(resp.ImageData)) diff --git a/internal/gateway/keys.go b/internal/gateway/keys.go new file mode 100644 index 0000000..2a4d4e2 --- /dev/null +++ b/internal/gateway/keys.go @@ -0,0 +1,129 @@ +package gateway + +import ( + "context" + "encoding/json" + "net/http" + "strings" +) + +// 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()) + + if path == "me" { + g.handleKeyMe(w, r) + return + } + if role != "admin" { + writeError(w, http.StatusForbidden, "forbidden", "admin role required") + return + } + + switch r.Method { + case http.MethodGet: + if path != "" { + writeError(w, http.StatusNotFound, "not_found", "use GET /api/keys") + 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 []string `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 + } + if body.Role == "" { + body.Role = "user" + } + if body.Role != "admin" && body.Role != "user" { + writeError(w, http.StatusBadRequest, "invalid_request", "role must be admin or user") + return + } + 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 []string `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 + } + 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 + } + writeJSON(w, http.StatusOK, map[string]interface{}{"key": rec}) +} + +// allowedModels returns the model whitelist for the request's key; nil means +// unrestricted (admin keys and user keys without an explicit scope). +func (g *Gateway) allowedModels(ctx context.Context) []string { + if reqRole(ctx) == "admin" { + return nil + } + rec, ok := g.core.FindKey(reqKey(ctx)) + if !ok { + return nil + } + return rec.Models +} \ No newline at end of file diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 8462ce0..1f8706f 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -15,6 +15,7 @@ import ( "net/url" "strings" + "llmsproxy/internal/config" "llmsproxy/internal/core" ) @@ -23,28 +24,20 @@ var uiFS embed.FS // Gateway is the HTTP handler for the OpenAI-compatible endpoint + web UI. type Gateway struct { - core *core.Core - apiKeys map[string]bool - ui http.Handler - stats *Stats + core *core.Core + ui http.Handler + stats *Stats } func New(c *core.Core, gatewayKeys []string) (*Gateway, error) { - keys := map[string]bool{} - for _, k := range gatewayKeys { - if k != "" { - keys[k] = true - } - } sub, err := fs.Sub(uiFS, "ui") if err != nil { return nil, err } return &Gateway{ - core: c, - apiKeys: keys, - ui: http.FileServer(http.FS(sub)), - stats: NewStats(3000), + core: c, + ui: http.FileServer(http.FS(sub)), + stats: NewStats(3000), }, nil } @@ -77,6 +70,8 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) { g.handleStatusAPI(w, r) case r.URL.Path == "/api/stats" || strings.HasPrefix(r.URL.Path, "/api/stats/"): g.handleStatsAPI(w, r) + case r.URL.Path == "/api/keys" || strings.HasPrefix(r.URL.Path, "/api/keys/"): + g.handleKeysAPI(w, r) case r.URL.Path == "/login": g.handleLogin(w, r) case r.URL.Path == "/api/login": @@ -105,10 +100,6 @@ func (g *Gateway) serveUI(w http.ResponseWriter, r *http.Request) { func (g *Gateway) auth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if len(g.apiKeys) == 0 { - next.ServeHTTP(w, r) - return - } key := "" if h := r.Header.Get("Authorization"); h != "" { parts := strings.SplitN(h, " ", 2) @@ -124,7 +115,8 @@ func (g *Gateway) auth(next http.Handler) http.Handler { key = c.Value } } - if !g.apiKeys[key] { + rec, ok := g.core.FindKey(key) + if !ok { if isAPIPath(r.URL.Path) { writeError(w, http.StatusUnauthorized, "invalid_api_key", "invalid gateway api key") return @@ -133,21 +125,36 @@ func (g *Gateway) auth(next http.Handler) http.Handler { http.Redirect(w, r, "/login?continue="+url.QueryEscape(r.URL.Path), http.StatusFound) return } - next.ServeHTTP(w, r.WithContext(withKey(r.Context(), key))) + next.ServeHTTP(w, r.WithContext(withAuth(r.Context(), key, rec.Role))) }) } -// keyCtxKey is the context key carrying the authenticated gateway key. -type keyCtxKey struct{} +// authCtx carries the authenticated gateway key and its role. +type authCtx struct { + key string + role string +} -func withKey(ctx context.Context, key string) context.Context { - return context.WithValue(ctx, keyCtxKey{}, key) +type authCtxKeyT struct{} + +func withAuth(ctx context.Context, key, role string) context.Context { + return context.WithValue(ctx, authCtxKeyT{}, authCtx{key: key, role: role}) } // reqKey returns the authenticated gateway key id (masked suffix for display). func reqKey(ctx context.Context) string { - k, _ := ctx.Value(keyCtxKey{}).(string) - return k + if a, ok := ctx.Value(authCtxKeyT{}).(authCtx); ok { + return a.key + } + return "" +} + +// reqRole returns the authenticated key role ("admin" or "user"). +func reqRole(ctx context.Context) string { + if a, ok := ctx.Value(authCtxKeyT{}).(authCtx); ok { + return a.role + } + return "user" } // keyID returns a short stable id for a gateway key (last 6 chars). @@ -232,9 +239,9 @@ var LANG='%[3]s'; var CONT='%[2]s'; var I={zh:{title:'ModelRouter',sub:'统一 LLM 网关 · 登录',ph:'输入网关 API Key',btn:'登录',lang:'English'}, en:{title:'ModelRouter',sub:'Unified LLM Gateway · Sign in',ph:'Enter gateway API key',btn:'Sign in',lang:'中文'}}; -function apply(){var t=I[LANG]||I.zh;document.querySelector('[data-i=title]').textContent=t.title; + function apply(){var t=I[LANG]||I.zh;document.querySelector('[data-i=title]').textContent=t.title; document.querySelector('[data-i=sub]').textContent=t.sub; - document.querySelector('[data-i=ph]').placeholder=t.ph; + var ph=document.querySelector('#key');if(ph)ph.placeholder=t.ph; document.querySelector('[data-i=btn]').textContent=t.btn; document.querySelector('[data-i=lang]').textContent=t.lang; document.documentElement.lang=LANG==='zh'?'zh':'en';} @@ -265,7 +272,7 @@ func (g *Gateway) handleLoginAPI(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid_request", "invalid json") return } - if !g.apiKeys[body.Key] { + if _, ok := g.core.FindKey(body.Key); !ok { writeError(w, http.StatusUnauthorized, "invalid_api_key", "invalid gateway api key") return } @@ -286,6 +293,9 @@ func (g *Gateway) handleModels(w http.ResponseWriter, r *http.Request) { return } models := g.core.Registry().ModelList() + if allow := g.allowedModels(r.Context()); allow != nil { + models = intersectModels(models, allow) + } type modelObj struct { ID string `json:"id"` Object string `json:"object"` @@ -308,9 +318,13 @@ func (g *Gateway) handleStatusAPI(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(host, ":") { host = "127.0.0.1" + host } - keys := make([]string, 0, len(g.apiKeys)) - for k := range g.apiKeys { - keys = append(keys, k) + ks := make([]config.GWKey, 0, len(g.core.ListKeys())) + for _, k := range g.core.ListKeys() { + ks = append(ks, config.GWKey{ + Key: k.Key, + Role: k.Role, + Name: k.Name, + }) } writeJSON(w, http.StatusOK, map[string]interface{}{ "default_model": g.core.DefaultModel(), @@ -318,7 +332,7 @@ func (g *Gateway) handleStatusAPI(w http.ResponseWriter, r *http.Request) { "sources": g.core.Registry().Status(), "adapters": g.core.ListAdapters(), "base_url": "http://" + host + "/v1", - "gateway_keys": keys, + "gateway_keys": ks, }) } diff --git a/internal/gateway/stats.go b/internal/gateway/stats.go index cfe8108..75d7d0a 100644 --- a/internal/gateway/stats.go +++ b/internal/gateway/stats.go @@ -45,13 +45,15 @@ type agrRow struct { // Stats collects per-key / per-model / per-source aggregates plus a bounded // ring of raw request records, all guarded by one mutex. type Stats struct { - mu sync.Mutex - active int64 - byKey map[string]*Stat - byModel map[string]*Stat - bySrc map[string]*Stat - recs []Req - maxRecs int + mu sync.Mutex + active int64 + byKey map[string]*Stat + byModel map[string]*Stat + bySrc map[string]*Stat + byKeyModel map[string]map[string]*Stat + byKeySrc map[string]map[string]*Stat + recs []Req + maxRecs int } func NewStats(maxRecords int) *Stats { @@ -59,10 +61,12 @@ func NewStats(maxRecords int) *Stats { maxRecords = 3000 } return &Stats{ - byKey: map[string]*Stat{}, - byModel: map[string]*Stat{}, - bySrc: map[string]*Stat{}, - maxRecs: maxRecords, + byKey: map[string]*Stat{}, + byModel: map[string]*Stat{}, + bySrc: map[string]*Stat{}, + byKeyModel: map[string]map[string]*Stat{}, + byKeySrc: map[string]map[string]*Stat{}, + maxRecs: maxRecords, } } @@ -107,6 +111,18 @@ func (s *Stats) Record(r Req) { inc(s.byKey, r.Key, r) inc(s.byModel, r.Model, r) inc(s.bySrc, r.Source, r) + km := s.byKeyModel[r.Key] + if km == nil { + km = map[string]*Stat{} + s.byKeyModel[r.Key] = km + } + inc(km, r.Model, r) + ks := s.byKeySrc[r.Key] + if ks == nil { + ks = map[string]*Stat{} + s.byKeySrc[r.Key] = ks + } + inc(ks, r.Source, r) s.recs = append(s.recs, r) if len(s.recs) > s.maxRecs { s.recs = s.recs[len(s.recs)-s.maxRecs:] @@ -132,8 +148,9 @@ type StatsRow struct { Stat } -// Snapshot returns the whole dashboard payload. -func (s *Stats) Snapshot(limit int) map[string]interface{} { +// Snapshot returns the whole dashboard payload; when key != "" the records +// and aggregate views are restricted to that gateway key. +func (s *Stats) Snapshot(limit int, key string) map[string]interface{} { s.mu.Lock() defer s.mu.Unlock() if limit <= 0 { @@ -143,8 +160,26 @@ func (s *Stats) Snapshot(limit int) map[string]interface{} { if len(s.recs) > limit { start = len(s.recs) - limit } + recs := s.recs[start:] + if key != "" { + filt := recs[:0] + for _, r := range recs { + if r.Key == key { + filt = append(filt, r) + } + } + recs = filt + } var total Stat - for _, a := range s.byModel { + var byModel, byKey, bySrc map[string]*Stat + if key == "" { + byKey, byModel, bySrc = s.byKey, s.byModel, s.bySrc + } else { + byKey = map[string]*Stat{key: s.byKey[key]} + byModel = s.byKeyModel[key] + bySrc = s.byKeySrc[key] + } + for _, a := range byModel { total.Reqs += a.Reqs total.OK += a.OK total.Err += a.Err @@ -159,9 +194,9 @@ func (s *Stats) Snapshot(limit int) map[string]interface{} { return map[string]interface{}{ "active": s.active, "total": total, - "by_key": rows(s.byKey), - "by_model": rows(s.byModel), - "by_source": rows(s.bySrc), - "records": append([]Req(nil), s.recs[start:]...), + "by_key": rows(byKey), + "by_model": rows(byModel), + "by_source": rows(bySrc), + "records": append([]Req(nil), recs...), } } \ No newline at end of file diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 5fec9da..efd9ad4 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -93,6 +93,63 @@ pre.configbox { background:var(--card2); border:1px solid var(--line); border-ra .att button { position:absolute; top:-8px; right:-8px; border-radius:50%; } .modelchip { cursor:pointer; color:var(--accent); text-decoration:none; } +/* ---------- dashboard / usage metrics ---------- */ +.kpis { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:12px; margin-bottom:18px; } +.kpi { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:14px 16px; box-shadow:var(--shadow); } +.kpi .k-lab { font-size:12px; color:var(--muted); display:flex; align-items:center; gap:6px; } +.kpi .k-val { font-size:26px; font-weight:800; margin-top:4px; font-variant-numeric:tabular-nums; letter-spacing:.3px; } +.kpi .k-val .u { font-size:13px; font-weight:600; color:var(--muted); margin-left:3px; } +.kpi .k-sub { font-size:11.5px; color:var(--muted); margin-top:3px; } +.dot { width:7px; height:7px; border-radius:50%; background:var(--ok); display:inline-block; animation:blink 1.6s infinite; } +.dot.err { background:var(--err); } +@keyframes blink { 50% { opacity:.25; } } +.dash-row { display:flex; gap:12px; margin-bottom:18px; } +.dash-row .card { flex:1; min-width:0; } +td.num, th.num { text-align:right; font-variant-numeric:tabular-nums; } +td .okc { color:var(--ok); font-weight:600; } +td .errc { color:var(--err); font-weight:600; } +.mini { font-size:11.5px; color:var(--muted); } +.bar { height:5px; border-radius:3px; background:var(--card2); overflow:hidden; min-width:60px; } +.bar i { display:block; height:100%; background:var(--accent); border-radius:3px; } +.bar.ok i { background:var(--ok); } +.bar.err i { background:var(--err); } +table th { white-space:nowrap; } +.recs-scroll { max-height:420px; overflow:auto; } +.recs-scroll table th { position:sticky; top:0; background:var(--card); z-index:1; } +td.t-tag { white-space:nowrap; } +.tbl-wrap { overflow-x:auto; -webkit-overflow-scrolling:touch; } +.tbl-wrap table { width:max-content; min-width:100%; } + +/* ---------- keys tab ---------- */ +.keys-grid { display:flex; flex-direction:column; gap:12px; } +.key-row { display:flex; align-items:center; gap:10px; padding:10px 12px; border:1px solid var(--line); + border-radius:12px; background:var(--card); flex-wrap:wrap; } +.key-row .kr-name { font-weight:700; min-width:120px; } +.key-row .kr-key { font-family:ui-monospace,Menlo,Consolas,monospace; font-size:12px; color:var(--muted); + overflow:hidden; text-overflow:ellipsis; max-width:340px; white-space:nowrap; } +.key-row .grow { flex:1; } +.tag-role { font-size:11px; padding:2px 8px; border-radius:99px; font-weight:600; } +.tag-role.admin { background:#3f6ef5; color:#fff; } +.tag-role.user { background:var(--card2); color:var(--muted); border:1px solid var(--line); } +.scope-box { border-top:1px dashed var(--line); margin-top:8px; padding-top:10px; } +.scope-chips { display:flex; flex-wrap:wrap; gap:8px; align-items:center; min-height:40px; } +.scope-chip { display:inline-flex; align-items:center; gap:6px; padding:5px 10px; border-radius:99px; + background:linear-gradient(135deg,#3f6ef5,#6a8ffb); color:#fff; font-size:12.5px; font-weight:600; + cursor:grab; user-select:none; } +.scope-chip[draggable="true"]:active { cursor:grabbing; } +.scope-chip .rm { cursor:pointer; opacity:.75; font-size:13px; line-height:1; padding:0 2px; } +.scope-chip .rm:hover { opacity:1; } +.scope-chip.drag-over { outline:2px dashed #fff; outline-offset:2px; } +.scope-add { padding:5px 10px; border-radius:99px; border:1px dashed var(--line); background:transparent; + color:var(--muted); font-size:12.5px; cursor:pointer; } +.scope-unlim { color:var(--accent); font-weight:700; font-size:13px; } +.keys-create { display:grid; grid-template-columns:1fr 2fr 1fr 2fr; gap:10px; align-items:end; } +.keys-create label { font-size:11.5px; color:var(--muted); margin-bottom:4px; } +.keys-create input, .keys-create select { margin:0; } +@media (max-width:760px) { .keys-create { grid-template-columns:1fr; } } +.filter-line { display:flex; align-items:center; gap:8px; margin-bottom:10px; } +.filter-line select { width:auto; margin:0; padding:6px 10px; } + /* ---------- chat page (clean) ---------- */ .tab-chat { max-width:840px; margin:0 auto; display:flex; flex-direction:column; height:calc(100vh - 178px); } .chat-wrap { background:var(--card); border:1px solid var(--line); border-radius:12px; box-shadow:var(--shadow); @@ -290,6 +347,7 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho