From 859d310ad327ad77f091b87b9b0bef97924b2cf0 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 9 Aug 2026 11:07:38 +0800 Subject: [PATCH] feat(auto): quota-aware AUTO slot scheduling (model tiers, hour/week/month resets) + key scope periods; fix key/slot create/delete UX --- internal/config/config.go | 56 ++- internal/config/store.go | 17 + internal/core/core.go | 64 +++- internal/gateway/chat.go | 332 +++++++++++++++++- internal/gateway/keys.go | 52 ++- internal/gateway/server.go | 3 + internal/gateway/stats.go | 92 +++++ internal/gateway/ui/index.html | 600 ++++++++++++++++++++++++++------- internal/provider/registry.go | 11 + 9 files changed, 1065 insertions(+), 162 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 8364adc..9023a12 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( + "encoding/json" "fmt" "os" "time" @@ -120,18 +121,55 @@ 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"` + Sources []Source `json:"sources"` + Keys []GWKey `json:"keys,omitempty"` + Auto []ModelScope `json:"auto,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). +// model scope with per-model token quota (0 = unlimited). 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"` + Key string `json:"key"` + Role string `json:"role"` + Name string `json:"name,omitempty"` + Models []ModelScope `json:"models,omitempty"` + Note string `json:"note,omitempty"` + CreatedAt int64 `json:"created_at,omitempty"` +} + +// ModelScope is one allowed model for a key, or one AUTO scheduling slot, +// with an optional token quota and reset period. TokenQuota 0 = unlimited; +// Period "" = never resets; "hour"/"week"/"month" are fixed windows; "nhour" +// uses Hours as the window length in hours. +type ModelScope struct { + Model string `json:"model"` + TokenQuota int64 `json:"token_quota"` + Period string `json:"period,omitempty"` + Hours int64 `json:"hours,omitempty"` +} + +// UnmarshalJSON accepts both the legacy "model-id" string form and the +// {"model":"...","token_quota":N} object form so old runtime files keep +// loading. +func (m *ModelScope) UnmarshalJSON(b []byte) error { + var s string + if err := json.Unmarshal(b, &s); err == nil { + m.Model = s + return nil + } + var o struct { + Model string `json:"model"` + TokenQuota int64 `json:"token_quota"` + Period string `json:"period"` + Hours int64 `json:"hours"` + } + if err := json.Unmarshal(b, &o); err != nil { + return err + } + m.Model = o.Model + m.TokenQuota = o.TokenQuota + m.Period = o.Period + m.Hours = o.Hours + return nil } diff --git a/internal/config/store.go b/internal/config/store.go index 46f1014..5e0fe1e 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -137,4 +137,21 @@ func (s *Store) DeleteKey(key string) (bool, error) { } s.data.Keys = kept return true, s.persistLocked() +} + +// AutoRules returns the persisted AUTO scheduling slots. +func (s *Store) AutoRules() []ModelScope { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]ModelScope, len(s.data.Auto)) + copy(out, s.data.Auto) + return out +} + +// SaveAutoRules persists the AUTO scheduling slots. +func (s *Store) SaveAutoRules(entries []ModelScope) error { + s.mu.Lock() + defer s.mu.Unlock() + s.data.Auto = entries + return s.persistLocked() } \ No newline at end of file diff --git a/internal/core/core.go b/internal/core/core.go index 7fbb3f4..607c934 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -9,6 +9,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "time" "llmsproxy/internal/config" @@ -50,12 +51,48 @@ func NewFromConfig(cfg *config.Config) (*Core, error) { if err := c.seedKeys(); err != nil { return nil, err } + if err := c.seedAuto(); err != nil { + return nil, err + } if err := c.rebuildRegistry(); err != nil { return nil, err } return c, nil } +// seedAuto migrates the legacy per-source model priority into flat AUTO +// scheduling slots (one slot per model, priority order) the first time no +// explicit auto rules exist. +func (c *Core) seedAuto() error { + if len(c.store.AutoRules()) > 0 { + return nil + } + type item struct { + model string + prio int + } + var flat []item + for _, s := range c.mergedSources() { + for _, m := range s.Models { + if m.Kind == "image" { + continue + } + flat = append(flat, item{m.ID, m.Priority}) + } + } + sort.SliceStable(flat, func(i, j int) bool { + if flat[i].prio != flat[j].prio { + return flat[i].prio > flat[j].prio + } + return flat[i].model < flat[j].model + }) + entries := make([]config.ModelScope, 0, len(flat)) + for _, it := range flat { + entries = append(entries, config.ModelScope{Model: it.model}) + } + return c.store.SaveAutoRules(entries) +} + // 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 { @@ -114,7 +151,7 @@ func (c *Core) ListKeys() []config.GWKey { return c.store.ListKeys() } 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) { +func (c *Core) CreateKey(name, role string, models []config.ModelScope, note string) (config.GWKey, error) { key := make([]byte, 16) if _, err := rand.Read(key); err != nil { return config.GWKey{}, err @@ -137,7 +174,7 @@ func (c *Core) CreateKey(name, role string, models []string, note string) (confi } // 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) { +func (c *Core) UpdateKey(key, name, role string, models []config.ModelScope, note string) (config.GWKey, error) { rec, ok := c.store.KeyByValue(key) if !ok { return config.GWKey{}, fmt.Errorf("key not found") @@ -159,6 +196,29 @@ func (c *Core) UpdateKey(key, name, role string, models []string, note string) ( // 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) } +// ---- AUTO scheduling slots (web UI canvas) ---- + +// AutoRules returns the AUTO scheduling slots in priority order (slot 0 = +// highest priority). +func (c *Core) AutoRules() []config.ModelScope { return c.store.AutoRules() } + +// SaveAutoRules persists the AUTO scheduling slots. +func (c *Core) SaveAutoRules(entries []config.ModelScope) error { + clean := make([]config.ModelScope, 0, len(entries)) + for _, e := range entries { + if e.Model == "" { + continue + } + clean = append(clean, e) + } + return c.store.SaveAutoRules(clean) +} + +// Registry resolves model -> owning provider. +func (c *Core) ProviderForModel(model string) *provider.Provider { + return c.registry.ProviderForModel(model) +} + // Config exposes the underlying configuration (read-only usage). func (c *Core) Config() *config.Config { return c.cfg } diff --git a/internal/gateway/chat.go b/internal/gateway/chat.go index c83464f..733ac2f 100644 --- a/internal/gateway/chat.go +++ b/internal/gateway/chat.go @@ -9,6 +9,7 @@ import ( "sync/atomic" "time" + "llmsproxy/internal/config" "llmsproxy/internal/provider" "llmsproxy/internal/scheduler" "llmsproxy/internal/types" @@ -106,11 +107,15 @@ func (g *Gateway) resolveCands(ctx context.Context, req *chatRequest) ([]*provid } // 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 { +// scope (used for user keys with a restricted model scope). An "AUTO" scope +// entry means the key is allowed to use any model. +func filterCandsByModels(cands []*provider.Provider, allow []config.ModelScope) []*provider.Provider { allowed := make(map[string]bool, len(allow)) for _, m := range allow { - allowed[m] = true + if m.Model == "" || strings.EqualFold(m.Model, "AUTO") { + return cands + } + allowed[m.Model] = true } out := make([]*provider.Provider, 0, len(cands)) for _, p := range cands { @@ -124,11 +129,20 @@ func filterCandsByModels(cands []*provider.Provider, allow []string) []*provider return out } -// intersectModels restricts a model list to the whitelist (preserving order). -func intersectModels(models, allow []string) []string { +// intersectModels restricts a model list to the scope (preserving order). An +// "AUTO" scope entry grants every model. +func intersectModels(models []string, allow []config.ModelScope) []string { allowed := make(map[string]bool, len(allow)) + any := false for _, m := range allow { - allowed[m] = true + if m.Model == "" || strings.EqualFold(m.Model, "AUTO") { + any = true + break + } + allowed[m.Model] = true + } + if any { + return models } out := make([]string, 0, len(models)) seen := map[string]bool{} @@ -141,6 +155,60 @@ func intersectModels(models, allow []string) []string { return out } +// checkModelScope validates the effective model against the key's model scope +// and token quota. Returns an error message when rejected. A scope entry with +// model "AUTO" grants all models; its quota caps the key's total tokens. +func (g *Gateway) checkModelScope(ctx context.Context, model string) string { + allow := g.allowedModels(ctx) + if allow == nil { + return "" + } + for _, sc := range allow { + if sc.Model != "" && strings.EqualFold(sc.Model, "AUTO") { + if sc.TokenQuota > 0 { + used := g.scopeTokens(ctx, sc) + if used >= sc.TokenQuota { + return fmt.Sprintf("token quota exceeded (%d/%d)", used, sc.TokenQuota) + } + } + return "" + } + } + for _, sc := range allow { + if sc.Model != model { + continue + } + if sc.TokenQuota > 0 { + used := g.scopeTokens(ctx, sc) + if used >= sc.TokenQuota { + return fmt.Sprintf("token quota exceeded for %q (%d/%d)", model, used, sc.TokenQuota) + } + } + return "" + } + return fmt.Sprintf("model %q is not allowed for this key", model) +} + +// scopeTokens returns the tokens a scope entry has consumed within its reset +// window (total for AUTO / per model otherwise). +func (g *Gateway) scopeTokens(ctx context.Context, sc config.ModelScope) int64 { + k := keyID(reqKey(ctx)) + if sc.Model != "" && strings.EqualFold(sc.Model, "AUTO") { + return g.stats.KeyTokens(k) + } + win := AutoPeriodSeconds(sc.Period, sc.Hours) + return g.stats.WindowTokens(sc.Model, win) +} + +func hasScopeModel(list []config.ModelScope, s string) bool { + for _, x := range list { + if x.Model == s || (x.Model != "" && strings.EqualFold(x.Model, "AUTO")) { + return true + } + } + return false +} + func (g *Gateway) resolveByModel(model string) ([]*provider.Provider, string) { if isAuto(model) { return g.core.Registry().Resolve("AUTO"), "" @@ -195,8 +263,44 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) { if model == "" { model = g.core.DefaultModel() } + if strings.EqualFold(strings.TrimSpace(req.Model), "AUTO") { + plans := g.autoPlans() + if len(plans) == 0 { + writeError(w, http.StatusServiceUnavailable, "no_provider", "no auto slot available (quota exhausted or none configured)") + return + } + if msg := g.checkModelScope(r.Context(), "AUTO"); msg != "" { + writeError(w, http.StatusForbidden, "model_not_allowed", msg) + return + } + ctx := r.Context() + done := g.stats.Begin() + defer done() + inner := &types.ChatRequest{ + Messages: req.Messages, + Temperature: req.Temperature, + MaxTokens: req.MaxTokens, + Stream: req.Stream, + Tools: req.Tools, + ToolChoice: req.ToolChoice, + DisableThinking: req.DisableThinking, + ExtraBody: req.ExtraBody, + } + rec := &Req{ + Key: keyID(reqKey(ctx)), + Type: "chat", + OK: false, + } + if req.Stream { + rec.Type = "stream" + g.streamChatAuto(w, ctx, plans, inner, rec) + return + } + g.singleChatAuto(w, ctx, plans, inner, rec) + return + } if !isAuto(model) { - if allow := g.allowedModels(r.Context()); allow != nil && !containsStr(allow, model) { + if allow := g.allowedModels(r.Context()); allow != nil && !hasScopeModel(allow, model) { writeError(w, http.StatusForbidden, "model_not_allowed", fmt.Sprintf("model %q is not allowed for this key", model)) return } @@ -209,6 +313,10 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) { if effective == "" { effective = firstModel(cands[0]) } + if msg := g.checkModelScope(r.Context(), effective); msg != "" { + writeError(w, http.StatusForbidden, "model_not_allowed", msg) + return + } ctx := r.Context() done := g.stats.Begin() defer done() @@ -300,13 +408,20 @@ func firstSource(cands []*provider.Provider) string { return "" } -func containsStr(list []string, s string) bool { - for _, x := range list { - if x == s { - return true +// effectiveImageModel picks the image model id that will actually be used so +// quota checks can target it (AUTO resolves to the first image candidate). +func effectiveImageModel(model string, cands []*provider.Provider) string { + if !isAuto(model) && model != "" { + return model + } + if len(cands) > 0 { + for _, id := range cands[0].Models() { + if m := cands[0].ModelByID(id); m != nil && m.Kind == "image" { + return id + } } } - return false + return model } func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands []*provider.Provider, req *types.ChatRequest, effective string, rec *Req) { @@ -441,6 +556,193 @@ func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands [ } } +// autoPlan is one schedulable AUTO slot: a model id pinned to its provider +// with an optional token quota window. Quota-exhausted slots are skipped. +type autoPlan struct { + p *provider.Provider + model string + quota int64 + win int64 +} + +// autoPlans builds the schedulable AUTO slots from the persisted rules. A +// slot is schedulable while its model is available and (when quota > 0) the +// tokens used within its reset window are below the quota. +func (g *Gateway) autoPlans() []autoPlan { + rules := g.core.AutoRules() + if len(rules) == 0 { + return nil + } + plans := make([]autoPlan, 0, len(rules)) + for _, e := range rules { + p := g.core.ProviderForModel(e.Model) + if p == nil { + continue + } + if m := p.ModelByID(e.Model); m != nil && m.Kind == "image" { + continue + } + win := AutoPeriodSeconds(e.Period, e.Hours) + if e.TokenQuota > 0 && g.stats.WindowTokens(e.Model, win) >= e.TokenQuota { + continue + } + plans = append(plans, autoPlan{p: p, model: e.Model, quota: e.TokenQuota, win: win}) + } + return plans +} + +// singleChatAuto runs a non-streaming AUTO request slot by slot: each slot +// pins its own model; a slot whose provider errors out is skipped. The first +// slot to answer wins; when every slot fails, the recorded error is from the +// last one. +func (g *Gateway) singleChatAuto(w http.ResponseWriter, ctx context.Context, plans []autoPlan, req *types.ChatRequest, rec *Req) { + rec.LatMs = 0 + t0 := time.Now() + var lastErr error + for _, pl := range plans { + if !pl.p.Available() { + continue + } + r := *req + r.Model = pl.model + resp, usedSrc, usedModel, err := g.core.Scheduler().Chat(ctx, scheduler.FromRegistry([]*provider.Provider{pl.p}), &r) + if err != nil { + lastErr = err + continue + } + rec.LatMs = time.Since(t0).Milliseconds() + rec.OK = true + rec.Status = http.StatusOK + rec.Prompt = int64(resp.TokenUsage.Prompt) + rec.Compl = int64(resp.TokenUsage.Completion) + rec.Source = usedSrc + rec.Model = usedModel + g.writeRec(rec) + msg := RespMessage{Role: "assistant", Content: resp.Content} + if resp.ReasoningContent != "" { + msg.ReasoningContent = resp.ReasoningContent + } + if len(resp.ToolCalls) > 0 { + msg.ToolCalls = toolCallsWire(resp.ToolCalls) + } + out := ChatCompletion{ + ID: newID(), + Object: "chat.completion", + Created: time.Now().Unix(), + Model: usedModel, + Choices: []ChatChoice{{Index: 0, Message: msg, FinishReason: resp.FinishReason}}, + } + if resp.TokenUsage.Total > 0 || resp.TokenUsage.Prompt > 0 || resp.TokenUsage.Completion > 0 { + out.Usage = &resp.TokenUsage + } + writeJSON(w, http.StatusOK, out) + return + } + rec.LatMs = time.Since(t0).Milliseconds() + if lastErr == nil { + lastErr = fmt.Errorf("no provider available") + } + rec.OK = false + rec.Status = http.StatusBadGateway + rec.Err = lastErr.Error() + g.writeRec(rec) + writeError(w, http.StatusBadGateway, "upstream_error", lastErr.Error()) +} + +// streamChatAuto streams an AUTO request. It stays pinned to the first slot +// whose stream begins; a slot that fails to connect is skipped. +func (g *Gateway) streamChatAuto(w http.ResponseWriter, ctx context.Context, plans []autoPlan, req *types.ChatRequest, rec *Req) { + rec.LatMs = 0 + t0 := time.Now() + rec.OK = true + rec.Status = http.StatusOK + defer func() { + rec.LatMs = time.Since(t0).Milliseconds() + g.writeRec(rec) + }() + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + id := newID() + created := time.Now().Unix() + send := func(obj interface{}) bool { + b, err := json.Marshal(obj) + if err != nil { + return false + } + if _, err := fmt.Fprintf(w, "data: %s\n\n", b); err != nil { + return false + } + if flusher != nil { + flusher.Flush() + } + return true + } + if !send(ChatChunk{ + ID: id, Object: "chat.completion.chunk", Created: created, Model: "auto", + Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{Role: "assistant"}}}, + }) { + return + } + var lastErr error + for _, pl := range plans { + if !pl.p.Available() { + continue + } + r := *req + r.Model = pl.model + chunks, usedSrc, usedModel, err := g.core.Scheduler().ChatStream(ctx, scheduler.FromRegistry([]*provider.Provider{pl.p}), &r) + if err != nil { + lastErr = err + continue + } + if usedModel != "" { + rec.Model = usedModel + rec.Source = usedSrc + } + for ck := range chunks { + chunk := ChatChunk{ + ID: id, Object: "chat.completion.chunk", Created: created, Model: rec.Model, + } + delta := RespMessage{Role: "assistant", Content: ck.Content} + if ck.ReasoningContent != "" { + delta.ReasoningContent = ck.ReasoningContent + } + if len(ck.ToolCalls) > 0 { + delta.ToolCalls = ck.ToolCalls + } + choice := ChunkChoice{Index: 0, Delta: delta} + if ck.Done { + stop := "stop" + choice.FinishReason = &stop + } + chunk.Choices = []ChunkChoice{choice} + rec.Compl += int64(len(ck.Content)+len(ck.ReasoningContent)) / 3 + if !send(chunk) { + return + } + } + return + } + if lastErr == nil { + lastErr = fmt.Errorf("no provider available") + } + rec.OK = false + rec.Status = http.StatusBadGateway + rec.Err = lastErr.Error() + stop := "stop" + send(ChatChunk{ + ID: id, Object: "chat.completion.chunk", Created: created, Model: "auto", + Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{}, FinishReason: &stop}}, + }) + fmt.Fprintf(w, "data: [DONE]\n\n") + if flusher != nil { + flusher.Flush() + } +} + func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST") @@ -460,7 +762,7 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) { model = g.core.DefaultModel() } if !isAuto(model) { - if allow := g.allowedModels(r.Context()); allow != nil && !containsStr(allow, model) { + if allow := g.allowedModels(r.Context()); allow != nil && !hasScopeModel(allow, model) { writeError(w, http.StatusForbidden, "model_not_allowed", fmt.Sprintf("model %q is not allowed for this key", model)) return } @@ -474,6 +776,10 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusServiceUnavailable, "no_provider", "no image source configured") return } + if msg := g.checkModelScope(r.Context(), effectiveImageModel(model, cands)); msg != "" { + writeError(w, http.StatusForbidden, "model_not_allowed", msg) + return + } done := g.stats.Begin() defer done() rec := &Req{Key: keyID(reqKey(r.Context())), Type: "image", Model: model, Source: firstSource(cands), OK: false} diff --git a/internal/gateway/keys.go b/internal/gateway/keys.go index 2a4d4e2..3b7bcd5 100644 --- a/internal/gateway/keys.go +++ b/internal/gateway/keys.go @@ -5,6 +5,8 @@ import ( "encoding/json" "net/http" "strings" + + "llmsproxy/internal/config" ) // handleKeysAPI manages gateway keys: GET /api/keys (admin: all keys), @@ -33,10 +35,10 @@ func (g *Gateway) handleKeysAPI(w http.ResponseWriter, r *http.Request) { 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"` + Name string `json:"name"` + Role string `json:"role"` + Models []config.ModelScope `json:"models"` + Note string `json:"note"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error()) @@ -61,10 +63,10 @@ func (g *Gateway) handleKeysAPI(w http.ResponseWriter, r *http.Request) { return } var body struct { - Name string `json:"name"` - Role string `json:"role"` - Models []string `json:"models"` - Note string `json:"note"` + Name string `json:"name"` + Role string `json:"role"` + Models []config.ModelScope `json:"models"` + Note string `json:"note"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error()) @@ -115,9 +117,9 @@ func (g *Gateway) handleKeyMe(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]interface{}{"key": rec}) } -// allowedModels returns the model whitelist for the request's key; nil means +// allowedModels returns the model scope for the request's key; nil means // unrestricted (admin keys and user keys without an explicit scope). -func (g *Gateway) allowedModels(ctx context.Context) []string { +func (g *Gateway) allowedModels(ctx context.Context) []config.ModelScope { if reqRole(ctx) == "admin" { return nil } @@ -126,4 +128,34 @@ func (g *Gateway) allowedModels(ctx context.Context) []string { return nil } return rec.Models +} + +// handleAutoAPI manages the AUTO scheduling slots: GET /api/auto returns the +// current rules; PUT /api/auto replaces them (admin only). +func (g *Gateway) handleAutoAPI(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + writeJSON(w, http.StatusOK, map[string]interface{}{"rules": g.core.AutoRules()}) + return + } + if reqRole(r.Context()) != "admin" { + writeError(w, http.StatusForbidden, "forbidden", "admin role required") + return + } + switch r.Method { + case http.MethodPut, http.MethodPost: + var body struct { + Rules []config.ModelScope `json:"rules"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error()) + return + } + if err := g.core.SaveAutoRules(body.Rules); err != nil { + writeError(w, http.StatusBadRequest, "auto_error", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "rules": g.core.AutoRules()}) + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "") + } } \ No newline at end of file diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 1f8706f..957c7bf 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -72,6 +72,8 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) { 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 == "/api/auto": + g.handleAutoAPI(w, r) case r.URL.Path == "/login": g.handleLogin(w, r) case r.URL.Path == "/api/login": @@ -92,6 +94,7 @@ func (g *Gateway) serveUI(w http.ResponseWriter, r *http.Request) { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") w.Write(data) return } diff --git a/internal/gateway/stats.go b/internal/gateway/stats.go index 75d7d0a..9187376 100644 --- a/internal/gateway/stats.go +++ b/internal/gateway/stats.go @@ -2,6 +2,7 @@ package gateway import ( "sync" + "time" ) // Req is one recorded gateway request (audit trail + per-key/per-model stats). @@ -54,8 +55,11 @@ type Stats struct { byKeySrc map[string]map[string]*Stat recs []Req maxRecs int + modelHour map[string]map[int64]int64 // model -> unix-hour bucket -> tokens } +const hourSec = 3600 + func NewStats(maxRecords int) *Stats { if maxRecords <= 0 { maxRecords = 3000 @@ -66,6 +70,7 @@ func NewStats(maxRecords int) *Stats { bySrc: map[string]*Stat{}, byKeyModel: map[string]map[string]*Stat{}, byKeySrc: map[string]map[string]*Stat{}, + modelHour: map[string]map[int64]int64{}, maxRecs: maxRecords, } } @@ -123,12 +128,99 @@ func (s *Stats) Record(r Req) { s.byKeySrc[r.Key] = ks } inc(ks, r.Source, r) + // window bucket for quota enforcement (per model, per unix hour) + tok := r.Prompt + r.Compl + if tok > 0 && r.Model != "" { + h := r.Time / hourSec + hm := s.modelHour[r.Model] + if hm == nil { + hm = map[int64]int64{} + s.modelHour[r.Model] = hm + } + hm[h] += tok + if len(hm) > 24*40 { + for k := range hm { + if k < h-24*40 { + delete(hm, k) + } + } + } + } s.recs = append(s.recs, r) if len(s.recs) > s.maxRecs { s.recs = s.recs[len(s.recs)-s.maxRecs:] } } +// ModelTokens returns the tokens consumed per model for one gateway key id +// (used for per-model token quota enforcement). +func (s *Stats) ModelTokens(key string) map[string]int64 { + s.mu.Lock() + defer s.mu.Unlock() + out := map[string]int64{} + for k, v := range s.byKeyModel[key] { + out[k] = v.Tokens + } + return out +} + +// KeyTokens returns the total tokens consumed by one gateway key id. +func (s *Stats) KeyTokens(key string) int64 { + s.mu.Lock() + defer s.mu.Unlock() + a := s.byKey[key] + if a == nil { + return 0 + } + return a.Tokens +} + +// AutoPeriodSeconds maps a quota reset period to its window length in +// seconds. "" → 0 (never resets); "hour" → 1h; "week" → 7d; "month" → 30d; +// "nhour" → Hours (>=1) hours. +func AutoPeriodSeconds(period string, hours int64) int64 { + switch period { + case "hour": + return hourSec + case "week": + return 7 * 24 * hourSec + case "month": + return 30 * 24 * hourSec + case "nhour": + if hours < 1 { + hours = 1 + } + return hours * hourSec + } + return 0 +} + +// WindowTokens returns the tokens billed for the model within the last `sec` +// seconds (0 = since forever). +func (s *Stats) WindowTokens(model string, sec int64) int64 { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now().Unix() + hm := s.modelHour[model] + if len(hm) == 0 { + return 0 + } + var total int64 + if sec <= 0 { + for _, v := range hm { + total += v + } + return total + } + cut := now - sec + for h, v := range hm { + if h*hourSec >= cut { + total += v + } + } + return total +} + func rows(m map[string]*Stat) []StatsRow { out := make([]StatsRow, 0, len(m)) for k, v := range m { diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index efd9ad4..8f0758a 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -147,6 +147,37 @@ td.t-tag { white-space:nowrap; } .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; } } + +/* key canvases — scratch-style model bricks per key */ +.key-actions { display:flex; align-items:center; gap:8px; margin-bottom:12px; } +.key-canvas { border:1px solid var(--line); border-radius:14px; padding:14px; margin-bottom:14px; background:var(--card); } +.kc-head { display:flex; align-items:center; gap:10px; flex-wrap:wrap; } +.kc-blocks { display:flex; flex-wrap:wrap; gap:10px; align-items:center; margin-top:12px; + background:var(--card2); border:1px dashed var(--line); border-radius:12px; padding:14px; min-height:64px; } +.kc-blocks.ovh { outline:2px dashed var(--accent); outline-offset:2px; } +.mb { display:inline-flex; align-items:center; gap:8px; padding:9px 13px; border-radius:12px; + background:linear-gradient(135deg,#41a3ff,#3f6ef5); color:#fff; font-size:13px; font-weight:700; + cursor:grab; user-select:none; box-shadow:0 3px 10px rgba(63,110,245,.30); } +.mb:active { cursor:grabbing; } +.mb .mb-ico { font-size:11px; opacity:.85; } +.mb .mb-name { font-family:ui-monospace,Menlo,Consolas,monospace; white-space:nowrap; } +.mb .mb-quota { font-size:11px; font-weight:600; background:rgba(255,255,255,.18); padding:2px 8px; + border-radius:99px; white-space:nowrap; } +.mb .copy-b { cursor:pointer; opacity:.8; font-size:12px; padding:0 3px; } +.mb .copy-b:hover { opacity:1; } +.mb-ghost { position:fixed; pointer-events:none; opacity:.85; z-index:60; transform:rotate(2deg); } +.mb-empty { color:var(--muted); font-size:12.5px; } +.add-brick { display:inline-flex; align-items:center; gap:6px; padding:9px 16px; border-radius:12px; + border:1.5px dashed var(--accent); background:transparent; color:var(--accent); font-weight:700; + font-size:12.5px; cursor:pointer; font-family:inherit; } +.add-brick:hover { background:rgba(63,110,245,.08); } +#ctx-wrap { position:fixed; z-index:70; background:var(--card); border:1px solid var(--line); border-radius:10px; + box-shadow:0 10px 30px rgba(15,22,44,.18); padding:5px; min-width:130px; } +.ctx-itm { display:flex; align-items:center; gap:8px; padding:8px 12px; border-radius:7px; cursor:pointer; + font-size:13px; font-weight:600; } +.ctx-itm:hover { background:var(--card2); } +.ctx-itm.danger { color:var(--err); } +.ctx-itm .ic { width:15px; text-align:center; } .filter-line { display:flex; align-items:center; gap:8px; margin-bottom:10px; } .filter-line select { width:auto; margin:0; padding:6px 10px; } @@ -260,6 +291,9 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho background:rgba(0,0,0,.2); } .scr-block .scr-name { white-space:nowrap; font-family:ui-monospace,Menlo,Consolas,monospace; font-size:12px; max-width:200px; overflow:hidden; text-overflow:ellipsis; } +.scr-block .scr-tag { flex:0 0 auto; font-family:ui-monospace,Menlo,Consolas,monospace; font-size:10.5px; + padding:2px 7px; border-radius:9px; background:rgba(0,0,0,.24); color:#ffe9a8; + border:1px solid rgba(255,220,130,.35); cursor:pointer; } .scr-block .scr-grip { flex:0 0 auto; display:flex; flex-direction:column; gap:2px; padding:6px 4px; margin-left:2px; border-radius:6px; cursor:grab; background:rgba(255,255,255,.18); box-shadow:inset 0 1px 2px rgba(0,0,0,.18); transition:background .12s; touch-action:none; } @@ -383,6 +417,12 @@ const STR = { kMeTitle:'我的密钥', kMeRole:'角色', kMeModels:'我可用的模型', kMeHint:'密钥不可在此新建或删除;需要变更请联系管理员。', kEmpty:'暂无其他密钥', kDelSelf:'不能删除当前登录所用密钥', kDelConfirm:'确定删除密钥 %s 吗?此后该密钥立即失效。', kAll:'不限', + kBrickH:'点击编辑模型与配额 · 右键更多操作 · 拖动可跨密钥移动', + kCopyB:'复制该模型', kEditB:'编辑', kDelB:'删除', + kFormTitle:'模型与 Token 配额', kModelB:'模型', kQuotaB:'Token 配额', kQuotaHintB:'0 / 留空 = 无限', + kPeriodB:'重置周期', kPerNothing:'不限(永不过期)', kPerHour:'每 小时', kPerWeek:'每 周', kPerMonth:'每 月', kPerHours:'每 N 小时', kPerNHint:'小时数', + kEditQ:'编辑配额', kClearQ:'清空配额', kDupB:'复制档位', kDelB2:'删除(从链中移除)', + kDupOK:'已复制该模型', kMovOK:'已移动', kSaved:'已保存', kEmptyB:'(暂无模型 —— 点击 + 添加)', kAddB:'添加模型', connTitle:'连接配置(Agent / OpenAI SDK)', connHint:'模型名默认 AUTO,按优先级自动选择可用源;点击任一模型可生成固定到该模型的配置。', copyCfg:'一键复制配置', copyEnv:'复制为环境变量', srcTitle:'源状态', srcCount:'共 %d 个', @@ -409,7 +449,7 @@ const STR = { u:'用户', a:'助手', at:'助手[思考]', aEmpty:'(空回复)', aErr:'请求失败: %s', cErr:'错误: %s', chatMeta:'模型=%s · 耗时 %s ms · %d 字符', connPinned:'# 固定到模型: %s (源 %s)', connAuto:'# 模型名 AUTO(自动选择可用源)', sortTitle:'画布排序:拖拽积木配置模型优先级', sortHint:'每行 = 一个优先级档位,行从上到下优先级递减;同一行的模型并排,视为同优先级。按住积木右侧 ⠿ 把手拖动:拖到行内 = 放入该档位(或调整同档顺序),拖到行与行之间的缝隙 = 提升/降低到新档位。生图模型(kind=image)不参与排序。', sortDragGrip:'拖拽前须按住把手', - sortSave:'保存排序', sortReset:'重置', sortSaved:'排序已保存并热重载', sortNoChange:'无变更', + sortSave:'保存排序', sortReset:'重置', sortAdd:'添加档位', sortSaved:'排序已保存并热重载', sortNoChange:'无变更', sortHintSave:'点击保存排序后生效', sortSource:'源', sortPrio:'优先级 %s', sortEmpty:'(该源暂无模型)', kpiActive:'活跃请求', kpiReqs:'总请求', kpiOk:'成功率', kpiTokens:'Tokens', kpiLat:'平均延迟', kpiMaxLat:'最大延迟', dashModel:'模型用量', dashSrc:'源用量与延迟', dashKey:'密钥用量', dashRecs:'请求记录(审计)', @@ -427,9 +467,15 @@ const STR = { 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.', +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', + kBrickH:'Click to edit model & quota · right-click for more · drag to move to another key', + kCopyB:'Copy', kEditB:'Edit', kDelB:'Delete', + kFormTitle:'Model & token quota', kModelB:'Model', kQuotaB:'Token quota', kQuotaHintB:'0 / empty = unlimited', + kPeriodB:'Reset period', kPerNothing:'Never', kPerHour:'Every hour', kPerWeek:'Every week', kPerMonth:'Every month', kPerHours:'Every N hours', kPerNHint:'hours', + kEditQ:'Edit quota', kClearQ:'Clear quota', kDupB:'Duplicate slot', kDelB2:'Delete (remove from chain)', + kDupOK:'Copied', kMovOK:'Moved', kSaved:'Saved', kEmptyB:'(no models yet — click + to add)', kAddB:'Add model', 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', @@ -456,7 +502,7 @@ const STR = { u:'You:', a:'Assistant:', at:'Assistant [thinking]', aEmpty:'(empty)', aErr:'Request failed: %s', cErr:'Error: %s', chatMeta:'Model=%s · %s ms · %d chars', connPinned:'# Pinned to model: %s (source %s)', connAuto:'# Model "AUTO" picks healthy source by priority', 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', + sortSave:'Save order', sortReset:'Reset', sortAdd:'Add slot', sortSaved:'Order saved & hot-reloaded', sortNoChange:'No changes', sortHintSave:'Click Save for it to take effect', 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)', @@ -1006,6 +1052,8 @@ const SORT_COLORS = [ ['#a466e8', '#8b4dd4'], ['#e86f9c', '#d74f86'], ['#45c4c0', '#2aa8a4'], ['#e8564a', '#d03f34'], ['#8ba3c7', '#6f8ab3'], ]; +let sortUid = 0; +function nexUid() { return 's' + (++sortUid); } function srcColor(name) { let h = 0; for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0; return SORT_COLORS[h % SORT_COLORS.length]; @@ -1023,11 +1071,36 @@ async function renderSort() { })); sortState.lanes = [...map.entries()].sort((a, b) => b[0] - a[0]).map(([prio, models]) => { models.sort((x, y) => x.src < y.src ? -1 : x.src > y.src ? 1 : 0); + models.forEach(m => m.uid = nexUid()); return { prio, models }; }); + // attach per-block quota meta from the AUTO rules (rules are ordered exactly + // like the chain; each block of a model instance gets its own rule) + let autoR = []; + try { autoR = (await api('/api/auto')).rules || []; } catch (e) {} + const un = autoR.slice(); + sortState.lanes.forEach(lane => lane.models.forEach(m => { + const ri = un.findIndex(r => r.model === m.id); + if (ri >= 0) { + const r = un.splice(ri, 1)[0]; + m.meta = { quota: r.token_quota || 0, period: r.period || '', hours: r.hours || 0 }; + } else m.meta = null; + })); + // orphan slots: rules left over after attaching one rule per source block + // (a model may legitimately appear twice in the chain -> its 2nd..nth rules + // become extra slots, preserving the multi-tier schedule) + un.forEach(r => { + sortState.lanes.push({ prio: 0, models: [{ src: '*', id: r.model, uid: nexUid(), + meta: { quota: r.token_quota || 0, period: r.period || '', hours: r.hours || 0 } }] }); + }); + // model picker for the "add slot" control + let addModels = []; + try { addModels = (await api('/api/status')).models || []; } catch (e) {} + allModels = addModels; sortState.origin = JSON.stringify(sortState.lanes); $('#tab-sort').innerHTML = `

