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() { `${esc(x.name)}${esc(x.adapter)} ${x.models.map(m => `${esc(m)}`).join('')} ${esc(x.base_url || '')} - ${x.available ? `${t('online')}` : `${t('offline')}`} + ${x.live_available ? `${t('online')}` : `${t('offline')}`} ${x.max_concurrent}`).join(''); $('#tab-status').innerHTML = `
@@ -601,21 +606,21 @@ async function renderStatus() {
+

${t('srcTitle')} (${s.sources.length})

+ ${srcRows || ``}
${t('tName')}${t('tAdapter')}${t('tModels')}${t('tURL')}${t('tConn')}${t('tConc')}
${t('srcEmpty')}
+

${t('dashModel')}

${t('dashSrc')}

${t('dashKey')}

-

${t('dashRecs')}

+

${t('dashRecs')}

${t('recFilter')}
-

${t('srcTitle')} (${s.sources.length})

- ${srcRows || ``}
${t('tName')}${t('tAdapter')}${t('tModels')}${t('tURL')}${t('tConn')}${t('tConc')}
${t('srcEmpty')}
-

${t('adTitle')} (${s.adapters.length})

${s.adapters.map(a => ``).join('')}
${t('tName')}${t('tVersion')}
${esc(a.name)}${esc(a.version || '')}
@@ -636,6 +641,35 @@ function renderKeySelect(keys) { if (cur) sel.value = cur; } function renderKeyF(v) { statsKeyF = v; paintStats(); } +function openExportModal() { + const now = new Date(); + const ago = d => { const x = new Date(now); x.setDate(x.getDate() - d); return x.toISOString().slice(0, 10); }; + const wrap = document.createElement('div'); wrap.id = 'modal-wrap'; + wrap.style.cssText = 'position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50'; + wrap.innerHTML = `

${t('exportCsv')}

+

+ +

+ +
+
+

+

