feat(keys): role-based gateway keys with admin management UI and per-user model scope

This commit is contained in:
root
2026-08-09 10:01:40 +08:00
parent dec03238dd
commit 3408c9cb1f
10 changed files with 901 additions and 82 deletions

View File

@ -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"`
}

View File

@ -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()
}

View File

@ -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 }

View File

@ -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))
}

View File

@ -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))

129
internal/gateway/keys.go Normal file
View File

@ -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
}

View File

@ -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,
})
}

View File

@ -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...),
}
}

View File

@ -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
<nav>
<button data-tab="status" class="active" data-i="navStatus">状态</button>
<button data-tab="chat" data-i="navChat">Chat 测试</button>
<button data-tab="keys" data-i="navKeys">密钥</button>
<button data-tab="sort" data-i="navSort">优先级</button>
<button data-tab="sources" data-i="navSources"></button>
<button data-tab="adapters" data-i="navAdapters">适配器</button>
@ -304,6 +362,7 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho
<main>
<div id="tab-status"></div>
<div id="tab-chat" class="hidden"></div>
<div id="tab-keys" class="hidden"></div>
<div id="tab-sort" class="hidden"></div>
<div id="tab-sources" class="hidden"></div>
<div id="tab-adapters" class="hidden"></div>
@ -314,7 +373,16 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho
const STR = {
zh: {
tagline:'统一 LLM 网关', logout:'退出登录', langTo:'EN',
navStatus:'状态', navChat:'Chat 测试', navSources:'源', navAdapters:'适配器', navSort:'优先级',
navStatus:'状态', navChat:'Chat 测试', navSources:'源', navAdapters:'适配器', navSort:'优先级', navKeys:'密钥',
keysTitle:'密钥管理', keysHint:'管理员密钥可查看与管理全部密钥,并可为每个用户密钥配置可用模型范围;用户密钥只能看到自己。',
kCreate:'新建密钥', kName:'名称', kRole:'角色', kRoleAdmin:'管理员', kRoleUser:'用户', kNote:'备注(可选)', kCreateBtn:'创建',
kKey:'密钥', kModels:'可用模型', kCreated:'创建时间', kActions:'操作', kCopy:'复制密钥', kDel:'删除',
kNewKey:'新密钥(请立即复制保存)', kNewOK:'密钥已创建', kScope:'配置模型范围', kScopeTitle:'配置模型范围 —— 拖拽排列、下拉添加、×删除、复制/粘贴到其它用户',
kScopeHint:'拖拽排序 · 下拉添加 · × 删除 · 复制/粘贴列表', kScopeSel:'选择模型…', kCopyList:'复制列表', kPasteList:'粘贴列表', kSaveScope:'保存',
kScopeSaved:'模型范围已保存', kScopeEmpty:'(全部模型,不限)', kScopeAll:'(空 = 不限)',
kMeTitle:'我的密钥', kMeRole:'角色', kMeModels:'我可用的模型', kMeHint:'密钥不可在此新建或删除;需要变更请联系管理员。',
kEmpty:'暂无其他密钥', kDelSelf:'不能删除当前登录所用密钥', kDelConfirm:'确定删除密钥 %s 吗?此后该密钥立即失效。',
kAll:'不限',
connTitle:'连接配置Agent / OpenAI SDK', connHint:'模型名默认 AUTO按优先级自动选择可用源点击任一模型可生成固定到该模型的配置。',
copyCfg:'一键复制配置', copyEnv:'复制为环境变量',
srcTitle:'源状态', srcCount:'共 %d 个',
@ -343,10 +411,25 @@ const STR = {
sortTitle:'画布排序:拖拽积木配置模型优先级', sortHint:'每行 = 一个优先级档位,行从上到下优先级递减;同一行的模型并排,视为同优先级。按住积木右侧 ⠿ 把手拖动:拖到行内 = 放入该档位(或调整同档顺序),拖到行与行之间的缝隙 = 提升/降低到新档位。生图模型kind=image不参与排序。', sortDragGrip:'拖拽前须按住把手',
sortSave:'保存排序', sortReset:'重置', sortSaved:'排序已保存并热重载', sortNoChange:'无变更',
sortSource:'源', sortPrio:'优先级 %s', sortEmpty:'(该源暂无模型)',
kpiActive:'活跃请求', kpiReqs:'总请求', kpiOk:'成功率', kpiTokens:'Tokens', kpiLat:'平均延迟', kpiMaxLat:'最大延迟',
dashModel:'模型用量', dashSrc:'源用量与延迟', dashKey:'密钥用量', dashRecs:'请求记录(审计)',
thModel:'模型', thSrc:'源', thKey:'密钥', thReqs:'请求', thOk:'成功', thErr:'失败',
thPrompt:'输入 Tokens', thCompl:'输出 Tokens', thAvgLat:'平均延迟', thMaxLat:'最长延迟',
thTime:'时间', thType:'类型', thStatus:'状态', thLatMs:'延迟',
allKeys:'全部密钥', recFilter:'密钥筛选', thTokens:'Tokens', noUsage:'暂无用量记录'
},
en: {
tagline:'Unified LLM Gateway', logout:'Log out', langTo:'中',
navStatus:'Status', navChat:'Chat', navSources:'Sources', navAdapters:'Adapters', navSort:'Priority',
navStatus:'Status', navChat:'Chat', navSources:'Sources', navAdapters:'Adapters', navSort:'Priority', navKeys:'Keys',
keysTitle:'Key management', keysHint:'Admin keys can view and manage every key and configure each user key\u0027s allowed models; user keys only see themselves.',
kCreate:'Create key', kName:'Name', kRole:'Role', kRoleAdmin:'Admin', kRoleUser:'User', kNote:'Note (optional)', kCreateBtn:'Create',
kKey:'Key', kModels:'Allowed models', kCreated:'Created', kActions:'Actions', kCopy:'Copy key', kDel:'Delete',
kNewKey:'New key (copy & save it now)', kNewOK:'Key created', kScope:'Model scope', kScopeTitle:'Model scope — drag to order, add from dropdown, × to remove, copy/paste the list to other users',
kScopeHint:'drag to order · add from dropdown · × to remove · copy/paste list', kScopeSel:'Pick a model…', kCopyList:'Copy list', kPasteList:'Paste list', kSaveScope:'Save',
kScopeSaved:'Model scope saved', kScopeEmpty:'(all models, unlimited)', kScopeAll:'(empty = unlimited)',
kMeTitle:'My key', kMeRole:'Role', kMeModels:'Models I can use', kMeHint:'Keys cannot be created or deleted here; contact an admin to change. ',
kEmpty:'No other keys', kDelSelf:'cannot delete the key you are logged in with', kDelConfirm:'Delete key %s? It will stop working immediately.',
kAll:'All',
connTitle:'Connection config (Agent / OpenAI SDK)', connHint:'Model defaults to AUTO — picks the best healthy source by priority. Click a model to pin it.',
copyCfg:'Copy config', copyEnv:'Copy as env vars',
srcTitle:'Sources', srcCount:'%d total',
@ -375,6 +458,12 @@ const STR = {
sortTitle:'Canvas sorting: drag blocks to set model priority', sortHint:'Each row = one priority tier, rows go high→low; models on the same row sit side by side and share that priority. Grab the ⠿ handle on the right of a block to drag: drop into a row = join that tier (or reorder within it), drop into the gap between rows = move up/down a tier. Image models (kind=image) stay out.', sortDragGrip:'grab the handle to drag',
sortSave:'Save order', sortReset:'Reset', sortSaved:'Order saved & hot-reloaded', sortNoChange:'No changes',
sortSource:'source', sortPrio:'priority %s', sortEmpty:'(no models in this source)',
kpiActive:'Active requests', kpiReqs:'Requests', kpiOk:'Success rate', kpiTokens:'Tokens', kpiLat:'Avg latency', kpiMaxLat:'Max latency',
dashModel:'Model usage', dashSrc:'Source usage & latency', dashKey:'Key usage', dashRecs:'Request records (audit)',
thModel:'Model', thSrc:'Source', thKey:'Key', thReqs:'Requests', thOk:'OK', thErr:'Err',
thPrompt:'Prompt Tokens', thCompl:'Completion Tokens', thAvgLat:'Avg latency', thMaxLat:'Max latency',
thTime:'Time', thType:'Type', thStatus:'Status', thLatMs:'Latency',
allKeys:'All keys', recFilter:'Key filter', thTokens:'Tokens', noUsage:'No usage data yet',
}
};
let LANG = localStorage.getItem('llms-proxy.lang') || ((navigator.language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en');
@ -410,7 +499,7 @@ document.getElementById('btn-logout').onclick = () => { location.href = '/api/lo
document.querySelectorAll('nav button').forEach(b => {
b.onclick = () => {
document.querySelectorAll('nav button').forEach(x => x.classList.toggle('active', x === b));
['status','chat','sort','sources','adapters'].forEach(tn => $('#tab-' + tn).classList.toggle('hidden', tn !== b.dataset.tab));
['status','chat','keys','sort','sources','adapters'].forEach(tn => $('#tab-' + tn).classList.toggle('hidden', tn !== b.dataset.tab));
lastTab = b.dataset.tab; refresh(lastTab);
};
});
@ -436,6 +525,16 @@ function copyText(txt, okMsg) {
}
/* ---------- status tab ---------- */
let statsTimerId = null;
const fmtN = n => (n ?? 0).toLocaleString();
const fmtMs = ms => (ms == null || ms < 0) ? '—' : (ms >= 1000 ? (ms / 1000).toFixed(1) + 's' : ms + 'ms');
const fmtLat = (sum, n) => (n > 0 ? Math.round(sum / n) : -1);
const fmtTime = ts => {
const d = new Date(ts);
const p = x => String(x).padStart(2, '0');
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
};
let statsKeyF = ''; // active key filter for records ('' = all)
async function renderStatus() {
const s = await api('/api/status');
const base = window._base = s.base_url || location.origin + '/v1';
@ -447,6 +546,7 @@ async function renderStatus() {
<td>${x.available ? `<span class="tag tag-green">${t('online')}</span>` : `<span class="tag tag-red">${t('offline')}</span>`}</td>
<td>${x.max_concurrent}</td></tr>`).join('');
$('#tab-status').innerHTML = `
<div class="kpis" id="kpi-row"></div>
<div class="card"><h2>${t('connTitle')}</h2>
<div class="muted">${t('connHint')}</div>
<pre class="configbox" id="conncfg"></pre>
@ -455,6 +555,18 @@ async function renderStatus() {
<label>${t('tModels')}</label>
<div class="chips" id="model-chips"></div>
</div>
<div class="dash-row">
<div class="card"><h2>${t('dashModel')}</h2><div id="tb-model"></div></div>
<div class="card"><h2>${t('dashSrc')}</h2><div id="tb-src"></div></div>
</div>
<div class="card"><h2>${t('dashKey')}</h2><div id="tb-key"></div></div>
<div class="card"><h2>${t('dashRecs')}</h2>
<div class="filter-line">
<span class="muted">${t('recFilter')}</span>
<select id="rec-key" onchange="renderKeyF(this.value)"></select>
</div>
<div class="recs-scroll" id="tb-recs"></div>
</div>
<div class="card"><h2>${t('srcTitle')} (${s.sources.length})</h2>
<table><tr><th>${t('tName')}</th><th>${t('tAdapter')}</th><th>${t('tModels')}</th><th>${t('tURL')}</th><th>${t('tConn')}</th><th>${t('tConc')}</th></tr>${srcRows || `<tr><td colspan="6" class="empty">${t('srcEmpty')}</td></tr>`}</table>
</div>
@ -465,6 +577,84 @@ async function renderStatus() {
$('#conncfg').textContent = '';
showModelConfig(null, '');
$('#model-chips').innerHTML = s.models.map(m => `<span class="tag tag-blue modelchip" onclick="showModelConfig(null,'${escAttr(m)}')">${esc(m)}</span>`).join('');
if (statsTimerId) clearInterval(statsTimerId);
await paintStats();
statsTimerId = setInterval(paintStats, 3000);
}
function renderKeySelect(keys) {
const sel = $('#rec-key');
if (!sel) return;
const cur = sel.value;
sel.innerHTML = `<option value="">${esc(t('allKeys'))}</option>` +
keys.map(k => `<option value="${escAttr(k)}">${esc(k)}</option>`).join('');
if (cur) sel.value = cur;
}
function renderKeyF(v) { statsKeyF = v; paintStats(); }
async function paintStats() {
try {
const q = '/api/stats?limit=500' + (statsKeyF ? '&key=' + encodeURIComponent(statsKeyF) : '');
const st = await api(q);
const tot = st.total || {};
const okr = tot.reqs ? Math.round(tot.ok * 100 / tot.reqs) : 0;
const avg = fmtLat(tot.latency_sum_ms, tot.reqs);
const kpi = $('#kpi-row');
if (kpi) kpi.innerHTML = `
<div class="kpi"><div class="k-lab">${t('kpiActive')} <span class="dot"></span></div><div class="k-val">${st.active || 0}</div><div class="k-sub">${statsKeyF ? esc(statsKeyF) : ''}</div></div>
<div class="kpi"><div class="k-lab">${t('kpiReqs')}</div><div class="k-val">${fmtN(tot.reqs)}</div><div class="k-sub">${t('kpiOk')} <b class="${okr >= 90 ? 'okc' : 'errc'}">${okr}%</b></div></div>
<div class="kpi"><div class="k-lab">${t('kpiTokens')}</div><div class="k-val">${fmtN(tot.tokens)}</div>
<div class="k-sub">${t('thPrompt')} ${fmtN(tot.prompt_tokens)} · ${t('thCompl')} ${fmtN(tot.completion_tokens)}</div></div>
<div class="kpi"><div class="k-lab">${t('kpiLat')}</div><div class="k-val">${fmtMs(avg)}</div><div class="k-sub">${t('kpiMaxLat')} ${fmtMs(tot.latency_max_ms)}</div></div>`;
paintModelTable(st.by_model || []);
paintSrcTable(st.by_source || []);
paintKeyTable(st.by_key || []);
paintRecords(st.records || []);
renderKeySelect((st.by_key || []).map(k => k.name));
} catch (e) { console.error('[stats]', e); }
}
function paintModelTable(rows) {
const el = $('#tb-model'); if (!el) return;
if (!rows.length) { el.innerHTML = `<div class="muted">${t('noUsage')}</div>`; return; }
el.innerHTML = `<div class="tbl-wrap"><table><tr><th>${t('thModel')}</th><th class="num">${t('thReqs')}</th><th class="num">${t('thOk')}</th><th class="num">${t('thErr')}</th>
<th class="num">${t('thPrompt')}</th><th class="num">${t('thCompl')}</th><th class="num">${t('thAvgLat')}</th><th class="num">${t('thMaxLat')}</th></tr>` +
rows.map(r => {
const maxReq = rows.reduce((m, x) => Math.max(m, x.reqs), 0);
const w = maxReq ? Math.max(6, r.reqs * 100 / maxReq) : 0;
return `<tr><td>${esc(r.name)}</td>
<td class="num"><span class="mini">${w.toFixed(0)}%</span><div class="bar"><i style="width:${w}%"></i></div>${fmtN(r.reqs)}</td>
<td class="num okc">${fmtN(r.ok)}</td><td class="num errc">${fmtN(r.err)}</td>
<td class="num">${fmtN(r.prompt_tokens)}</td><td class="num">${fmtN(r.completion_tokens)}</td>
<td class="num">${fmtMs(fmtLat(r.latency_sum_ms, r.reqs))}</td><td class="num">${fmtMs(r.latency_max_ms)}</td></tr>`;
}).join('') + '</table></div>';
}
function paintSrcTable(rows) {
const el = $('#tb-src'); if (!el) return;
if (!rows.length) { el.innerHTML = `<div class="muted">${t('noUsage')}</div>`; return; }
el.innerHTML = `<div class="tbl-wrap"><table><tr><th>${t('thSrc')}</th><th class="num">${t('thReqs')}</th><th class="num">${t('thOk')}</th><th class="num">${t('thErr')}</th>
<th class="num">${t('thTokens')}</th><th class="num">${t('thAvgLat')}</th><th class="num">${t('thMaxLat')}</th></tr>` +
rows.map(r => `<tr><td><b>${esc(r.name)}</b></td>
<td class="num">${fmtN(r.reqs)}</td><td class="num okc">${fmtN(r.ok)}</td><td class="num errc">${fmtN(r.err)}</td>
<td class="num">${fmtN(r.tokens)}</td>
<td class="num">${fmtMs(fmtLat(r.latency_sum_ms, r.reqs))}</td><td class="num">${fmtMs(r.latency_max_ms)}</td></tr>`).join('') + '</table></div>';
}
function paintKeyTable(rows) {
const el = $('#tb-key'); if (!el) return;
if (!rows.length) { el.innerHTML = `<div class="muted">${t('noUsage')}</div>`; return; }
el.innerHTML = `<div class="tbl-wrap"><table><tr><th>${t('thKey')}</th><th class="num">${t('thReqs')}</th><th class="num">${t('thOk')}</th><th class="num">${t('thErr')}</th>
<th class="num">${t('thTokens')}</th><th class="num">${t('thAvgLat')}</th></tr>` +
rows.map(r => `<tr><td><button class="ghost small" onclick="renderKeyF('${escAttr(r.name)}')">${esc(r.name)}</button></td>
<td class="num">${fmtN(r.reqs)}</td><td class="num okc">${fmtN(r.ok)}</td><td class="num errc">${fmtN(r.err)}</td>
<td class="num">${fmtN(r.tokens)}</td><td class="num">${fmtMs(fmtLat(r.latency_sum_ms, r.reqs))}</td></tr>`).join('') + '</table></div>';
}
function paintRecords(records) {
const el = $('#tb-recs'); if (!el) return;
if (!records.length) { el.innerHTML = `<div class="muted">${t('noUsage')}</div>`; return; }
el.innerHTML = `<table><tr><th>${t('thTime')}</th><th class="num">${t('thStatus')}</th><th>${t('thKey')}</th><th>${t('thType')}</th><th>${t('thModel')}</th><th>${t('thSrc')}</th>
<th class="num">${t('thPrompt')}</th><th class="num">${t('thCompl')}</th><th class="num">${t('thLatMs')}</th></tr>` +
records.slice().reverse().map(r => `<tr>
<td class="t-tag">${fmtTime(r.time)}</td>
<td class="num">${r.ok ? `<span class="tag tag-green">${r.status || 200}</span>` : `<span class="tag tag-red" title="${esc(r.error || '')}">${r.status || 500}</span>`}</td>
<td>${esc(r.key)}</td><td class="t-tag">${esc(r.type)}</td><td>${esc(r.model)}</td><td>${esc(r.source || '')}</td>
<td class="num">${fmtN(r.prompt_tokens)}</td><td class="num">${fmtN(r.completion_tokens)}</td><td class="num">${fmtMs(r.latency_ms)}</td></tr>`).join('') + '</table>';
}
function showModelConfig(srcName, model) {
const el = $('#conncfg'); if (!el) return;
@ -1247,14 +1437,206 @@ async function delAdapter(name) {
toast(t('toastDelOk')); renderAdapters();
}
/* ---------- keys tab ---------- */
let allModels = [];
let scopeDragEl = null;
function maskKey(k) { return k.length > 12 ? k.slice(0, 6) + '…' + k.slice(-6) : k; }
function fmtCreated(ts) { if (!ts) return '—'; const d = new Date(ts * 1000);
const p = x => String(x).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; }
function roleTag(r) { return r === 'admin'
? `<span class="tag-role admin">${t('kRoleAdmin')}</span>`
: `<span class="tag-role user">${t('kRoleUser')}</span>`; }
async function renderKeys() {
const me = await api('/api/keys/me');
window._me = me.key;
return me.key.role === 'admin' ? renderKeysAdmin(me.key) : renderKeysUser(me.key);
}
async function renderKeysUser(me) {
$('#tab-keys').innerHTML = `
<div class="card"><h2>${t('kMeTitle')}</h2>
<table><tr><th>${t('kName')}</th><th>${t('kMeRole')}</th><th>${t('kKey')}</th><th>${t('kMeModels')}</th></tr>
<tr><td><b>${esc(me.name || '—')}</b></td><td>${roleTag(me.role)}</td>
<td><span class="kr-key">${esc(me.key)}</span>
<button class="ghost small" onclick="copyText('${escAttr(me.key)}')">${t('kCopy')}</button></td>
<td>${(me.models && me.models.length)
? me.models.map(m => `<span class="tag tag-blue">${esc(m)}</span>`).join('')
: `<span class="scope-unlim">${t('kAll')}</span>`}</td></tr></table>
<div class="muted" style="margin-top:10px">${t('kMeHint')}</div>
</div>`;
}
async function renderKeysAdmin() {
let models = [];
try { models = (await api('/api/status')).models || []; } catch (e) {}
allModels = models;
$('#tab-keys').innerHTML = `
<div class="card"><h2>${t('kCreate')}</h2>
<div class="keys-create">
<label>${t('kName')}</label><input id="kc-name" placeholder="alice">
<label>${t('kRole')}</label>
<select id="kc-role"><option value="user">${t('kRoleUser')}</option><option value="admin">${t('kRoleAdmin')}</option></select>
<label>${t('kNote')}</label><input id="kc-note" placeholder="…">
<button onclick="createKey()">${t('kCreateBtn')}</button>
</div>
<div id="k-newbox"></div>
</div>
<div class="card"><h2>${t('keysTitle')}</h2><div class="muted">${t('keysHint')}</div><div id="k-list"></div></div>`;
loadKeys();
}
async function loadKeys() {
const el = $('#k-list'); if (!el) return;
const j = await api('/api/keys');
const ks = j.keys || [];
el.innerHTML = ks.length ? ks.map(k => keyRowHtml(k)).join('') : `<div class="muted">${t('kEmpty')}</div>`;
}
function keyRowHtml(k) {
return `
<div class="key-row">
<span class="kr-name">${esc(k.name || '—')}</span>
${roleTag(k.role)}
<span class="kr-key">${esc(maskKey(k.key))}</span>
<button class="ghost small" onclick="copyText('${escAttr(k.key)}')">${t('kCopy')}</button>
<span class="grow"></span>
<span class="muted">${t('kModels')}: ${(k.models && k.models.length) ? k.models.length : t('kAll')}</span>
<span class="muted">${fmtCreated(k.created_at)}</span>
<button class="ghost small" onclick="toggleScope('${escAttr(k.key)}')">${t('kScope')}</button>
<button class="ghost small errc" onclick="delKey('${escAttr(k.key)}','${escAttr(k.name || '')}')">${t('kDel')}</button>
</div>
<div id="scope-${escAttr(k.key)}" class="scope-box" style="display:none;padding-left:12px;padding-right:12px">
<div class="muted" style="font-size:12px;margin-bottom:8px">${t('kScopeTitle')}</div>
<div class="scope-chips">${(k.models && k.models.length)
? k.models.map(m => scopeChipHtml(k.key, m)).join('')
: `<span class="scope-unlim">${t('kScopeEmpty')}</span>`}</div>
<div style="display:flex;gap:8px;align-items:center;margin-top:10px;flex-wrap:wrap">
<select class="scope-add" id="add-${escAttr(k.key)}" onchange="scopeAdd('${escAttr(k.key)}', this)">
<option value="">${t('kScopeSel')}</option>
${allModels.map(m => `<option value="${escAttr(m)}">${esc(m)}</option>`).join('')}
</select>
<button class="ghost small" onclick="scopeCopy('${escAttr(k.key)}')">${t('kCopyList')}</button>
<button class="ghost small" onclick="scopePaste('${escAttr(k.key)}')">${t('kPasteList')}</button>
<span class="grow"></span>
<button class="small" onclick="saveScope('${escAttr(k.key)}')">${t('kSaveScope')}</button>
</div>
</div>`;
}
function scopeChipHtml(key, m) {
return `<span class="scope-chip" data-m="${escAttr(m)}" title="${t('kScopeHint')}">${esc(m)}<span class="rm" onclick="scopeRm('${escAttr(key)}', this)">×</span></span>`;
}
function toggleScope(key) {
const box = document.getElementById('scope-' + key);
if (!box) return;
box.style.display = box.style.display === 'none' ? 'block' : 'none';
if (box.style.display === 'block') scopeDragInit(key);
}
function scopeModels(key) {
const box = document.getElementById('scope-' + key);
if (!box) return [];
return [...box.querySelectorAll('.scope-chip')].map(x => x.dataset.m).filter(Boolean);
}
function scopeDragInit(key) {
const box = document.getElementById('scope-' + key);
if (!box) return;
box.querySelectorAll('.scope-chip').forEach(ch => {
ch.setAttribute('draggable', 'true');
ch.addEventListener('dragstart', e => { scopeDragEl = ch; ch.classList.add('drag-over'); e.dataTransfer.effectAllowed = 'move'; });
ch.addEventListener('dragend', () => { ch.classList.remove('drag-over'); scopeDragEl = null; });
ch.addEventListener('dragover', e => e.preventDefault());
ch.addEventListener('drop', e => {
e.preventDefault();
if (!scopeDragEl || scopeDragEl === ch) return;
const items = scopeModels(key);
const from = items.indexOf(scopeDragEl.dataset.m);
const to = items.indexOf(ch.dataset.m);
if (from < 0 || to < 0) return;
items.splice(to, 0, items.splice(from, 1)[0]);
const chips = box.querySelector('.scope-chips');
chips.innerHTML = items.map(m => scopeChipHtml(key, m)).join('');
scopeDragInit(key);
});
});
}
function scopeAdd(key, sel) {
const m = sel.value; sel.value = '';
if (!m) return;
const box = document.getElementById('scope-' + key); if (!box) return;
const chips = box.querySelector('.scope-chips');
if ([...chips.querySelectorAll('.scope-chip')].some(x => x.dataset.m === m)) return;
if (chips.querySelector('.scope-unlim')) chips.innerHTML = '';
chips.innerHTML += scopeChipHtml(key, m);
scopeDragInit(key);
}
function scopeRm(key, el) {
el.closest('.scope-chip').remove();
const box = document.getElementById('scope-' + key);
const chips = box ? box.querySelector('.scope-chips') : null;
if (chips && !chips.querySelector('.scope-chip')) chips.innerHTML = `<span class="scope-unlim">${t('kScopeEmpty')}</span>`;
}
function scopeCopy(key) { copyText(scopeModels(key).join('\n')); }
async function scopePaste(key) {
let txt = '';
try { txt = await navigator.clipboard.readText(); } catch (e) {}
if (!txt) txt = prompt(t('kPasteList'));
if (!txt) return;
const ms = txt.split(/[\n,;\s]+/).map(s => s.trim()).filter(Boolean);
const box = document.getElementById('scope-' + key); if (!box) return;
const chips = box.querySelector('.scope-chips');
chips.innerHTML = ms.map(m => scopeChipHtml(key, m)).join('');
scopeDragInit(key);
}
async function saveScope(key) {
const ms = scopeModels(key);
await api('/api/keys/' + encodeURIComponent(key), { method: 'PUT', body: JSON.stringify({ models: ms }) });
toast(t('kScopeSaved'));
loadKeys();
}
async function createKey() {
const name = $('#kc-name').value.trim();
if (!name) { toast(t('kName')); return; }
const j = await api('/api/keys', { method: 'POST', body: JSON.stringify({
name, role: $('#kc-role').value, note: $('#kc-note').value.trim(),
}) });
$('#kc-name').value = ''; $('#kc-note').value = '';
$('#k-newbox').innerHTML = `
<div class="key-row" style="margin-top:12px;background:var(--card2)">
<b>${t('kNewKey')}</b>
<span class="kr-key" style="max-width:none">${esc(j.key.key)}</span>
<button class="small" onclick="copyText('${escAttr(j.key.key)}')">${t('kCopy')}</button>
<span class="grow"></span>
<button class="ghost small" onclick="$('#k-newbox').innerHTML=''">×</button>
</div>`;
toast(t('kNewOK'));
loadKeys();
}
async function delKey(key, name) {
if (!confirm(tFmt('kDelConfirm', name || key))) return;
try {
await api('/api/keys/' + encodeURIComponent(key), { method: 'DELETE' });
toast(t('toastDelOk'));
loadKeys();
} catch (e) { toast(e.message); }
}
/* ---------- boot ---------- */
function refresh(tab) {
if (tab === 'status') return renderStatus();
if (tab === 'chat') return renderChat();
if (tab === 'keys') return renderKeys();
if (tab === 'sort') return renderSort();
if (tab === 'sources') return renderSources();
return renderAdapters();
}
(async () => {
try {
const me = await api('/api/keys/me');
window._me = me.key;
if (me.key.role !== 'admin') {
['sort', 'sources', 'adapters'].forEach(tn => {
const b = document.querySelector(`nav button[data-tab="${tn}"]`);
if (b) b.style.display = 'none';
});
}
} catch (e) {}
})();
refresh('status');
</script>
</body>

View File

@ -47,7 +47,10 @@ func FromRegistry(ps []*provider.Provider) []Provider {
// candidate receives a request pinned to its own model (ModelFor), so an AUTO
// chain fallback switches the model id per provider instead of reusing the
// first candidate's model name.
func (s *Scheduler) Chat(ctx context.Context, cands []Provider, req *types.ChatRequest) (*types.UnifiedResponse, error) {
//
// On success it returns the response together with the name of the provider
// and the exact model id that actually served the request (used for stats).
func (s *Scheduler) Chat(ctx context.Context, cands []Provider, req *types.ChatRequest) (*types.UnifiedResponse, string, string, error) {
attempts := s.MaxRetries + 1
var lastErr error
for i := 0; i < attempts && i < len(cands); i++ {
@ -56,27 +59,25 @@ func (s *Scheduler) Chat(ctx context.Context, cands []Provider, req *types.ChatR
r.Model = p.ModelFor(req.Model)
resp, err := p.Chat(ctx, &r)
if ctx.Err() != nil {
return nil, ctx.Err()
return nil, "", "", ctx.Err()
}
if err == nil {
return resp, nil
return resp, p.Name(), r.Model, nil
}
lastErr = fmt.Errorf("provider %s: %w", p.Name(), err)
}
if lastErr == nil {
// if loop couldn't run because cands was short but no error recorded yet
if len(cands) == 0 {
return nil, fmt.Errorf("no provider available")
lastErr = fmt.Errorf("no provider available")
}
// should not happen
return nil, lastErr
}
return nil, lastErr
return nil, "", "", lastErr
}
// ChatStream runs a streaming chat across cands, falling back early on connect
// errors. The request model is pinned per candidate like Chat.
func (s *Scheduler) ChatStream(ctx context.Context, cands []Provider, req *types.ChatRequest) (<-chan types.UnifiedChunk, error) {
// errors. The request model is pinned per candidate like Chat. On success it
// returns the chunk channel plus the serving provider name and model id.
func (s *Scheduler) ChatStream(ctx context.Context, cands []Provider, req *types.ChatRequest) (<-chan types.UnifiedChunk, string, string, error) {
attempts := s.MaxRetries + 1
var lastErr error
for i := 0; i < attempts && i < len(cands); i++ {
@ -85,32 +86,31 @@ func (s *Scheduler) ChatStream(ctx context.Context, cands []Provider, req *types
r.Model = p.ModelFor(req.Model)
resp, err := p.ChatStream(ctx, &r)
if err == nil {
return resp, nil
return resp, p.Name(), r.Model, nil
}
lastErr = fmt.Errorf("provider %s: %w", p.Name(), err)
}
if lastErr == nil {
if len(cands) == 0 {
return nil, fmt.Errorf("no provider available")
}
if lastErr == nil && len(cands) == 0 {
lastErr = fmt.Errorf("no provider available")
}
return nil, lastErr
return nil, "", "", lastErr
}
// Image runs an image-generation request across cands.
func (s *Scheduler) Image(ctx context.Context, cands []Provider, req *types.ImageGenRequest) (*types.UnifiedResponse, error) {
// Image runs an image-generation request across cands; returns the used
// provider name on success.
func (s *Scheduler) Image(ctx context.Context, cands []Provider, req *types.ImageGenRequest) (*types.UnifiedResponse, string, error) {
attempts := s.MaxRetries + 1
var lastErr error
for i := 0; i < attempts && i < len(cands); i++ {
p := cands[i]
resp, err := p.Image(ctx, req)
if err == nil {
return resp, nil
return resp, p.Name(), nil
}
lastErr = fmt.Errorf("provider %s: %w", p.Name(), err)
}
if lastErr == nil && len(cands) == 0 {
return nil, fmt.Errorf("no provider available")
lastErr = fmt.Errorf("no provider available")
}
return nil, lastErr
return nil, "", lastErr
}