diff --git a/internal/config/config.go b/internal/config/config.go index 9023a12..7be1c2d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -121,9 +121,11 @@ 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"` - Auto []ModelScope `json:"auto,omitempty"` + Sources []Source `json:"sources"` + DeletedSources []string `json:"deleted_sources,omitempty"` + DeletedAdapters []string `json:"deleted_adapters,omitempty"` + Keys []GWKey `json:"keys,omitempty"` + Auto []ModelScope `json:"auto,omitempty"` } // GWKey is a gateway API key persisted in the runtime store. Role is "admin" @@ -144,6 +146,8 @@ type GWKey struct { // uses Hours as the window length in hours. type ModelScope struct { Model string `json:"model"` + Source string `json:"source,omitempty"` // optional: pin to one upstream source; "" = any source + Tier int `json:"tier,omitempty"` TokenQuota int64 `json:"token_quota"` Period string `json:"period,omitempty"` Hours int64 `json:"hours,omitempty"` @@ -160,6 +164,8 @@ func (m *ModelScope) UnmarshalJSON(b []byte) error { } var o struct { Model string `json:"model"` + Source string `json:"source"` + Tier int `json:"tier"` TokenQuota int64 `json:"token_quota"` Period string `json:"period"` Hours int64 `json:"hours"` @@ -168,6 +174,8 @@ func (m *ModelScope) UnmarshalJSON(b []byte) error { return err } m.Model = o.Model + m.Source = o.Source + m.Tier = o.Tier m.TokenQuota = o.TokenQuota m.Period = o.Period m.Hours = o.Hours diff --git a/internal/config/store.go b/internal/config/store.go index 5e0fe1e..4bb4ee9 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -49,14 +49,16 @@ func (s *Store) Upsert(src Source) error { for i := range s.data.Sources { if s.data.Sources[i].Name == src.Name { s.data.Sources[i] = src + s.data.DeletedSources = removeString(s.data.DeletedSources, src.Name) return s.persistLocked() } } s.data.Sources = append(s.data.Sources, src) + s.data.DeletedSources = removeString(s.data.DeletedSources, src.Name) return s.persistLocked() } -// Remove deletes a runtime source and persists. +// Remove deletes a runtime source or hides a base YAML source and persists. func (s *Store) Remove(name string) (bool, error) { s.mu.Lock() defer s.mu.Unlock() @@ -69,11 +71,47 @@ func (s *Store) Remove(name string) (bool, error) { } kept = append(kept, src) } - if !removed { - return false, nil - } s.data.Sources = kept - return true, s.persistLocked() + if !containsString(s.data.DeletedSources, name) { + s.data.DeletedSources = append(s.data.DeletedSources, name) + } + return removed || containsString(s.data.DeletedSources, name), s.persistLocked() +} + +func (s *Store) DeletedSources() map[string]bool { + s.mu.Lock() + defer s.mu.Unlock() + out := map[string]bool{} + for _, name := range s.data.DeletedSources { + out[name] = true + } + return out +} + +func (s *Store) DeleteAdapter(name string) error { + s.mu.Lock() + defer s.mu.Unlock() + if !containsString(s.data.DeletedAdapters, name) { + s.data.DeletedAdapters = append(s.data.DeletedAdapters, name) + } + return s.persistLocked() +} + +func (s *Store) RestoreAdapter(name string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.data.DeletedAdapters = removeString(s.data.DeletedAdapters, name) + return s.persistLocked() +} + +func (s *Store) DeletedAdapters() map[string]bool { + s.mu.Lock() + defer s.mu.Unlock() + out := map[string]bool{} + for _, name := range s.data.DeletedAdapters { + out[name] = true + } + return out } func (s *Store) persistLocked() error { @@ -84,6 +122,25 @@ func (s *Store) persistLocked() error { return os.WriteFile(s.path, b, 0644) } +func containsString(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + +func removeString(list []string, s string) []string { + out := list[:0] + for _, x := range list { + if x != s { + out = append(out, x) + } + } + return out +} + // ListKeys returns the persisted gateway keys. func (s *Store) ListKeys() []GWKey { s.mu.Lock() diff --git a/internal/core/core.go b/internal/core/core.go index 607c934..f736f94 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -152,6 +152,7 @@ func (c *Core) FindKey(key string) (config.GWKey, bool) { return c.store.KeyByVa // CreateKey builds a new random gateway key and persists it. func (c *Core) CreateKey(name, role string, models []config.ModelScope, note string) (config.GWKey, error) { + models = cleanScopes(models) key := make([]byte, 16) if _, err := rand.Read(key); err != nil { return config.GWKey{}, err @@ -185,7 +186,7 @@ func (c *Core) UpdateKey(key, name, role string, models []config.ModelScope, not if role == "admin" || role == "user" { rec.Role = role } - rec.Models = models + rec.Models = cleanScopes(models) rec.Note = note if err := c.store.SaveKey(rec); err != nil { return config.GWKey{}, err @@ -202,16 +203,23 @@ func (c *Core) DeleteKey(key string) (bool, error) { return c.store.DeleteKey(ke // 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 { +func cleanScopes(entries []config.ModelScope) []config.ModelScope { clean := make([]config.ModelScope, 0, len(entries)) for _, e := range entries { if e.Model == "" { continue } + if e.Source == "undefined" || e.Source == "null" { + e.Source = "" + } clean = append(clean, e) } - return c.store.SaveAutoRules(clean) + return clean +} + +// SaveAutoRules persists the AUTO scheduling slots. +func (c *Core) SaveAutoRules(entries []config.ModelScope) error { + return c.store.SaveAutoRules(cleanScopes(entries)) } // Registry resolves model -> owning provider. @@ -219,14 +227,24 @@ func (c *Core) ProviderForModel(model string) *provider.Provider { return c.registry.ProviderForModel(model) } +// ProviderForSlot resolves a (model, source) scheduling slot to a provider; +// source "" falls back to ProviderForModel. +func (c *Core) ProviderForSlot(model, source string) *provider.Provider { + return c.registry.ProviderForSlot(model, source) +} + // Config exposes the underlying configuration (read-only usage). func (c *Core) Config() *config.Config { return c.cfg } // mergedSources = base YAML sources + runtime sources (runtime wins by name). func (c *Core) mergedSources() []config.Source { + deleted := c.store.DeletedSources() byName := map[string]config.Source{} order := []string{} for _, s := range c.cfg.Sources { + if deleted[s.Name] { + continue + } byName[s.Name] = s order = append(order, s.Name) } @@ -278,7 +296,17 @@ func (c *Core) Reload() error { // ---- adapter management (web UI) ---- -func (c *Core) ListAdapters() []lua.APIAdapter { return c.vm.ListAdapters() } +func (c *Core) ListAdapters() []lua.APIAdapter { + deleted := c.store.DeletedAdapters() + list := c.vm.ListAdapters() + out := make([]lua.APIAdapter, 0, len(list)) + for _, a := range list { + if !deleted[a.Name] { + out = append(out, a) + } + } + return out +} // UploadAdapter saves a new Lua adapter script to the adapter dir and loads it. func (c *Core) UploadAdapter(name, code string) error { @@ -295,7 +323,7 @@ func (c *Core) UploadAdapter(name, code string) error { if err := c.vm.LoadAdapter(path); err != nil { return fmt.Errorf("load adapter: %w", err) } - return nil + return c.store.RestoreAdapter(name) } // RemoveAdapter deletes an adapter script and evicts it from the VM. @@ -303,7 +331,7 @@ func (c *Core) RemoveAdapter(name string) error { path := filepath.Join(c.cfg.AdapterDir, name+".lua") _ = os.Remove(path) c.vm.RemoveAdapter(name) - return nil + return c.store.DeleteAdapter(name) } // ---- source management (web UI) ---- diff --git a/internal/gateway/api.go b/internal/gateway/api.go index 7b4436e..5a7c2ad 100644 --- a/internal/gateway/api.go +++ b/internal/gateway/api.go @@ -1,11 +1,13 @@ package gateway import ( + "encoding/csv" "encoding/json" "io" "net/http" "strconv" "strings" + "time" "llmsproxy/internal/config" ) @@ -133,5 +135,44 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) { // user keys may only see their own usage key = keyID(reqKey(r.Context())) } - writeJSON(w, http.StatusOK, g.stats.Snapshot(limit, key)) + if r.URL.Query().Get("export") == "csv" { + from, _ := strconv.ParseInt(r.URL.Query().Get("from"), 10, 64) + to, _ := strconv.ParseInt(r.URL.Query().Get("to"), 10, 64) + if to == 0 { + to = time.Now().UnixMilli() + } + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", "attachment; filename=llmsproxy-requests.csv") + cw := csv.NewWriter(w) + names := map[string]string{} + for _, k := range g.core.ListKeys() { + names[keyID(k.Key)] = k.Name + } + _ = cw.Write([]string{"time", "key", "key_name", "type", "model", "source", "status", "ok", "prompt_tokens", "completion_tokens", "latency_ms", "error"}) + for _, rec := range g.stats.Records(from, to, key) { + _ = cw.Write([]string{ + time.UnixMilli(rec.Time).Format(time.RFC3339), + rec.Key, + names[rec.Key], + rec.Type, + rec.Model, + rec.Source, + strconv.Itoa(rec.Status), + strconv.FormatBool(rec.OK), + strconv.FormatInt(rec.Prompt, 10), + strconv.FormatInt(rec.Compl, 10), + strconv.FormatInt(rec.LatMs, 10), + rec.Err, + }) + } + cw.Flush() + return + } + snap := g.stats.Snapshot(limit, key) + keyNames := map[string]string{} + for _, k := range g.core.ListKeys() { + keyNames[keyID(k.Key)] = k.Name + } + snap["key_names"] = keyNames + writeJSON(w, http.StatusOK, snap) } diff --git a/internal/gateway/chat.go b/internal/gateway/chat.go index 733ac2f..3da2649 100644 --- a/internal/gateway/chat.go +++ b/internal/gateway/chat.go @@ -108,22 +108,37 @@ func (g *Gateway) resolveCands(ctx context.Context, req *chatRequest) ([]*provid // filterCandsByModels keeps only providers exposing at least one model of the // scope (used for user keys with a restricted model scope). An "AUTO" scope -// entry means the key is allowed to use any model. +// entry means the key is allowed to use any model. Scope entries with a +// Source pinned to a specific upstream narrow the candidates to that source +// for the matching model. func filterCandsByModels(cands []*provider.Provider, allow []config.ModelScope) []*provider.Provider { - allowed := make(map[string]bool, len(allow)) for _, m := range allow { if m.Model == "" || strings.EqualFold(m.Model, "AUTO") { return cands } + } + allowed := make(map[string]bool, len(allow)) + byModelSrc := map[string]map[string]bool{} + for _, m := range allow { allowed[m.Model] = true + if m.Source != "" { + if byModelSrc[m.Model] == nil { + byModelSrc[m.Model] = map[string]bool{} + } + byModelSrc[m.Model][m.Source] = true + } } out := make([]*provider.Provider, 0, len(cands)) for _, p := range cands { for _, id := range p.Models() { - if allowed[id] { - out = append(out, p) - break + if !allowed[id] { + continue } + if srcs := byModelSrc[id]; len(srcs) > 0 && !srcs[p.Name()] { + continue + } + out = append(out, p) + break } } return out @@ -197,7 +212,7 @@ func (g *Gateway) scopeTokens(ctx context.Context, sc config.ModelScope) int64 { return g.stats.KeyTokens(k) } win := AutoPeriodSeconds(sc.Period, sc.Hours) - return g.stats.WindowTokens(sc.Model, win) + return g.stats.WindowTokens(sc.Model, sc.Source, win) } func hasScopeModel(list []config.ModelScope, s string) bool { @@ -263,7 +278,7 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) { if model == "" { model = g.core.DefaultModel() } - if strings.EqualFold(strings.TrimSpace(req.Model), "AUTO") { + if isAuto(model) { plans := g.autoPlans() if len(plans) == 0 { writeError(w, http.StatusServiceUnavailable, "no_provider", "no auto slot available (quota exhausted or none configured)") @@ -424,6 +439,39 @@ func effectiveImageModel(model string, cands []*provider.Provider) string { return model } +func estimatePromptTokens(req *types.ChatRequest) int64 { + if req == nil { + return 0 + } + b, _ := json.Marshal(struct { + Messages []types.ChatMessage `json:"messages"` + Tools []interface{} `json:"tools,omitempty"` + }{Messages: req.Messages, Tools: req.Tools}) + if len(b) == 0 { + return 0 + } + return int64(len(b)/3 + 1) +} + +func estimateTextTokens(parts ...interface{}) int64 { + var n int + for _, p := range parts { + switch v := p.(type) { + case string: + n += len(v) + case json.RawMessage: + n += len(v) + case []types.ToolCall: + b, _ := json.Marshal(v) + n += len(b) + } + } + if n == 0 { + return 0 + } + return int64(n/3 + 1) +} + func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands []*provider.Provider, req *types.ChatRequest, effective string, rec *Req) { rec.LatMs = 0 t0 := time.Now() @@ -440,7 +488,13 @@ func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands [ rec.OK = true rec.Status = http.StatusOK rec.Prompt = int64(resp.TokenUsage.Prompt) + if rec.Prompt == 0 { + rec.Prompt = estimatePromptTokens(req) + } rec.Compl = int64(resp.TokenUsage.Completion) + if rec.Compl == 0 { + rec.Compl = estimateTextTokens(resp.Content, resp.ReasoningContent, resp.ToolCalls) + } rec.Source = usedSrc rec.Model = usedModel g.writeRec(rec) @@ -495,6 +549,7 @@ func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands [ if usedModel != "" { rec.Model = usedModel } + rec.Prompt = estimatePromptTokens(req) w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") @@ -540,7 +595,7 @@ func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands [ choice.FinishReason = &stop } chunk.Choices = []ChunkChoice{choice} - rec.Compl += int64(len(ck.Content)+len(ck.ReasoningContent)) / 3 + rec.Compl += int64(len(ck.Content)+len(ck.ReasoningContent)+len(ck.ToolCalls)) / 3 if !send(chunk) { return } @@ -575,7 +630,7 @@ func (g *Gateway) autoPlans() []autoPlan { } plans := make([]autoPlan, 0, len(rules)) for _, e := range rules { - p := g.core.ProviderForModel(e.Model) + p := g.core.ProviderForSlot(e.Model, e.Source) if p == nil { continue } @@ -583,7 +638,7 @@ func (g *Gateway) autoPlans() []autoPlan { continue } win := AutoPeriodSeconds(e.Period, e.Hours) - if e.TokenQuota > 0 && g.stats.WindowTokens(e.Model, win) >= e.TokenQuota { + if e.TokenQuota > 0 && g.stats.WindowTokens(e.Model, e.Source, win) >= e.TokenQuota { continue } plans = append(plans, autoPlan{p: p, model: e.Model, quota: e.TokenQuota, win: win}) @@ -599,12 +654,14 @@ func (g *Gateway) singleChatAuto(w http.ResponseWriter, ctx context.Context, pla rec.LatMs = 0 t0 := time.Now() var lastErr error + var lastSrc, lastModel string for _, pl := range plans { if !pl.p.Available() { continue } r := *req r.Model = pl.model + lastSrc, lastModel = pl.p.Name(), pl.model resp, usedSrc, usedModel, err := g.core.Scheduler().Chat(ctx, scheduler.FromRegistry([]*provider.Provider{pl.p}), &r) if err != nil { lastErr = err @@ -614,7 +671,13 @@ func (g *Gateway) singleChatAuto(w http.ResponseWriter, ctx context.Context, pla rec.OK = true rec.Status = http.StatusOK rec.Prompt = int64(resp.TokenUsage.Prompt) + if rec.Prompt == 0 { + rec.Prompt = estimatePromptTokens(&r) + } rec.Compl = int64(resp.TokenUsage.Completion) + if rec.Compl == 0 { + rec.Compl = estimateTextTokens(resp.Content, resp.ReasoningContent, resp.ToolCalls) + } rec.Source = usedSrc rec.Model = usedModel g.writeRec(rec) @@ -645,6 +708,12 @@ func (g *Gateway) singleChatAuto(w http.ResponseWriter, ctx context.Context, pla rec.OK = false rec.Status = http.StatusBadGateway rec.Err = lastErr.Error() + if rec.Model == "" { + rec.Model = lastModel + } + if rec.Source == "" { + rec.Source = lastSrc + } g.writeRec(rec) writeError(w, http.StatusBadGateway, "upstream_error", lastErr.Error()) } @@ -687,12 +756,14 @@ func (g *Gateway) streamChatAuto(w http.ResponseWriter, ctx context.Context, pla return } var lastErr error + var lastSrc, lastModel string for _, pl := range plans { if !pl.p.Available() { continue } r := *req r.Model = pl.model + lastSrc, lastModel = pl.p.Name(), pl.model chunks, usedSrc, usedModel, err := g.core.Scheduler().ChatStream(ctx, scheduler.FromRegistry([]*provider.Provider{pl.p}), &r) if err != nil { lastErr = err @@ -702,6 +773,7 @@ func (g *Gateway) streamChatAuto(w http.ResponseWriter, ctx context.Context, pla rec.Model = usedModel rec.Source = usedSrc } + rec.Prompt = estimatePromptTokens(&r) for ck := range chunks { chunk := ChatChunk{ ID: id, Object: "chat.completion.chunk", Created: created, Model: rec.Model, @@ -719,7 +791,7 @@ func (g *Gateway) streamChatAuto(w http.ResponseWriter, ctx context.Context, pla choice.FinishReason = &stop } chunk.Choices = []ChunkChoice{choice} - rec.Compl += int64(len(ck.Content)+len(ck.ReasoningContent)) / 3 + rec.Compl += int64(len(ck.Content)+len(ck.ReasoningContent)+len(ck.ToolCalls)) / 3 if !send(chunk) { return } @@ -732,11 +804,14 @@ func (g *Gateway) streamChatAuto(w http.ResponseWriter, ctx context.Context, pla 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}}, - }) + if rec.Model == "" { + rec.Model = lastModel + } + if rec.Source == "" { + rec.Source = lastSrc + } + errEvent, _ := json.Marshal(map[string]interface{}{"error": map[string]string{"message": lastErr.Error(), "type": "upstream_error"}}) + fmt.Fprintf(w, "data: %s\n\n", errEvent) fmt.Fprintf(w, "data: [DONE]\n\n") if flusher != nil { flusher.Flush() diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 957c7bf..bfef46a 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -14,6 +14,8 @@ import ( "net/http" "net/url" "strings" + "sync" + "time" "llmsproxy/internal/config" "llmsproxy/internal/core" @@ -24,9 +26,11 @@ var uiFS embed.FS // Gateway is the HTTP handler for the OpenAI-compatible endpoint + web UI. type Gateway struct { - core *core.Core - ui http.Handler - stats *Stats + core *core.Core + ui http.Handler + stats *Stats + probeMu sync.Mutex + lastProbe time.Time } func New(c *core.Core, gatewayKeys []string) (*Gateway, error) { @@ -34,10 +38,14 @@ func New(c *core.Core, gatewayKeys []string) (*Gateway, error) { if err != nil { return nil, err } + st := NewStats(3000) + if cfg := c.Config(); cfg != nil && cfg.RuntimeFile != "" { + st.LoadAudit(cfg.RuntimeFile + ".audit.jsonl") + } return &Gateway{ core: c, ui: http.FileServer(http.FS(sub)), - stats: NewStats(3000), + stats: st, }, nil } @@ -313,7 +321,21 @@ func (g *Gateway) handleModels(w http.ResponseWriter, r *http.Request) { }) } +// ensureProbe triggers a live source probe at most once every 30s. +func (g *Gateway) ensureProbe(ctx context.Context) { + g.probeMu.Lock() + due := time.Since(g.lastProbe) > 30*time.Second + if due { + g.lastProbe = time.Now() + } + g.probeMu.Unlock() + if due { + g.core.Registry().ProbeAll(ctx) + } +} + func (g *Gateway) handleStatusAPI(w http.ResponseWriter, r *http.Request) { + g.ensureProbe(r.Context()) host := r.Host if host == "" { host = g.core.Listen() diff --git a/internal/gateway/stats.go b/internal/gateway/stats.go index 9187376..aefb263 100644 --- a/internal/gateway/stats.go +++ b/internal/gateway/stats.go @@ -1,6 +1,9 @@ package gateway import ( + "bufio" + "encoding/json" + "os" "sync" "time" ) @@ -55,6 +58,7 @@ type Stats struct { byKeySrc map[string]map[string]*Stat recs []Req maxRecs int + auditPath string modelHour map[string]map[int64]int64 // model -> unix-hour bucket -> tokens } @@ -109,6 +113,30 @@ func inc(m map[string]*Stat, name string, r Req) { } } +func (s *Stats) LoadAudit(path string) { + f, err := os.Open(path) + if err == nil { + var recs []Req + sc := bufio.NewScanner(f) + for sc.Scan() { + var r Req + if json.Unmarshal(sc.Bytes(), &r) == nil { + recs = append(recs, r) + } + } + _ = f.Close() + if len(recs) > s.maxRecs { + recs = recs[len(recs)-s.maxRecs:] + } + for _, r := range recs { + s.Record(r) + } + } + s.mu.Lock() + s.auditPath = path + s.mu.Unlock() +} + // Record appends a finished request to the aggregates and ring buffer. func (s *Stats) Record(r Req) { s.mu.Lock() @@ -128,14 +156,18 @@ 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) + // window bucket for quota enforcement (per source-model pair, per unix hour) tok := r.Prompt + r.Compl if tok > 0 && r.Model != "" { + key := r.Model + if r.Source != "" { + key = r.Source + "::" + r.Model + } h := r.Time / hourSec - hm := s.modelHour[r.Model] + hm := s.modelHour[key] if hm == nil { hm = map[int64]int64{} - s.modelHour[r.Model] = hm + s.modelHour[key] = hm } hm[h] += tok if len(hm) > 24*40 { @@ -150,6 +182,14 @@ func (s *Stats) Record(r Req) { if len(s.recs) > s.maxRecs { s.recs = s.recs[len(s.recs)-s.maxRecs:] } + if s.auditPath != "" { + if f, err := os.OpenFile(s.auditPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644); err == nil { + if b, err := json.Marshal(r); err == nil { + _, _ = f.Write(append(b, '\n')) + } + _ = f.Close() + } + } } // ModelTokens returns the tokens consumed per model for one gateway key id @@ -197,11 +237,17 @@ func AutoPeriodSeconds(period string, hours int64) int64 { // 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 { +// WindowTokens returns the tokens consumed for one model (optionally pinned +// to a single source) within the window; sec <= 0 means all time. +func (s *Stats) WindowTokens(model, source string, sec int64) int64 { s.mu.Lock() defer s.mu.Unlock() + key := model + if source != "" { + key = source + "::" + model + } now := time.Now().Unix() - hm := s.modelHour[model] + hm := s.modelHour[key] if len(hm) == 0 { return 0 } @@ -240,6 +286,26 @@ type StatsRow struct { Stat } +// Records returns request records filtered by unix-millisecond time range and key. +func (s *Stats) Records(from, to int64, key string) []Req { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]Req, 0, len(s.recs)) + for _, r := range s.recs { + if key != "" && r.Key != key { + continue + } + if from > 0 && r.Time < from { + continue + } + if to > 0 && r.Time > to { + continue + } + out = append(out, r) + } + return out +} + // Snapshot returns the whole dashboard payload; when key != "" the records // and aggregate views are restricted to that gateway key. func (s *Stats) Snapshot(limit int, key string) map[string]interface{} { diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 8f0758a..c88e749 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -55,6 +55,7 @@ tr:last-child td { border-bottom:0; } .tag-red { background:var(--tag-err); color:var(--err); } .tag-blue { background:var(--tag-blue); color:var(--accent); } .tag-warn { background:var(--tag-warn); color:var(--warn); } +.net-dot { display:inline-block; width:7px; height:7px; border-radius:50%; background:currentColor; margin-right:6px; vertical-align:1px; } button { background:var(--accent); color:#fff; border:0; border-radius:8px; padding:8px 16px; cursor:pointer; font:inherit; font-weight:500; transition:background .15s; } button:hover { background:var(--accent-h); } @@ -263,7 +264,7 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho .scr-blocks { display:flex; flex-wrap:wrap; align-items:center; min-width:0; } /* 块间嵌合:每个块的凸榫插进左侧块的凹槽(平铺同档模型,如积木搭肩) */ .scr-block { position:relative; display:flex; align-items:center; gap:10px; padding:12px 14px 12px 20px; - color:#fff; font-weight:700; font-size:13px; user-select:none; margin-right:7px; + color:#fff; font-weight:700; font-size:13px; user-select:none; margin-right:0; border-radius:11px 11px 7px 7px; box-shadow:0 6px 0 rgba(0,0,0,.13), inset 0 2px 0 rgba(255,255,255,.28), inset 0 -5px 0 rgba(0,0,0,.07); transition:opacity .15s; } /* 左侧凸榫:宽14px 从块左缘伸出,正好填满左侧凹槽(块间距 7px → 榫左半插进凹槽右半仍露 7px 搭肩) */ @@ -277,8 +278,9 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho .scr-row .scr-block:last-child { margin-right:0; } .scr-row .scr-block:last-child::after { display:none; } /* 左右卡扣生长动画(块新获得左凸榫/右凹槽身份时)—— 延迟到位移完成后再弹出 */ +.scr-block.pre-grow-l::before { transform:translateY(-50%) scaleX(0); transform-origin:100% 50%; opacity:0; } .scr-block.grow-l::before { animation:growlL .22s ease-out .24s both; } -@keyframes growlL { from { transform: translateY(-50%) scaleX(0); transform-origin:100% 50%; } } +@keyframes growlL { from { transform: translateY(-50%) scaleX(0); transform-origin:100% 50%; opacity:0; } to { transform: translateY(-50%) scaleX(1); opacity:1; } } .scr-block.grow-r::after { animation:growrR .22s ease-out .24s both; } @keyframes growrR { from { transform: translateY(-50%) scaleX(0); transform-origin:0 50%; } } /* 上下卡扣生长动画:新行首块的凸榫/凹槽从根部弹出(列位置定后) */ @@ -290,7 +292,8 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho align-items:center; justify-content:center; font-size:11px; font-weight:800; letter-spacing:.5px; 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; } + max-width:200px; overflow:hidden; text-overflow:ellipsis; display:flex; flex-direction:column; line-height:1.25; } +.scr-block .scr-srcname { font-size:9.5px; opacity:.75; font-weight:600; 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; } @@ -310,10 +313,12 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho .scr-lane.scr-top .scr-block .scr-knob, .scr-lane.scr-last .scr-block .scr-slot { display:none; } .scr-block.drag-src { opacity:.28; } -.scr-block.drag-src .scr-knob, .scr-block.drag-src .scr-slot { visibility:hidden; } +.scr-block.drag-src .scr-knob, .scr-block.drag-src .scr-slot, +.scr-block.drag-src::before, .scr-block.drag-src::after { visibility:hidden; } .scr-ghost { position:fixed; z-index:200; pointer-events:none; transform:rotate(1.5deg) scale(1.05); filter:drop-shadow(0 16px 20px rgba(0,0,0,.35)); cursor:grabbing; } -.scr-ghost .scr-knob, .scr-ghost .scr-slot { display:none; } +.scr-ghost .scr-knob, .scr-ghost .scr-slot, +.scr-ghost::before, .scr-ghost::after { display:none; } .scr-gap { height:7px; margin:-2px 0 0; border-radius:8px; border:2px dashed transparent; transition:all .12s; display:flex; align-items:center; justify-content:center; color:var(--muted); font-size:11px; letter-spacing:2px; } @@ -407,7 +412,7 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho const STR = { zh: { tagline:'统一 LLM 网关', logout:'退出登录', langTo:'EN', - navStatus:'状态', navChat:'Chat 测试', navSources:'源', navAdapters:'适配器', navSort:'优先级', navKeys:'密钥', + navStatus:'状态', navChat:'对话', navSources:'源', navAdapters:'适配器', navSort:'优先级', navKeys:'密钥', keysTitle:'密钥管理', keysHint:'管理员密钥可查看与管理全部密钥,并可为每个用户密钥配置可用模型范围;用户密钥只能看到自己。', kCreate:'新建密钥', kName:'名称', kRole:'角色', kRoleAdmin:'管理员', kRoleUser:'用户', kNote:'备注(可选)', kCreateBtn:'创建', kKey:'密钥', kModels:'可用模型', kCreated:'创建时间', kActions:'操作', kCopy:'复制密钥', kDel:'删除', @@ -419,11 +424,11 @@ const STR = { kAll:'不限', kBrickH:'点击编辑模型与配额 · 右键更多操作 · 拖动可跨密钥移动', kCopyB:'复制该模型', kEditB:'编辑', kDelB:'删除', - kFormTitle:'模型与 Token 配额', kModelB:'模型', kQuotaB:'Token 配额', kQuotaHintB:'0 / 留空 = 无限', + kFormTitle:'模型与 Token 配额', kModelB:'模型', kSrcHint:'同一模型多源时请选择具体来源', kAnySrc:'任意源', 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,按优先级自动选择可用源;点击任一模型可生成固定到该模型的配置。', + connTitle:'连接配置(Agent / OpenAI SDK)', connHint:'模型名默认 AUTO,按优先级页面的 AUTO 链选择可用源;点击任一模型可生成固定到该模型的配置。', copyCfg:'一键复制配置', copyEnv:'复制为环境变量', srcTitle:'源状态', srcCount:'共 %d 个', tName:'名称', tAdapter:'适配器', tModels:'模型(点击看配置)', tURL:'地址', tConn:'连接', tConc:'并发', @@ -440,7 +445,7 @@ const STR = { modalNew:'新增源', modalEdit:'编辑源', mName:'名称', mURL:'Base URL', mKey:'API Key', mAlias:'适配器', mAliasAuto:'自动', mEp:'聊天端点(可选覆盖)', mImgEp:'生图端点(可选覆盖)', mConc:'并发上限', mTemp:'温度', - mModels:'模型列表(优先级越大,AUTO 越优先选择)', mAddModel:'+ 模型', + mModels:'模型列表', mAddModel:'+ 模型', mMeta:'Meta(透传给 build_headers 钩子,JSON)', mSave:'保存', mCancel:'取消', toastCopied:'已复制', toastCopyFail:'复制失败,请手动选择复制', toastSaved:'已保存并热重载', toastEmpty:'请输入消息', toastBadJson:'Meta 不是合法 JSON', toastSaveFail:'保存失败: %s', @@ -452,7 +457,7 @@ const STR = { sortSave:'保存排序', sortReset:'重置', sortAdd:'添加档位', sortSaved:'排序已保存并热重载', sortNoChange:'无变更', sortHintSave:'点击保存排序后生效', sortSource:'源', sortPrio:'优先级 %s', sortEmpty:'(该源暂无模型)', kpiActive:'活跃请求', kpiReqs:'总请求', kpiOk:'成功率', kpiTokens:'Tokens', kpiLat:'平均延迟', kpiMaxLat:'最大延迟', - dashModel:'模型用量', dashSrc:'源用量与延迟', dashKey:'密钥用量', dashRecs:'请求记录(审计)', + dashModel:'模型用量', dashSrc:'源用量与延迟', dashKey:'密钥用量', dashRecs:'请求记录', exportCsv:'导出 CSV', expWeek:'近一周', expMonth:'近一月', expYear:'近一年', expRange:'自定义范围', expStart:'开始日期', expEnd:'结束日期', expDownload:'下载', thModel:'模型', thSrc:'源', thKey:'密钥', thReqs:'请求', thOk:'成功', thErr:'失败', thPrompt:'输入 Tokens', thCompl:'输出 Tokens', thAvgLat:'平均延迟', thMaxLat:'最长延迟', thTime:'时间', thType:'类型', thStatus:'状态', thLatMs:'延迟', @@ -472,11 +477,11 @@ kMeTitle:'My key', kMeRole:'Role', kMeModels:'Models I can use', kMeHint:'Keys c 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', + kFormTitle:'Model & token quota', kModelB:'Model', kSrcHint:'pick a source when the same model exists on several sources', kAnySrc:'any source', 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.', + connTitle:'Connection config (Agent / OpenAI SDK)', connHint:'Model defaults to AUTO — follows the AUTO chain from the Priority page. Click a model to pin it.', copyCfg:'Copy config', copyEnv:'Copy as env vars', srcTitle:'Sources', srcCount:'%d total', tName:'Name', tAdapter:'Adapter', tModels:'Models', tURL:'URL', tConn:'Status', tConc:'Concurrency', @@ -493,19 +498,19 @@ kMeTitle:'My key', kMeRole:'Role', kMeModels:'Models I can use', kMeHint:'Keys c modalNew:'Add source', modalEdit:'Edit source', mName:'Name', mURL:'Base URL', mKey:'API Key', mAlias:'Adapter', mAliasAuto:'Auto', mEp:'Chat endpoint (override)', mImgEp:'Image endpoint (override)', mConc:'Max concurrency', mTemp:'Temperature', - mModels:'Models (higher priority → preferred)', mAddModel:'+ model', + mModels:'Models', mAddModel:'+ model', mMeta:'Meta (passed to build_headers hook, JSON)', mSave:'Save', mCancel:'Cancel', toastCopied:'Copied', toastCopyFail:'Copy failed', toastSaved:'Saved & hot-reloaded', toastEmpty:'Enter a message', toastBadJson:'Meta is not valid JSON', toastSaveFail:'Save failed: %s', toastUploaded:'Adapter loaded', toastUpFail:'Upload failed: %s', toastNeedAll:'Adapter name and code required', toastDelOk:'Deleted', confirmDelSrc:'Delete source %s?', confirmDelAdp:'Delete adapter %s?', 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', + cErr:'Error: %s', chatMeta:'Model=%s · %s ms · %d chars', connPinned:'# Pinned to model: %s (source %s)', connAuto:'# Model "AUTO" follows the Priority page AUTO chain', 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', 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)', + dashModel:'Model usage', dashSrc:'Source usage & latency', dashKey:'Key usage', dashRecs:'Request records', exportCsv:'Export CSV', expWeek:'Last week', expMonth:'Last month', expYear:'Last year', expRange:'Custom range', expStart:'Start date', expEnd:'End date', expDownload:'Download', thModel:'Model', thSrc:'Source', thKey:'Key', thReqs:'Requests', thOk:'OK', thErr:'Err', thPrompt:'Prompt Tokens', thCompl:'Completion Tokens', thAvgLat:'Avg latency', thMaxLat:'Max latency', thTime:'Time', thType:'Type', thStatus:'Status', thLatMs:'Latency', @@ -589,7 +594,7 @@ async function renderStatus() { `
| ${t('tName')} | ${t('tAdapter')} | ${t('tModels')} | ${t('tURL')} | ${t('tConn')} | ${t('tConc')} |
|---|---|---|---|---|---|
| ${t('srcEmpty')} | |||||
| ${t('tName')} | ${t('tAdapter')} | ${t('tModels')} | ${t('tURL')} | ${t('tConn')} | ${t('tConc')} |
|---|---|---|---|---|---|
| ${t('srcEmpty')} | |||||
| ${t('tName')} | ${t('tVersion')} |
|---|---|
| ${esc(a.name)} | ${esc(a.version || '')} |
+ +
+ ++
+| ${t('thKey')} | ${t('thReqs')} | ${t('thOk')} | ${t('thErr')} | ${t('thTokens')} | ${t('thAvgLat')} |
|---|---|---|---|---|---|
| + rows.map(r => ` | |||||
| ${fmtN(r.reqs)} | ${fmtN(r.ok)} | ${fmtN(r.err)} | ${fmtN(r.tokens)} | ${fmtMs(fmtLat(r.latency_sum_ms, r.reqs))} |
| ${t('thTime')} | ${t('thStatus')} | ${t('thKey')} | ${t('thType')} | ${t('thModel')} | ${t('thSrc')} | @@ -699,7 +733,7 @@ function paintRecords(records) { records.slice().reverse().map(r => `|||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ${fmtTime(r.time)} | ${r.ok ? `${r.status || 200}` : `${r.status || 500}`} | -${esc(r.key)} | ${esc(r.type)} | ${esc(r.model)} | ${esc(r.source || '')} | +${esc(keyNames[r.key] ? keyNames[r.key] + ' · ' + r.key : r.key)} | ${esc(r.type)} | ${esc(r.model)} | ${esc(r.source || '')} | ${fmtN(r.prompt_tokens)} | ${fmtN(r.completion_tokens)} | ${fmtMs(r.latency_ms)} |
+