feat(auto): quota-aware AUTO slot scheduling (model tiers, hour/week/month resets) + key scope periods; fix key/slot create/delete UX

This commit is contained in:
root
2026-08-09 11:07:38 +08:00
parent 3408c9cb1f
commit 859d310ad3
9 changed files with 1065 additions and 162 deletions

View File

@ -3,6 +3,7 @@
package config
import (
"encoding/json"
"fmt"
"os"
"time"
@ -122,16 +123,53 @@ func (c *Config) ApplyDefaults() error {
type RuntimeConfig struct {
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"`
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
}

View File

@ -138,3 +138,20 @@ 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()
}

View File

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

View File

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

View File

@ -5,6 +5,8 @@ import (
"encoding/json"
"net/http"
"strings"
"llmsproxy/internal/config"
)
// handleKeysAPI manages gateway keys: GET /api/keys (admin: all keys),
@ -35,7 +37,7 @@ func (g *Gateway) handleKeysAPI(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
Role string `json:"role"`
Models []string `json:"models"`
Models []config.ModelScope `json:"models"`
Note string `json:"note"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
@ -63,7 +65,7 @@ func (g *Gateway) handleKeysAPI(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
Role string `json:"role"`
Models []string `json:"models"`
Models []config.ModelScope `json:"models"`
Note string `json:"note"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
@ -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
}
@ -127,3 +129,33 @@ func (g *Gateway) allowedModels(ctx context.Context) []string {
}
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", "")
}
}

View File

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

View File

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

View File