+
`; + document.body.appendChild(wrap); +} +function downloadStatsCsv(from, to) { + const q = new URLSearchParams({ export: 'csv', from: String(Math.floor(from)), to: String(Math.floor(to)) }); + if (statsKeyF) q.set('key', statsKeyF); + location.href = '/api/stats?' + q.toString(); + const w = $('#modal-wrap'); if (w) w.remove(); +} +function downloadStatsCsvFromForm() { + const f = $('#exp-from').value, t0 = $('#exp-to').value; + const from = f ? new Date(f + 'T00:00:00').getTime() : 0; + const to = t0 ? new Date(t0 + 'T23:59:59').getTime() : Date.now(); + downloadStatsCsv(from, to); +} async function paintStats() { try { const q = '/api/stats?limit=500' + (statsKeyF ? '&key=' + encodeURIComponent(statsKeyF) : ''); @@ -652,8 +686,8 @@ async function paintStats() {
${t('kpiLat')}
${fmtMs(avg)}
${t('kpiMaxLat')} ${fmtMs(tot.latency_max_ms)}
`; paintModelTable(st.by_model || []); paintSrcTable(st.by_source || []); - paintKeyTable(st.by_key || []); - paintRecords(st.records || []); + paintKeyTable(st.by_key || [], st.key_names || {}); + paintRecords(st.records || [], st.key_names || {}); renderKeySelect((st.by_key || []).map(k => k.name)); } catch (e) { console.error('[stats]', e); } } @@ -682,16 +716,16 @@ function paintSrcTable(rows) { ${fmtN(r.tokens)} ${fmtMs(fmtLat(r.latency_sum_ms, r.reqs))}${fmtMs(r.latency_max_ms)}`).join('') + '
'; } -function paintKeyTable(rows) { +function paintKeyTable(rows, keyNames) { const el = $('#tb-key'); if (!el) return; if (!rows.length) { el.innerHTML = `
${t('noUsage')}
`; return; } el.innerHTML = `
` + - rows.map(r => ` + rows.map(r => ``).join('') + '
${t('thKey')}${t('thReqs')}${t('thOk')}${t('thErr')} ${t('thTokens')}${t('thAvgLat')}
${fmtN(r.reqs)}${fmtN(r.ok)}${fmtN(r.err)} ${fmtN(r.tokens)}${fmtMs(fmtLat(r.latency_sum_ms, r.reqs))}
'; } -function paintRecords(records) { +function paintRecords(records, keyNames) { const el = $('#tb-recs'); if (!el) return; if (!records.length) { el.innerHTML = `
${t('noUsage')}
`; return; } el.innerHTML = ` @@ -699,7 +733,7 @@ function paintRecords(records) { records.slice().reverse().map(r => ` - + `).join('') + '
${t('thTime')}${t('thStatus')}${t('thKey')}${t('thType')}${t('thModel')}${t('thSrc')}
${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)}
'; } function showModelConfig(srcName, model) { @@ -957,7 +991,7 @@ function removeChatImg(i) { chatImages.splice(i, 1); renderChatImgs(); } async function renderSources() { const j = await api('/api/sources'); const rows = j.sources.map(s => `${esc(s.name)}${esc(s.base_url)}${esc(s.adapter)} - ${s.models.map(m => `${esc(m.id)} ·${m.priority||0}`).join('')} + ${s.models.map(m => `${esc(m.id)}`).join('')} `).join(''); $('#tab-sources').innerHTML = ` @@ -1006,9 +1040,8 @@ function editSource(name) { }).catch(() => {}); } function modelRow(m, i) { - return `
+ return `
-
`; @@ -1020,7 +1053,7 @@ function addModelRow() { async function saveSource(btn) { const models = [...document.querySelectorAll('#s-models .model-row')].map(row => ({ id: row.querySelector('.m-id').value.trim(), - priority: parseInt(row.querySelector('.m-prio').value) || 0, + priority: parseInt(row.dataset.priority) || 0, kind: row.querySelector('.m-kind').value, })).filter(m => m.id); let meta = {}; @@ -1062,41 +1095,44 @@ function srcShort(name) { return (name || '?').slice(0, 2).toUpperCase(); } const sortState = { lanes: [], origin: null, drag: null }; async function renderSort() { const j = await api('/api/sources'); - const map = new Map(); - j.sources.forEach(s => (s.models || []).forEach(m => { - if (m.kind === 'image') return; // image models never share the chat chain - const p = m.priority || 0; - if (!map.has(p)) map.set(p, []); - map.get(p).push({ src: s.name, id: m.id }); - })); - 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; + const byModel = new Map(); + const byPair = new Map(); + const sourceRows = new Map(); + j.sources.forEach(s => (s.models || []).forEach(m => { + if (m.kind === 'image') return; + const it = { src: s.name, id: m.id, prio: m.priority || 0 }; + byPair.set(m.id + '|' + s.name, it); + if (!byModel.has(m.id)) byModel.set(m.id, it); + if (!sourceRows.has(it.prio)) sourceRows.set(it.prio, []); + sourceRows.get(it.prio).push({ src: s.name, id: m.id }); })); - // 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; + if (autoR.length) { + const tiers = []; + autoR.forEach((r, i) => { + const src = normSrc(r.source); + const pair = src ? byPair.get(r.model + '|' + src) : null; + if (src && !pair) return; + const ref = pair || byModel.get(r.model) || { src: '*', id: r.model }; + const ti = Math.max(1, parseInt(r.tier) || (i + 1)) - 1; + if (!tiers[ti]) tiers[ti] = []; + tiers[ti].push({ + src: ref.src || '*', id: r.model, uid: nexUid(), + meta: { quota: r.token_quota || 0, period: r.period || '', hours: r.hours || 0 }, + }); + }); + sortState.lanes = tiers.filter(Boolean).map((models, i) => ({ prio: (tiers.length - i) * 10, models })); + } else { + sortState.lanes = [...sourceRows.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(); m.meta = null; }); + return { prio, models }; + }); + } + // model picker for the "add slot" control: one option per (source, model) + // pair so identical model ids on different sources stay distinguishable + await loadModelPairs(); sortState.origin = JSON.stringify(sortState.lanes); $('#tab-sort').innerHTML = `

${t('sortTitle')} @@ -1112,17 +1148,17 @@ async function renderSort() {

`; paintSort(); } -function scrBlockHtml(it, isFirst, li, ji) { +function scrBlockHtml(it, isFirst, li, ji, extraClass) { const c = srcColor(it.src); const s = srcShort(it.src); return ` -
${isFirst ? '' : ''} ${esc(it.src === '*' ? '+' : s)} - ${esc(it.id)} + ${esc(it.id)}${esc(it.src === '*' ? t('kAnySrc') : it.src)} ${it.meta ? `${esc(quantBadge(it.meta.quota, it.meta.period, it.meta.hours))}` : ''} × @@ -1178,6 +1214,10 @@ function paintSortNow(affected) { }); cv.querySelectorAll('.scr-lane').forEach(l => prev.set('lane:' + l.dataset.lane, l.getBoundingClientRect())); cv.querySelectorAll('.scr-block').forEach(b => prev.set('blk:' + b.dataset.key, b.getBoundingClientRect())); + const preGrowL = new Set(); + sortState.lanes.forEach(lane => lane.models.forEach((m, i) => { + if (i > 0 && (prevFirst.has(m.uid) || !prev.has('blk:' + m.uid))) preGrowL.add(m.uid); + })); const html = []; sortState.lanes.forEach((lane, li) => { const n = sortState.lanes.length - li; @@ -1185,7 +1225,7 @@ function paintSortNow(affected) { html.push(`
`); html.push(`
${Array.from({ length: n }, () => '').join('')}
-
${lane.models.map((m, i) => scrBlockHtml(m, i === 0, li, i)).join('')}
+
${lane.models.map((m, i) => scrBlockHtml(m, i === 0, li, i, preGrowL.has(m.uid) ? 'pre-grow-l' : '')).join('')}
`); }); html.push(`
`); @@ -1243,7 +1283,7 @@ function paintSortNow(affected) { const peer2 = cv.querySelector(`.scr-lane[data-lane="${li + 1}"] .scr-block`); if (peer2) peer2.classList.add('grow-t'); } - if (!isFirst && prevFirst.has(key)) b.classList.add('grow-l'); + if (!isFirst && (prevFirst.has(key) || !prev.has('blk:' + key))) { b.classList.remove('pre-grow-l'); void b.offsetWidth; b.classList.add('grow-l'); } if (!isLast && prevLast.has(key)) b.classList.add('grow-r'); }); }); @@ -1432,41 +1472,22 @@ function sortReset() { paintSort(all); } async function saveSort() { - const snap = await api('/api/sources'); - const byName = {}; snap.sources.forEach(x => byName[x.name] = x); - const groups = {}; - sortState.lanes.forEach((lane, li) => lane.models.forEach(it => { - (groups[it.src] = groups[it.src] || []).push({ id: it.id, priority: (sortState.lanes.length - li) * 10 }); - })); - let dirty = 0; - for (const name of Object.keys(groups)) { - const base = byName[name]; - if (!base) continue; - const seen = new Set(); - const ordered = groups[name].filter(it => { if (seen.has(it.id)) return false; seen.add(it.id); return true; }) - .map(it => ({ id: it.id, priority: it.priority, kind: 'chat' })); - (base.models || []).forEach(x => { - if (seen.has(x.id)) return; - ordered.push({ id: x.id, priority: x.priority || 0, kind: x.kind === 'image' ? 'image' : 'chat' }); - }); - if (JSON.stringify(ordered) !== JSON.stringify(base.models)) { - base.models = ordered; - await api('/api/sources', { method: 'POST', body: JSON.stringify(base) }); - dirty++; - } - } - toast(dirty ? t('sortSaved') : t('sortNoChange')); - sortState.origin = JSON.stringify(sortState.lanes); - try { await persistAuto(); } catch (e) { toast(e.message); } + try { + await persistAuto(); + sortState.origin = JSON.stringify(sortState.lanes); + toast(t('sortSaved')); + } catch (e) { toast(e.message); } } /* ---------- AUTO quota editing on sort blocks ---------- */ async function persistAuto() { const rules = []; - sortState.lanes.forEach(lane => lane.models.forEach(it => { + sortState.lanes.forEach((lane, li) => lane.models.forEach(it => { const m = it.meta; rules.push({ model: it.id, + source: it.src === '*' ? undefined : it.src, + tier: li + 1, token_quota: m && m.quota ? +m.quota : 0, period: m && m.period ? m.period : '', hours: m && m.hours ? +m.hours : 0, @@ -1479,8 +1500,10 @@ function scrAddModal() { const wrap = document.createElement('div'); wrap.id = 'modal-wrap'; wrap.style.cssText = 'position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50'; wrap.innerHTML = `

${t('sortAdd')}

- - + + @@ -1504,14 +1527,15 @@ function scrAddModal() { $('#a-model').focus(); } function scrAddFromForm() { - const model = $('#a-model').value.trim(); - if (!model) { toast(t('kName')); return; } + const raw = $('#a-model').value.trim(); + if (!raw) { toast(t('kName')); return; } + const [id, src] = raw.split('|'); 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 } }] }); + sortState.lanes.push({ models: [{ src: src || '*', id: id, 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]); @@ -1544,7 +1568,7 @@ function sortScopeEdit(li, ji) { const cur = it.meta || { quota: 0, period: '', hours: 0 }; const wrap = document.createElement('div'); wrap.id = 'modal-wrap'; wrap.style.cssText = 'position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50'; - wrap.innerHTML = `

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

+ wrap.innerHTML = `

${t('kFormTitle')} · ${esc(it.id)}${it.src !== '*' ? ' · ' + esc(it.src) : ''}

@@ -1654,6 +1678,17 @@ async function delAdapter(name) { /* ---------- keys tab ---------- */ let allModels = []; let scopeDragEl = null; +async function loadModelPairs() { + let srcs = []; + try { srcs = (await api('/api/sources')).sources || []; } catch (e) {} + const pairs = []; + (srcs || []).forEach(s => (s.models || []).forEach(m => { + if (m.kind === 'image') return; + pairs.push({ src: s.name, id: m.id, label: m.id + ' · ' + s.name, key: m.id + '|' + s.name }); + })); + if (pairs.length) { allModels = pairs; return; } + try { allModels = (await api('/api/status')).models || []; } catch (e) { allModels = []; } +} function maskKey(k) { return k.length > 12 ? k.slice(0, 6) + '…' + k.slice(-6) : k; } function fmtCreated(ts) { if (!ts) return '—'; const d = new Date(ts * 1000); const p = x => String(x).padStart(2, '0'); @@ -1674,15 +1709,13 @@ async function renderKeysUser(me) { ${esc(me.key)} ${(me.models && me.models.length) - ? me.models.map(m => `${esc(m.model)}${m.token_quota ? ' · ' + esc(fmtQuota(m.token_quota)) : ''}`).join('') + ? me.models.map(m => { const src = normSrc(m.source); return `${esc(m.model)}${src ? ' · ' + esc(src) : ''}${m.token_quota ? ' · ' + esc(fmtQuota(m.token_quota)) : ''}` }).join('') : `${t('kAll')}`}
${t('kMeHint')}
`; } async function renderKeysAdmin() { - let models = []; - try { models = (await api('/api/status')).models || []; } catch (e) {} - allModels = models; + await loadModelPairs(); $('#tab-keys').innerHTML = `
@@ -1712,6 +1745,7 @@ function keyCanvasHtml(k) { ${fmtCreated(k.created_at)} +
${scopes.length ? '' : `${t('kEmptyB')}`} @@ -1722,17 +1756,23 @@ function keyCanvasHtml(k) { } function scopeHtml(key, m) { const qt = fmtQuota(m.token_quota); - const attrs = `data-key="${escAttr(key)}" data-model="${escAttr(m.model)}" + const comb = scopeComb(m); + const src = normSrc(m.source); + const attrs = `data-key="${escAttr(key)}" data-model="${escAttr(comb)}" data-quota="${m.token_quota || 0}" data-period="${escAttr(m.period || '')}" data-hours="${m.hours || 0}"`; return ` + onclick="scopeEdit('${escAttr(key)}','${escAttr(comb)}')" + oncontextmenu="scopeCtx(event,'${escAttr(key)}','${escAttr(comb)}')"> - ${esc(m.model)} + ${esc(m.model)}${src ? `${esc(src)}` : ''} ${esc(quantBadge(m.token_quota, m.period, m.hours))} - + `; } +function normSrc(s) { return (!s || s === 'undefined' || s === 'null') ? '' : s; } +function scopeComb(m) { const s = normSrc(m.source); return s ? m.model + '|' + s : m.model; } +function splitCombKey(comb) { const p = String(comb).split('|'); return { model: p[0], source: normSrc(p[1]) }; } +function scopeUncomb(comb) { const p = String(comb).split('|'); return { model: p[0], source: p[1] || '' }; } 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'; @@ -1753,12 +1793,17 @@ function quantBadge(quota, period, hours) { return fmtQuota(quota) + periodText(period, hours); } 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, - })); + return [...canvas.querySelectorAll('.mb')].map(b => { + const comb = b.dataset.model.split('|'); + const src = comb.length > 1 ? normSrc(comb[1]) : ''; + return { + model: comb[0], + source: src || undefined, + 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 }) }); @@ -1772,48 +1817,51 @@ async function scopePush(key) { await loadKeys(); scopeEdit(key, 'AUTO'); } -async function scopeDup(key, model) { +async function scopeDup(key, comb) { 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); + const src = scopes.find(s => scopeComb(s) === comb); if (!src) return; - scopes.push({ model: src.model, token_quota: src.token_quota }); + scopes.push({ model: src.model, source: src.source, token_quota: src.token_quota }); try { await putScope(key, scopes); toast(t('kDupOK')); } catch (e) { toast(e.message); } } -async function scopeRm(key, model) { +async function scopeRm(key, comb) { const canvas = document.querySelector(`.key-canvas[data-key="${CSS.escape(key)}"]`); if (!canvas) return; - const scopes = readScopes(canvas).filter(s => s.model !== model); + const scopes = readScopes(canvas).filter(s => scopeComb(s) !== comb); try { await putScope(key, scopes); } catch (e) { toast(e.message); return; } await loadKeys(); toast(t('toastDelOk')); } -function scopeEdit(key, model) { +function scopeEdit(key, comb) { 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 sc = readScopes(canvas).find(s => scopeComb(s) === comb) + || { model: '', source: '', token_quota: 0, period: '', hours: 0 }; + const curM = sc.model; 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); + const opts = [{ key: 'AUTO', label: 'AUTO' }].concat(allModels.map(m => typeof m === 'string' + ? { key: m, label: m } : { key: m.key, label: m.label })); + if (curM && !opts.some(o => o.key === comb)) opts.unshift({ key: comb, label: comb }); wrap.innerHTML = `

${t('kFormTitle')}

- - + + + placeholder="${escAttr(t('kQuotaHintB'))}" value="${sc.token_quota ? sc.token_quota : ''}"> -

+

+

`; document.body.appendChild(wrap); @@ -1823,20 +1871,22 @@ function scopeEdit(key, model) { }); $('#sc-model').focus(); } -async function scopeSave(key, oldModel, btn) { +async function scopeSave(key, oldComb, 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; } + const comb = $('#sc-model').value.trim(); + if (!comb) { toast(t('kName')); return; } + const parts = splitCombKey(comb); let q = parseInt($('#sc-quota').value); - if (isNaN(q) || isNaN(q) || q < 0) q = 0; + if (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 }; + const entry = { model: parts.model, source: parts.source || undefined, token_quota: q, period, hours }; + const i = scopes.findIndex(s => scopeComb(s) === oldComb); + if (i < 0) scopes.push(entry); + else scopes[i] = entry; if (btn) btn.disabled = true; try { await putScope(key, scopes); @@ -1892,16 +1942,16 @@ function bindCanvasDrop(cv) { moveBrick(scopeDragEl.key, cv.dataset.key, scopeDragEl.model); }); } -async function moveBrick(from, to, model) { +async function moveBrick(from, to, comb) { 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); + const b = fs.find(s => scopeComb(s) === comb); if (!b) return; - fs = fs.filter(s => s.model !== model); - const dup = ts.some(s => s.model === model); + fs = fs.filter(s => scopeComb(s) !== comb); + const dup = ts.some(s => scopeComb(s) === comb); if (!dup) ts.push(b); try { await putScope(from, fs); diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 1988157..565a743 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -57,9 +57,14 @@ type Provider struct { adapter string client *http.Client - mu sync.Mutex - sem chan struct{} - health health + mu sync.Mutex + sem chan struct{} + health health + lastProbe struct { + ok bool + err string + at int64 + } } func New(cfg config.Source, vm *lua.VM) *Provider { @@ -175,6 +180,48 @@ func (p *Provider) Available() bool { return p.health.available() } +// Probe performs a lightweight reachability + auth check against the source +// using its best chat model (1-token). It records the result for Status(). +func (p *Provider) Probe(ctx context.Context) (bool, string) { + ok := false + msg := "" + model := p.bestChatModel() + if pm := p.ModelByID(model); pm != nil && pm.Kind == "image" { + model = "" + } + if model == "" { + if ms := p.Models(); len(ms) > 0 { + model = ms[0] + } + } + if model != "" { + _, err := p.Chat(ctx, &types.ChatRequest{ + Model: model, + Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}}, + MaxTokens: 1, + }) + if err == nil { + ok = true + } else { + msg = err.Error() + } + } else { + msg = "no chat model configured" + } + p.mu.Lock() + p.lastProbe.ok = ok + p.lastProbe.err = msg + p.lastProbe.at = time.Now().Unix() + p.mu.Unlock() + return ok, msg +} + +func (p *Provider) LastProbe() (bool, string, int64) { + p.mu.Lock() + defer p.mu.Unlock() + return p.lastProbe.ok, p.lastProbe.err, p.lastProbe.at +} + // ReportStatus records an upstream HTTP status for backoff decisions. func (p *Provider) ReportStatus(code int) { p.mu.Lock() @@ -331,21 +378,23 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch }() ch := make(chan types.UnifiedChunk, 64) + sel := <-rc + if sel.err != nil { + p.reportError() + p.Release() + return nil, sel.err + } + if sel.resp.StatusCode != 200 { + raw, _ := io.ReadAll(sel.resp.Body) + sel.resp.Body.Close() + p.ReportStatus(sel.resp.StatusCode) + p.Release() + return nil, fmt.Errorf("api error %d: %s", sel.resp.StatusCode, truncate(string(raw), 500)) + } go func() { defer p.Release() defer close(ch) - sel := <-rc - if sel.err != nil { - p.reportError() - return - } defer sel.resp.Body.Close() - if sel.resp.StatusCode != 200 { - raw, _ := io.ReadAll(sel.resp.Body) - p.ReportStatus(sel.resp.StatusCode) - _ = raw - return - } scanner := bufio.NewScanner(sel.resp.Body) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) for scanner.Scan() { diff --git a/internal/provider/registry.go b/internal/provider/registry.go index e072029..ed381ed 100644 --- a/internal/provider/registry.go +++ b/internal/provider/registry.go @@ -3,9 +3,11 @@ package provider import ( + "context" "sort" "strings" "sync" + "time" ) // Registry holds all configured providers and routes model requests. @@ -143,6 +145,36 @@ func (r *Registry) ProviderForModel(model string) *Provider { return p } +// ProviderForSlot returns the provider for a (model, source) slot. When +// source is empty it behaves like ProviderForModel (owner of the model id); +// when source is set it returns only that exact source (nil if the source +// does not serve the model). +func (r *Registry) ProviderForSlot(model, source string) *Provider { + model = strings.ToLower(strings.TrimSpace(model)) + source = strings.TrimSpace(source) + r.mu.RLock() + defer r.mu.RUnlock() + if source == "" { + p, ok := r.byModel[model] + if !ok { + return nil + } + return p + } + for _, p := range r.providers { + if !strings.EqualFold(p.Name(), source) { + continue + } + for _, m := range p.cfg.Models { + if strings.EqualFold(m.ID, model) { + return p + } + } + return nil + } + return nil +} + // Default returns the highest-priority available provider. func (r *Registry) Default() *Provider { chain := r.AUTOChain() @@ -161,6 +193,27 @@ type SourceStatus struct { Available bool `json:"available"` Healthy bool `json:"healthy"` MaxConcurrent int `json:"max_concurrent"` + LiveAvailable bool `json:"live_available"` + LastError string `json:"last_error,omitempty"` + LastChecked int64 `json:"last_checked,omitempty"` +} + +// ProbeAll runs a live reachability check for every provider (in parallel). +func (r *Registry) ProbeAll(ctx context.Context) { + r.mu.RLock() + providers := append([]*Provider(nil), r.providers...) + r.mu.RUnlock() + var wg sync.WaitGroup + for _, p := range providers { + wg.Add(1) + go func(p *Provider) { + defer wg.Done() + probeCtx, cancel := context.WithTimeout(ctx, 6*time.Second) + defer cancel() + p.Probe(probeCtx) + }(p) + } + wg.Wait() } func (r *Registry) Status() []SourceStatus { @@ -168,16 +221,20 @@ func (r *Registry) Status() []SourceStatus { defer r.mu.RUnlock() out := make([]SourceStatus, 0, len(r.providers)) for _, p := range r.providers { - s := SourceStatus{ - Name: p.Name(), - Adapter: p.Adapter(), - BaseURL: p.Config().BaseURL, - Models: p.Models(), - Available: p.Available(), - Healthy: p.Available(), - MaxConcurrent: p.MaxConcurrent(), - } - out = append(out, s) + live, lastErr, lastAt := p.LastProbe() + s := SourceStatus{ + Name: p.Name(), + Adapter: p.Adapter(), + BaseURL: p.Config().BaseURL, + Models: p.Models(), + Available: p.Available(), + Healthy: p.Available(), + MaxConcurrent: p.MaxConcurrent(), + LiveAvailable: live, + LastError: lastErr, + LastChecked: lastAt, + } + out = append(out, s) } return out } \ No newline at end of file