mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 00:48:00 +00:00
feat(keys): role-based gateway keys with admin management UI and per-user model scope
This commit is contained in:
@ -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"`
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
@ -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 }
|
||||
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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
129
internal/gateway/keys.go
Normal 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
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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...),
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user