@ -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 = `
<div class="card"><h2><span>${t('sortTitle')}</span><span class="grow"></span>
<button class="ghost small" onclick="scrAddModal()">+ ${t('sortAdd')}</button>
<button class="ghost small" onclick="sortReset()">${t('sortReset')}</button>
<button class="small" onclick="saveSort()">${t('sortSave')}</button></h2>
<div class="sort-canvas" id="scr-canvas"></div>
@ -1039,24 +1112,30 @@ async function renderSort() {
</div>`;
paintSort();
}
function scrBlockHtml(it, isFirst) {
function scrBlockHtml(it, isFirst, li, ji) {
const c = srcColor(it.src);
const s = srcShort(it.src);
return `
<div class="scr-block" data-key="${escAttr(it.src + '|' + it.id)}"
style="background:linear-gradient(135deg,${c[0]},${c[1]})">
<div class="scr-block" data-key="${escAttr(it.uid)}" data-li="${li}" data-ji="${ji}"
style="background:linear-gradient(135deg,${c[0]},${c[1]})"
onclick="scrCtx(event,'${li}','${ji}')"
oncontextmenu="scrCtx(event,'${li}','${ji}')">
${isFirst ? '<i class="scr-knob"></i><i class="scr-slot"></i>' : ''}
<span class="scr-ico">${esc(srcShort(it.src))}</span>
<span class="scr-ico">${esc(it.src === '*' ? '+' : s)}</span>
<span class="scr-name">${esc(it.id)}</span>
${it.meta ? `<span class="scr-tag">${esc(quantBadge(it.meta.quota, it.meta.period, it.meta.hours))}</span>` : ''}
<span class="scr-x" title="${escAttr(t('kDelB2'))}" onclick="event.stopPropagation();scrDelSlot('${li}','${ji}')">×</span>
<span class="scr-grip"><i></i><i></i><i></i></span>
</div>`;
}
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(`<div class="scr-gap" data-gap="${li}"></div>`);
html.push(`<div class="${cls}" data-lane="${li}">
<div class="scr-tier">${Array.from({ length: n }, () => '<span></span>').join('')}</div>
<div class="scr-row">${lane.models.map((m, i) => scrBlockHtml(m, i === 0)).join('')}</div>
<div class="scr-row">${lane.models.map((m, i) => scrBlockHtml(m, i === 0, li, i)).join('')}</div>
</div>`);
});
html.push(`<div class="scr-gap" data-gap="${sortState.lanes.length}"></div>`);
@ -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 = `<div class="card" style="width:400px;max-width:100%"><h2>${t('sortAdd')}</h2>
<label>${t('kModelB')}</label>
<select id="a-model">${allModels.map(m => `<option value="${escAttr(m)}">${esc(m)}</option>`).join('')}</select>
<label>${t('kQuotaB')} <span class="muted">${t('kQuotaHintB')}</span></label>
<input id="a-quota" type="number" min="0" step="1" placeholder="${escAttr(t('kQuotaHintB'))}">
<label>${t('kPeriodB')}</label>
<select id="a-period">
<option value="" selected>${t('kPerNothing')}</option>
<option value="hour">${t('kPerHour')}</option>
<option value="week">${t('kPerWeek')}</option>
<option value="month">${t('kPerMonth')}</option>
<option value="nhour">${t('kPerHours')}</option>
</select>
<div id="a-hours-box" style="display:none"><label>${t('kPerNHint')}</label>
<input id="a-hours" type="number" min="1" step="1" value="24"></div>
<p><button onclick="scrAddFromForm()">${t('kSaveScope')}</button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t('mCancel')}</button></p>
</div>`;
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 = `<div class="card" style="width:400px;max-width:100%"><h2>${t('kFormTitle')} · ${esc(it.id)}</h2>
<label>${t('kQuotaB')} <span class="muted">${t('kQuotaHintB')}</span></label>
<input id="q-quota" type="number" min="0" step="1"
placeholder="${escAttr(t('kQuotaHintB'))}" value="${cur.quota || ''}">
<label>${t('kPeriodB')}</label>
<select id="q-period">
<option value="" ${!cur.period ? 'selected' : ''}>${t('kPerNothing')}</option>
<option value="hour" ${cur.period === 'hour' ? 'selected' : ''}>${t('kPerHour')}</option>
<option value="week" ${cur.period === 'week' ? 'selected' : ''}>${t('kPerWeek')}</option>
<option value="month" ${cur.period === 'month' ? 'selected' : ''}>${t('kPerMonth')}</option>
<option value="nhour" ${cur.period === 'nhour' ? 'selected' : ''}>${t('kPerHours')}</option>
</select>
<div id="q-hours-box" style="display:none"><label>${t('kPerNHint')}</label>
<input id="q-hours" type="number" min="1" step="1" value="${cur.hours || 24}"></div>
<p><button onclick="sortScopeSave(${li},${ji},this)">${t('kSaveScope')}</button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t('mCancel')}</button></p>
</div>`;
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) {
<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('')
? me.models.map(m => `<span class="tag tag-blue" title="${m.token_quota ? 'quota ' + fmtQuota(m.token_quota) : t('kQuotaUnlim')}">${esc(m.model)}${m.token_quota ? ' · ' + esc(fmtQuota(m.token_quota)) : ''}</span>`).join('')
: `<span class="scope-unlim">${t('kAll')}</span>`}</td></tr></table>
<div class="muted" style="margin-top:10px">${t('kMeHint')}</div>
</div>`;
@ -1470,140 +1684,270 @@ async function renderKeysAdmin() {
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 class="key-actions">
<span class="grow"></span>
<button class="small" onclick="openKeyModal()">${t('kCreate')}</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>`;
<div id="k-list"></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>`;
const ks = (j.keys || []).filter(k => k.role === 'user');
el.innerHTML = ks.length ? ks.map(k => keyCanvasHtml(k)).join('') : `<div class="muted">${t('kEmpty')}</div>`;
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 `
<div class="key-row">
<span class="kr-name">${esc(k.name || '—')}</span>
<div class="key-canvas" data-key="${escAttr(k.key)}">
<div class="kc-head">
<b>${esc(k.name || '—')}</b>
${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 class="kc-blocks" data-key="${escAttr(k.key)}">
${scopes.length ? '' : `<span class="mb-empty">${t('kEmptyB')}</span>`}
${scopes.map(m => scopeHtml(k.key, m)).join('')}
<button class="add-brick" onclick="scopePush('${escAttr(k.key)}')">+ ${t('kAddB')}</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 scopeHtml(key, m) {
const qt = fmtQuota(m.token_quota);
const attrs = `data-key="${escAttr(key)}" data-model="${escAttr(m.model)}"
data-quota="${m.token_quota || 0}" data-period="${escAttr(m.period || '')}" data-hours="${m.hours || 0}"`;
return `<span class="mb" draggable="true" ${attrs} title="${escAttr(t('kBrickH'))}"
onclick="scopeEdit('${escAttr(key)}','${escAttr(m.model)}')"
oncontextmenu="scopeCtx(event,'${escAttr(key)}','${escAttr(m.model)}')">
<span class="mb-ico">◆</span>
<span class="mb-name">${esc(m.model)}</span>
<span class="mb-quota">${esc(quantBadge(m.token_quota, m.period, m.hours))}</span>
<span class="copy-b" title="${escAttr(t('kCopyB'))}" onclick="event.stopPropagation();scopeDup('${escAttr(key)}','${escAttr(m.model)}')">⧉</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 fmtQuota(q) { q = +q || 0; if (!q) return '∞';
if (q >= 1e9) return (q / 1e9).toFixed(1) + 'B';
if (q >= 1e6) return (q / 1e6).toFixed(1) + 'M';
if (q >= 1e3) return (q / 1e3).toFixed(1) + 'K';
return String(q); }
function periodText(p, h) {
h = +h || 0;
if (p === 'hour') return '·1h';
if (p === 'week') return '·7d';
if (p === 'month') return '·30d';
if (p === 'nhour') return '·' + Math.max(1, h) + 'h';
return '';
}
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 quantBadge(quota, period, hours) {
quota = +quota || 0;
period = period || '';
if (!quota && !period) return '∞';
return fmtQuota(quota) + periodText(period, hours);
}
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 readScopes(canvas) {
return [...canvas.querySelectorAll('.mb')].map(b => ({
model: b.dataset.model,
token_quota: parseInt(b.dataset.quota) || 0,
period: b.dataset.period || '',
hours: parseInt(b.dataset.hours) || 0,
}));
}
async function putScope(key, scopes) {
await api('/api/keys/' + encodeURIComponent(key), { method: 'PUT', body: JSON.stringify({ models: scopes }) });
}
async function scopePush(key) {
const canvas = document.querySelector(`.key-canvas[data-key="${CSS.escape(key)}"]`);
if (!canvas) return;
const scopes = readScopes(canvas);
if (!scopes.some(s => s.model === 'AUTO')) scopes.push({ model: 'AUTO', token_quota: 0 });
try { await putScope(key, scopes); } catch (e) { toast(e.message); return; }
await loadKeys();
scopeEdit(key, 'AUTO');
}
async function scopeDup(key, model) {
const canvas = document.querySelector(`.key-canvas[data-key="${CSS.escape(key)}"]`);
if (!canvas) return;
const scopes = readScopes(canvas);
const src = scopes.find(s => s.model === model);
if (!src) return;
scopes.push({ model: src.model, token_quota: src.token_quota });
try { await putScope(key, scopes); toast(t('kDupOK')); } catch (e) { toast(e.message); }
}
async function scopeRm(key, model) {
const canvas = document.querySelector(`.key-canvas[data-key="${CSS.escape(key)}"]`);
if (!canvas) return;
const scopes = readScopes(canvas).filter(s => s.model !== model);
try { await putScope(key, scopes); } catch (e) { toast(e.message); return; }
await loadKeys();
toast(t('toastDelOk'));
}
function scopeEdit(key, model) {
const canvas = document.querySelector(`.key-canvas[data-key="${CSS.escape(key)}"]`);
if (!canvas) return;
const cur = readScopes(canvas).find(s => s.model === model) || { model: '', token_quota: 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';
const opts = ['AUTO', ...allModels];
if (cur.model && !opts.includes(cur.model)) opts.unshift(cur.model);
wrap.innerHTML = `<div class="card" style="width:400px;max-width:100%"><h2>${t('kFormTitle')}</h2>
<label>${t('kModelB')}</label>
<select id="sc-model">${opts.map(m => `<option value="${escAttr(m)}" ${m === cur.model ? 'selected' : ''}>${esc(m)}</option>`).join('')}</select>
<label>${t('kQuotaB')} <span class="muted">${t('kQuotaHintB')}</span></label>
<input id="sc-quota" type="number" min="0" step="1"
placeholder="${escAttr(t('kQuotaHintB'))}" value="${cur.token_quota ? cur.token_quota : ''}">
<label>${t('kPeriodB')}</label>
<select id="sc-period">
<option value="" ${!cur.period ? 'selected' : ''}>${t('kPerNothing')}</option>
<option value="hour" ${cur.period === 'hour' ? 'selected' : ''}>${t('kPerHour')}</option>
<option value="week" ${cur.period === 'week' ? 'selected' : ''}>${t('kPerWeek')}</option>
<option value="month" ${cur.period === 'month' ? 'selected' : ''}>${t('kPerMonth')}</option>
<option value="nhour" ${cur.period === 'nhour' ? 'selected' : ''}>${t('kPerHours')}</option>
</select>
<div id="sc-hours-box" style="display:none"><label>${t('kPerNHint')}</label>
<input id="sc-hours" type="number" min="1" step="1" value="${cur.hours || 24}"></div>
<p><button onclick="scopeSave('${escAttr(key)}','${escAttr(cur.model)}', this)">${t('kSaveScope')}</button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t('mCancel')}</button></p>
</div>`;
document.body.appendChild(wrap);
$('#sc-period').addEventListener('change', () => {
$('#sc-hours').closest('#sc-hours-box').style.display =
$('#sc-period').value === 'nhour' ? 'block' : 'none';
});
$('#sc-model').focus();
}
async function scopeSave(key, oldModel, btn) {
const canvas = document.querySelector(`.key-canvas[data-key="${CSS.escape(key)}"]`);
if (!canvas) return;
const model = $('#sc-model').value.trim();
if (!model) { toast(t('kName')); return; }
let q = parseInt($('#sc-quota').value);
if (isNaN(q) || isNaN(q) || q < 0) q = 0;
let hours = parseInt($('#sc-hours').value);
if (isNaN(hours) || hours < 1) hours = 1;
const period = $('#sc-period').value;
const scopes = readScopes(canvas);
const i = scopes.findIndex(s => s.model === oldModel);
if (i < 0) scopes.push({ model, token_quota: q, period, hours });
else scopes[i] = { model, token_quota: q, period, hours };
if (btn) btn.disabled = true;
try {
await putScope(key, scopes);
const w = $('#modal-wrap'); if (w) w.remove();
toast(t('kSaved'));
await loadKeys();
} catch (e) { toast(e.message); if (btn) btn.disabled = false; }
}
function scopeCtx(e, key, model) {
e.preventDefault(); e.stopPropagation();
scopeDragEl = null;
showCtx(e.clientX, e.clientY, [
{ label: t('kEditB'), icon: '✎', fn: () => scopeEdit(key, model) },
{ label: t('kDelB'), icon: '✕', danger: true, fn: () => scopeRm(key, model) },
]);
}
let ctxEl = null;
function showCtx(x, y, items) {
hideCtx();
const w = document.createElement('div'); w.id = 'ctx-wrap';
w.innerHTML = items.map((it, i) =>
`<div class="ctx-itm ${it.danger ? 'danger' : ''}" data-i="${i}"><span class="ic">${esc(it.icon)}</span>${esc(it.label)}</div>`).join('');
w.addEventListener('click', e => {
const t = e.target.closest('.ctx-itm'); if (!t) return;
const it = items[+t.dataset.i]; hideCtx(); if (it) it.fn();
});
document.body.appendChild(w);
const r = w.getBoundingClientRect();
w.style.left = Math.max(6, Math.min(x, window.innerWidth - r.width - 6)) + 'px';
w.style.top = Math.max(6, Math.min(y, window.innerHeight - r.height - 6)) + 'px';
setTimeout(() => document.addEventListener('click', hideCtx2, { once: true }), 10);
}
function hideCtx2() { hideCtx(); }
function hideCtx() { if (ctxEl) { ctxEl.remove(); ctxEl = null; } }
/* cross-canvas brick dragging */
function bindBrickDrag(b) {
b.addEventListener('dragstart', e => {
scopeDragEl = { key: b.dataset.key, model: b.dataset.model };
e.dataTransfer.setData('text/plain', b.dataset.model);
e.dataTransfer.effectAllowed = 'move';
const g = b.cloneNode(true); g.classList.add('mb-ghost');
document.body.appendChild(g);
b.addEventListener('dragend', () => { g.remove(); scopeDragEl = null; }, { once: true });
});
b.addEventListener('dragover', e => e.preventDefault());
}
function bindCanvasDrop(cv) {
cv.addEventListener('dragover', e => { e.preventDefault(); cv.classList.add('ovh'); });
cv.addEventListener('dragleave', () => cv.classList.remove('ovh'));
cv.addEventListener('drop', e => {
e.preventDefault(); cv.classList.remove('ovh');
if (!scopeDragEl) return;
moveBrick(scopeDragEl.key, cv.dataset.key, scopeDragEl.model);
});
}
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);
async function moveBrick(from, to, model) {
if (from === to) return;
const sf = document.querySelector(`.key-canvas[data-key="${CSS.escape(from)}"]`);
const st = document.querySelector(`.key-canvas[data-key="${CSS.escape(to)}"]`);
if (!sf || !st) return;
let fs = readScopes(sf), ts = readScopes(st);
const b = fs.find(s => s.model === model);
if (!b) return;
fs = fs.filter(s => s.model !== model);
const dup = ts.some(s => s.model === model);
if (!dup) ts.push(b);
try {
await putScope(from, fs);
await putScope(to, ts);
toast(t('kMovOK'));
} catch (e) { toast(e.message); loadKeys(); }
}
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 openKeyModal() {
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 = `<div class="card" style="width:400px;max-width:100%"><h2>${t('kCreate')}</h2>
<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">
<p><button onclick="createKey(this)">${t('kCreateBtn')}</button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t('mCancel')}</button></p>
</div>`;
document.body.appendChild(wrap);
$('#kc-name').focus();
}
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() {
async function createKey(btn) {
const name = $('#kc-name').value.trim();
if (!name) { toast(t('kName')); return; }
const j = await api('/api/keys', { method: 'POST', body: JSON.stringify({
if (btn) btn.disabled = true;
let j;
try {
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 = '';
} catch (e) { toast(e.message); if (btn) btn.disabled = false; return; }
const w = $('#modal-wrap'); if (w) w.remove();
$('#k-newbox').innerHTML = `
<div class="key-row" style="margin-top:12px;background:var(--card2)">
<div class="key-canvas" style="background:var(--card2)">
<div class="kc-head">
<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>`;
</div></div>`;
toast(t('kNewOK'));
loadKeys();
}

View File

@ -132,6 +132,17 @@ func (r *Registry) AUTOChain() []*Provider {
return r.Resolve("AUTO")
}
// ProviderForModel returns the provider owning the model id (nil if unknown).
func (r *Registry) ProviderForModel(model string) *Provider {
r.mu.RLock()
defer r.mu.RUnlock()
p, ok := r.byModel[strings.ToLower(strings.TrimSpace(model))]
if !ok {
return nil
}
return p
}
// Default returns the highest-priority available provider.
func (r *Registry) Default() *Provider {
chain := r.AUTOChain()