${t('sortTitle')} +

@@ -1039,24 +1112,30 @@ async function renderSort() {
`; paintSort(); } -function scrBlockHtml(it, isFirst) { +function scrBlockHtml(it, isFirst, li, ji) { const c = srcColor(it.src); + const s = srcShort(it.src); return ` -
+
${isFirst ? '' : ''} - ${esc(srcShort(it.src))} + ${esc(it.src === '*' ? '+' : s)} ${esc(it.id)} + ${it.meta ? `${esc(quantBadge(it.meta.quota, it.meta.period, it.meta.hours))}` : ''} + ×
`; } +function findSlots(li, ji) { return sortState.lanes[li].models[ji]; } function paintSort(affected) { const cv = $('#scr-canvas'); if (!cv) return; // compute the next lane-leaders so we can retract the outgoing ones first const nextFirst = new Set(); sortState.lanes.forEach(lane => { - if (lane.models.length) nextFirst.add(lane.models[0].src + '|' + lane.models[0].id); + if (lane.models.length) nextFirst.add(lane.models[0].uid); }); // outgoing leaders: currently first in their row but no longer first post-move const outgoing = []; @@ -1106,7 +1185,7 @@ function paintSortNow(affected) { html.push(`
`); html.push(`
${Array.from({ length: n }, () => '').join('')}
-
${lane.models.map((m, i) => scrBlockHtml(m, i === 0)).join('')}
+
${lane.models.map((m, i) => scrBlockHtml(m, i === 0, li, i)).join('')}
`); }); html.push(`
`); @@ -1285,13 +1364,13 @@ function onSortUp(e) { let minLi = item.li, maxLi = item.li; if (d.gap !== undefined) { // move to a new lane positioned at that gap - sortState.lanes[item.li].models.splice(item.idx, 1); + const m = sortState.lanes[item.li].models.splice(item.idx, 1)[0]; let dst = d.gap; if (!sortState.lanes[item.li].models.length) { sortState.lanes.splice(item.li, 1); if (item.li < dst) dst -= 1; } - sortState.lanes.splice(dst, 0, { models: [{ src: item.src, id: item.id }] }); + sortState.lanes.splice(dst, 0, { models: [m] }); minLi = Math.min(item.li, dst); maxLi = Math.max(item.li, dst); } else if (typeof d.ins === 'number') { @@ -1316,10 +1395,10 @@ function onSortUp(e) { sortState.drag = null; paintSort(affected); } -function findLaneItem(key) { +function findLaneItem(uid) { for (let i = 0; i < sortState.lanes.length; i++) { - const idx = sortState.lanes[i].models.findIndex(m => m.src + '|' + m.id === key); - if (idx >= 0) return { li: i, idx, src: sortState.lanes[i].models[idx].src, id: sortState.lanes[i].models[idx].id }; + const idx = sortState.lanes[i].models.findIndex(m => m.uid === uid); + if (idx >= 0) return { li: i, idx, it: sortState.lanes[i].models[idx] }; } return null; } @@ -1378,6 +1457,141 @@ async function saveSort() { } toast(dirty ? t('sortSaved') : t('sortNoChange')); sortState.origin = JSON.stringify(sortState.lanes); + try { await persistAuto(); } catch (e) { toast(e.message); } +} + +/* ---------- AUTO quota editing on sort blocks ---------- */ +async function persistAuto() { + const rules = []; + sortState.lanes.forEach(lane => lane.models.forEach(it => { + const m = it.meta; + rules.push({ + model: it.id, + token_quota: m && m.quota ? +m.quota : 0, + period: m && m.period ? m.period : '', + hours: m && m.hours ? +m.hours : 0, + }); + })); + await api('/api/auto', { method: 'PUT', body: JSON.stringify({ rules }) }); +} +function scrAddModal() { + const cur = { quota: 0, period: '', hours: 0 }; + const wrap = document.createElement('div'); wrap.id = 'modal-wrap'; + wrap.style.cssText = 'position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50'; + wrap.innerHTML = `

${t('sortAdd')}

+ + + + + + + +

+

+
`; + document.body.appendChild(wrap); + $('#a-period').addEventListener('change', () => { + $('#a-hours').closest('#a-hours-box').style.display = + $('#a-period').value === 'nhour' ? 'block' : 'none'; + }); + $('#a-model').focus(); +} +function scrAddFromForm() { + const model = $('#a-model').value.trim(); + if (!model) { toast(t('kName')); return; } + let q = parseInt($('#a-quota').value); + if (isNaN(q) || q < 0) q = 0; + const p = $('#a-period').value; + let h = parseInt($('#a-hours').value); + if (isNaN(h) || h < 1) h = 1; + sortState.lanes.push({ models: [{ src: '*', id: model, uid: nexUid(), meta: { quota: q, period: p, hours: (p || q) ? h : 0 } }] }); + const li = sortState.lanes.length - 1; + const w = $('#modal-wrap'); if (w) w.remove(); + paintSort([li]); + persistAuto().then(() => toast(t('sortHintSave'))).catch(e => toast(e.message)); +} +function sortScopeSave(li, ji) { + const it = sortState.lanes[li].models[ji]; + if (!it) return; + let q = parseInt($('#q-quota').value); + if (isNaN(q) || q < 0) q = 0; + const p = $('#q-period').value; + let h = parseInt($('#q-hours').value); + if (isNaN(h) || h < 1) h = 1; + it.meta = { quota: q, period: p, hours: (p || q) ? h : 0 }; + const w = $('#modal-wrap'); if (w) w.remove(); + paintSort([li]); + persistAuto().then(() => toast(t('sortHintSave'))).catch(e => toast(e.message)); +} +function scrCtx(e, li, ji) { + if (e.button === 2) { + sortBlockCtx(e, li, ji); + return; + } + e.stopPropagation(); + sortScopeEdit(li, ji); +} +function sortScopeEdit(li, ji) { + const it = sortState.lanes[li].models[ji]; + if (!it) return; + const cur = it.meta || { quota: 0, period: '', hours: 0 }; + const wrap = document.createElement('div'); wrap.id = 'modal-wrap'; + wrap.style.cssText = 'position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50'; + wrap.innerHTML = `

${t('kFormTitle')} · ${esc(it.id)}

+ + + + + +

+

+
`; + document.body.appendChild(wrap); + $('#q-period').addEventListener('change', () => { + $('#q-hours').closest('#q-hours-box').style.display = + $('#q-period').value === 'nhour' ? 'block' : 'none'; + }); +} +function sortBlockCtx(e, li, ji) { + e.preventDefault(); e.stopPropagation(); + const it = sortState.lanes[li].models[ji]; + if (!it) return; + const items = [ + { label: t('kEditQ'), icon: '✎', fn: () => sortScopeEdit(li, ji) }, + { label: t('kDupB'), icon: '⧉', fn: () => { + sortState.lanes[li].models.splice(ji + 1, 0, { src: it.src, id: it.id, uid: nexUid(), meta: it.meta ? { ...it.meta } : null }); + paintSort([li]); + persistAuto().then(() => toast(t('sortHintSave'))).catch(e => toast(e.message)); + } }, + ]; + if (it.meta) items.push({ + label: t('kClearQ'), icon: '♾', fn: () => { + it.meta = null; paintSort([li]); + persistAuto().then(() => toast(t('sortHintSave'))).catch(e => toast(e.message)); + } }); + items.push({ label: t('kDelB2'), icon: '✕', danger: true, fn: () => scrDelSlot(li, ji) }); + showCtx(e.clientX, e.clientY, items); +} +function scrDelSlot(li, ji) { + sortState.lanes[li].models.splice(ji, 1); + if (!sortState.lanes[li].models.length && sortState.lanes.length > 1) sortState.lanes.splice(li, 1); + paintSort([li]); + persistAuto().then(() => toast(t('sortHintSave'))).catch(e => toast(e.message)); } /* ---------- adapters tab ---------- */ @@ -1460,7 +1674,7 @@ async function renderKeysUser(me) { ${esc(me.key)} ${(me.models && me.models.length) - ? me.models.map(m => `${esc(m)}`).join('') + ? me.models.map(m => `${esc(m.model)}${m.token_quota ? ' · ' + esc(fmtQuota(m.token_quota)) : ''}`).join('') : `${t('kAll')}`}
${t('kMeHint')}
`; @@ -1470,140 +1684,270 @@ async function renderKeysAdmin() { try { models = (await api('/api/status')).models || []; } catch (e) {} allModels = models; $('#tab-keys').innerHTML = ` -

${t('kCreate')}

-
- - - - - -
-
+
+ +
-

${t('keysTitle')}

${t('keysHint')}
`; +
+
`; 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('') : `
${t('kEmpty')}
`; + const ks = (j.keys || []).filter(k => k.role === 'user'); + el.innerHTML = ks.length ? ks.map(k => keyCanvasHtml(k)).join('') : `
${t('kEmpty')}
`; + el.querySelectorAll('.kc-blocks').forEach(cv => bindCanvasDrop(cv)); + el.querySelectorAll('.mb').forEach(b => bindBrickDrag(b)); } -function keyRowHtml(k) { +function keyCanvasHtml(k) { + const scopes = k.models || []; return ` -
- ${esc(k.name || '—')} - ${roleTag(k.role)} - ${esc(maskKey(k.key))} - - - ${t('kModels')}: ${(k.models && k.models.length) ? k.models.length : t('kAll')} - ${fmtCreated(k.created_at)} - - -
-