feat(auto): AUTO-only priority chain with tiered slots + live source probing; CSV export w/ key names; audit persistence; fix prompt token accounting & deepseek thinking

This commit is contained in:
root
2026-08-10 00:10:19 +08:00
parent 859d310ad3
commit d48b993010
10 changed files with 663 additions and 210 deletions

View File

@ -122,6 +122,8 @@ func (c *Config) ApplyDefaults() error {
// RuntimeConfig is the persisted web-UI editable slice (sources added/edited).
type RuntimeConfig struct {
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"`
}
@ -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

View File

@ -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()

View File

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

View File

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

View File

@ -108,24 +108,39 @@ 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] {
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()

View File

@ -14,6 +14,8 @@ import (
"net/http"
"net/url"
"strings"
"sync"
"time"
"llmsproxy/internal/config"
"llmsproxy/internal/core"
@ -27,6 +29,8 @@ type Gateway struct {
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()

View File

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

View File

@ -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() {
`<tr><td><b>${esc(x.name)}</b></td><td>${esc(x.adapter)}</td>
<td>${x.models.map(m => `<span class="tag tag-blue modelchip" onclick="showModelConfig('${escAttr(x.name)}','${escAttr(m)}')">${esc(m)}</span>`).join('')}</td>
<td><span class="muted">${esc(x.base_url || '')}</span></td>
<td>${x.available ? `<span class="tag tag-green">${t('online')}</span>` : `<span class="tag tag-red">${t('offline')}</span>`}</td>
<td>${x.live_available ? `<span class="tag tag-green"><i class="net-dot"></i>${t('online')}</span>` : `<span class="tag tag-red" title="${esc(x.last_error || '')}"><i class="net-dot"></i>${t('offline')}</span>`}</td>
<td>${x.max_concurrent}</td></tr>`).join('');
$('#tab-status').innerHTML = `
<div class="kpis" id="kpi-row"></div>
@ -601,21 +606,21 @@ async function renderStatus() {
<label>${t('tModels')}</label>
<div class="chips" id="model-chips"></div>
</div>
<div class="card"><h2>${t('srcTitle')} (${s.sources.length})</h2>
<table><tr><th>${t('tName')}</th><th>${t('tAdapter')}</th><th>${t('tModels')}</th><th>${t('tURL')}</th><th>${t('tConn')}</th><th>${t('tConc')}</th></tr>${srcRows || `<tr><td colspan="6" class="empty">${t('srcEmpty')}</td></tr>`}</table>
</div>
<div class="dash-row">
<div class="card"><h2>${t('dashModel')}</h2><div id="tb-model"></div></div>
<div class="card"><h2>${t('dashSrc')}</h2><div id="tb-src"></div></div>
</div>
<div class="card"><h2>${t('dashKey')}</h2><div id="tb-key"></div></div>
<div class="card"><h2>${t('dashRecs')}</h2>
<div class="card"><h2><span>${t('dashRecs')}</span><span class="grow"></span><button class="ghost small" onclick="openExportModal()">${t('exportCsv')}</button></h2>
<div class="filter-line">
<span class="muted">${t('recFilter')}</span>
<select id="rec-key" onchange="renderKeyF(this.value)"></select>
</div>
<div class="recs-scroll" id="tb-recs"></div>
</div>
<div class="card"><h2>${t('srcTitle')} (${s.sources.length})</h2>
<table><tr><th>${t('tName')}</th><th>${t('tAdapter')}</th><th>${t('tModels')}</th><th>${t('tURL')}</th><th>${t('tConn')}</th><th>${t('tConc')}</th></tr>${srcRows || `<tr><td colspan="6" class="empty">${t('srcEmpty')}</td></tr>`}</table>
</div>
<div class="card"><h2>${t('adTitle')} (${s.adapters.length})</h2>
<table><tr><th>${t('tName')}</th><th>${t('tVersion')}</th></tr>
${s.adapters.map(a => `<tr><td>${esc(a.name)}</td><td>${esc(a.version || '')}</td></tr>`).join('')}</table>
@ -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 = `<div class="card" style="width:420px;max-width:100%"><h2>${t('exportCsv')}</h2>
<p><button onclick="downloadStatsCsv(${Date.now() - 7*864e5}, ${Date.now()})">${t('expWeek')}</button>
<button onclick="downloadStatsCsv(${Date.now() - 30*864e5}, ${Date.now()})">${t('expMonth')}</button>
<button onclick="downloadStatsCsv(${Date.now() - 365*864e5}, ${Date.now()})">${t('expYear')}</button></p>
<label>${t('expRange')}</label>
<div class="row"><div><label>${t('expStart')}</label><input id="exp-from" type="date" value="${ago(7)}"></div>
<div><label>${t('expEnd')}</label><input id="exp-to" type="date" value="${now.toISOString().slice(0, 10)}"></div></div>
<p><button onclick="downloadStatsCsvFromForm()">${t('expDownload')}</button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t('mCancel')}</button></p>
</div>`;
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() {
<div class="kpi"><div class="k-lab">${t('kpiLat')}</div><div class="k-val">${fmtMs(avg)}</div><div class="k-sub">${t('kpiMaxLat')} ${fmtMs(tot.latency_max_ms)}</div></div>`;
paintModelTable(st.by_model || []);
paintSrcTable(st.by_source || []);
paintKeyTable(st.by_key || []);
paintRecords(st.records || []);
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) {
<td class="num">${fmtN(r.tokens)}</td>
<td class="num">${fmtMs(fmtLat(r.latency_sum_ms, r.reqs))}</td><td class="num">${fmtMs(r.latency_max_ms)}</td></tr>`).join('') + '</table></div>';
}
function paintKeyTable(rows) {
function paintKeyTable(rows, keyNames) {
const el = $('#tb-key'); if (!el) return;
if (!rows.length) { el.innerHTML = `<div class="muted">${t('noUsage')}</div>`; return; }
el.innerHTML = `<div class="tbl-wrap"><table><tr><th>${t('thKey')}</th><th class="num">${t('thReqs')}</th><th class="num">${t('thOk')}</th><th class="num">${t('thErr')}</th>
<th class="num">${t('thTokens')}</th><th class="num">${t('thAvgLat')}</th></tr>` +
rows.map(r => `<tr><td><button class="ghost small" onclick="renderKeyF('${escAttr(r.name)}')">${esc(r.name)}</button></td>
rows.map(r => `<tr><td><button class="ghost small" onclick="renderKeyF('${escAttr(r.name)}')">${esc(keyNames[r.name] ? keyNames[r.name] + ' · ' + r.name : r.name)}</button></td>
<td class="num">${fmtN(r.reqs)}</td><td class="num okc">${fmtN(r.ok)}</td><td class="num errc">${fmtN(r.err)}</td>
<td class="num">${fmtN(r.tokens)}</td><td class="num">${fmtMs(fmtLat(r.latency_sum_ms, r.reqs))}</td></tr>`).join('') + '</table></div>';
}
function paintRecords(records) {
function paintRecords(records, keyNames) {
const el = $('#tb-recs'); if (!el) return;
if (!records.length) { el.innerHTML = `<div class="muted">${t('noUsage')}</div>`; return; }
el.innerHTML = `<table><tr><th>${t('thTime')}</th><th class="num">${t('thStatus')}</th><th>${t('thKey')}</th><th>${t('thType')}</th><th>${t('thModel')}</th><th>${t('thSrc')}</th>
@ -699,7 +733,7 @@ function paintRecords(records) {
records.slice().reverse().map(r => `<tr>
<td class="t-tag">${fmtTime(r.time)}</td>
<td class="num">${r.ok ? `<span class="tag tag-green">${r.status || 200}</span>` : `<span class="tag tag-red" title="${esc(r.error || '')}">${r.status || 500}</span>`}</td>
<td>${esc(r.key)}</td><td class="t-tag">${esc(r.type)}</td><td>${esc(r.model)}</td><td>${esc(r.source || '')}</td>
<td>${esc(keyNames[r.key] ? keyNames[r.key] + ' · ' + r.key : r.key)}</td><td class="t-tag">${esc(r.type)}</td><td>${esc(r.model)}</td><td>${esc(r.source || '')}</td>
<td class="num">${fmtN(r.prompt_tokens)}</td><td class="num">${fmtN(r.completion_tokens)}</td><td class="num">${fmtMs(r.latency_ms)}</td></tr>`).join('') + '</table>';
}
function showModelConfig(srcName, model) {
@ -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 => `<tr><td><b>${esc(s.name)}</b></td><td>${esc(s.base_url)}</td><td>${esc(s.adapter)}</td>
<td>${s.models.map(m => `<span class="tag tag-blue">${esc(m.id)}<span class="muted"> ·${m.priority||0}</span></span>`).join('')}</td>
<td>${s.models.map(m => `<span class="tag tag-blue">${esc(m.id)}</span>`).join('')}</td>
<td><button class="ghost small" onclick="editSource(${JSON.stringify(s.name).replace(/"/g,'&quot;')})">${t('srcEdit')}</button>
<button class="danger small" onclick="delSource('${escAttr(s.name)}')">${t('srcDel')}</button></td></tr>`).join('');
$('#tab-sources').innerHTML = `
@ -1006,9 +1040,8 @@ function editSource(name) {
}).catch(() => {});
}
function modelRow(m, i) {
return `<div class="model-row">
return `<div class="model-row" data-priority="${m.priority || 0}">
<input data-mi="${i}" class="m-id" placeholder="model-id" value="${escAttr(m.id)}">
<input data-mi="${i}" class="m-prio" type="number" placeholder="priority" value="${m.priority || 0}" style="width:90px">
<select data-mi="${i}" class="m-kind"><option ${(m.kind==='image')?'':'selected'} value="chat">chat</option><option ${(m.kind==='image')?'selected':''} value="image">image</option></select>
<button class="ghost del small" onclick="this.closest('.model-row').remove()">&times;</button>
</div>`;
@ -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 } }] });
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 },
});
// model picker for the "add slot" control
let addModels = [];
try { addModels = (await api('/api/status')).models || []; } catch (e) {}
allModels = addModels;
});
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 = `
<div class="card"><h2><span>${t('sortTitle')}</span><span class="grow"></span>
@ -1112,17 +1148,17 @@ async function renderSort() {
</div>`;
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 `
<div class="scr-block" data-key="${escAttr(it.uid)}" data-li="${li}" data-ji="${ji}"
<div class="scr-block ${escAttr(extraClass || '')}" data-key="${escAttr(it.uid)}" data-li="${li}" data-ji="${ji}"
style="background:linear-gradient(135deg,${c[0]},${c[1]})"
onclick="scrCtx(event,'${li}','${ji}')"
oncontextmenu="scrCtx(event,'${li}','${ji}')">
${isFirst ? '<i class="scr-knob"></i><i class="scr-slot"></i>' : ''}
<span class="scr-ico">${esc(it.src === '*' ? '+' : s)}</span>
<span class="scr-name">${esc(it.id)}</span>
<span class="scr-name">${esc(it.id)}<em class="scr-srcname">${esc(it.src === '*' ? t('kAnySrc') : it.src)}</em></span>
${it.meta ? `<span class="scr-tag">${esc(quantBadge(it.meta.quota, it.meta.period, it.meta.hours))}</span>` : ''}
<span class="scr-x" title="${escAttr(t('kDelB2'))}" onclick="event.stopPropagation();scrDelSlot('${li}','${ji}')">×</span>
<span class="scr-grip"><i></i><i></i><i></i></span>
@ -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(`<div class="scr-gap" data-gap="${li}"></div>`);
html.push(`<div class="${cls}" data-lane="${li}">
<div class="scr-tier">${Array.from({ length: n }, () => '<span></span>').join('')}</div>
<div class="scr-row">${lane.models.map((m, i) => scrBlockHtml(m, i === 0, li, i)).join('')}</div>
<div class="scr-row">${lane.models.map((m, i) => scrBlockHtml(m, i === 0, li, i, preGrowL.has(m.uid) ? 'pre-grow-l' : '')).join('')}</div>
</div>`);
});
html.push(`<div class="scr-gap" data-gap="${sortState.lanes.length}"></div>`);
@ -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'));
try {
await persistAuto();
sortState.origin = JSON.stringify(sortState.lanes);
try { await persistAuto(); } catch (e) { toast(e.message); }
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 = `<div class="card" style="width:400px;max-width:100%"><h2>${t('sortAdd')}</h2>
<label>${t('kModelB')}</label>
<select id="a-model">${allModels.map(m => `<option value="${escAttr(m)}">${esc(m)}</option>`).join('')}</select>
<label>${t('kModelB')} <span class="muted">${t('kSrcHint')}</span></label>
<select id="a-model">${allModels.map(m => typeof m === 'string'
? `<option value="${escAttr(m)}">${esc(m)}</option>`
: `<option value="${escAttr(m.key)}">${esc(m.label)}</option>`).join('')}</select>
<label>${t('kQuotaB')} <span class="muted">${t('kQuotaHintB')}</span></label>
<input id="a-quota" type="number" min="0" step="1" placeholder="${escAttr(t('kQuotaHintB'))}">
<label>${t('kPeriodB')}</label>
@ -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 = `<div class="card" style="width:400px;max-width:100%"><h2>${t('kFormTitle')} · ${esc(it.id)}</h2>
wrap.innerHTML = `<div class="card" style="width:400px;max-width:100%"><h2>${t('kFormTitle')} · ${esc(it.id)}${it.src !== '*' ? ' · ' + esc(it.src) : ''}</h2>
<label>${t('kQuotaB')} <span class="muted">${t('kQuotaHintB')}</span></label>
<input id="q-quota" type="number" min="0" step="1"
placeholder="${escAttr(t('kQuotaHintB'))}" value="${cur.quota || ''}">
@ -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) {
<td><span class="kr-key">${esc(me.key)}</span>
<button class="ghost small" onclick="copyText('${escAttr(me.key)}')">${t('kCopy')}</button></td>
<td>${(me.models && me.models.length)
? me.models.map(m => `<span class="tag tag-blue" title="${m.token_quota ? 'quota ' + fmtQuota(m.token_quota) : t('kQuotaUnlim')}">${esc(m.model)}${m.token_quota ? ' · ' + esc(fmtQuota(m.token_quota)) : ''}</span>`).join('')
? me.models.map(m => { const src = normSrc(m.source); return `<span class="tag tag-blue" title="${m.token_quota ? 'quota ' + fmtQuota(m.token_quota) : t('kQuotaUnlim')}">${esc(m.model)}${src ? ' · ' + esc(src) : ''}${m.token_quota ? ' · ' + esc(fmtQuota(m.token_quota)) : ''}</span>` }).join('')
: `<span class="scope-unlim">${t('kAll')}</span>`}</td></tr></table>
<div class="muted" style="margin-top:10px">${t('kMeHint')}</div>
</div>`;
}
async function renderKeysAdmin() {
let models = [];
try { models = (await api('/api/status')).models || []; } catch (e) {}
allModels = models;
await loadModelPairs();
$('#tab-keys').innerHTML = `
<div class="key-actions">
<span class="grow"></span>
@ -1712,6 +1745,7 @@ function keyCanvasHtml(k) {
<span class="grow"></span>
<span class="muted">${fmtCreated(k.created_at)}</span>
<button class="ghost small errc" onclick="delKey('${escAttr(k.key)}','${escAttr(k.name || '')}')">${t('kDel')}</button>
<button class="ghost small errc" title="${escAttr(t('kDel'))}" onclick="delKey('${escAttr(k.key)}','${escAttr(k.name || '')}')">×</button>
</div>
<div class="kc-blocks" data-key="${escAttr(k.key)}">
${scopes.length ? '' : `<span class="mb-empty">${t('kEmptyB')}</span>`}
@ -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 `<span class="mb" draggable="true" ${attrs} title="${escAttr(t('kBrickH'))}"
onclick="scopeEdit('${escAttr(key)}','${escAttr(m.model)}')"
oncontextmenu="scopeCtx(event,'${escAttr(key)}','${escAttr(m.model)}')">
onclick="scopeEdit('${escAttr(key)}','${escAttr(comb)}')"
oncontextmenu="scopeCtx(event,'${escAttr(key)}','${escAttr(comb)}')">
<span class="mb-ico">◆</span>
<span class="mb-name">${esc(m.model)}</span>
<span class="mb-name">${esc(m.model)}${src ? `<em class="mb-src">${esc(src)}</em>` : ''}</span>
<span class="mb-quota">${esc(quantBadge(m.token_quota, m.period, m.hours))}</span>
<span class="copy-b" title="${escAttr(t('kCopyB'))}" onclick="event.stopPropagation();scopeDup('${escAttr(key)}','${escAttr(m.model)}')">⧉</span>
<span class="copy-b" title="${escAttr(t('kCopyB'))}" onclick="event.stopPropagation();scopeDup('${escAttr(key)}','${escAttr(comb)}')">⧉</span>
</span>`;
}
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,
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 = `<div class="card" style="width:400px;max-width:100%"><h2>${t('kFormTitle')}</h2>
<label>${t('kModelB')}</label>
<select id="sc-model">${opts.map(m => `<option value="${escAttr(m)}" ${m === cur.model ? 'selected' : ''}>${esc(m)}</option>`).join('')}</select>
<label>${t('kModelB')} <span class="muted">${t('kSrcHint')}</span></label>
<select id="sc-model">${opts.map(o => `<option value="${escAttr(o.key)}" ${o.key === comb ? 'selected' : ''}>${esc(o.label)}</option>`).join('')}</select>
<label>${t('kQuotaB')} <span class="muted">${t('kQuotaHintB')}</span></label>
<input id="sc-quota" type="number" min="0" step="1"
placeholder="${escAttr(t('kQuotaHintB'))}" value="${cur.token_quota ? cur.token_quota : ''}">
placeholder="${escAttr(t('kQuotaHintB'))}" value="${sc.token_quota ? sc.token_quota : ''}">
<label>${t('kPeriodB')}</label>
<select id="sc-period">
<option value="" ${!cur.period ? 'selected' : ''}>${t('kPerNothing')}</option>
<option value="hour" ${cur.period === 'hour' ? 'selected' : ''}>${t('kPerHour')}</option>
<option value="week" ${cur.period === 'week' ? 'selected' : ''}>${t('kPerWeek')}</option>
<option value="month" ${cur.period === 'month' ? 'selected' : ''}>${t('kPerMonth')}</option>
<option value="nhour" ${cur.period === 'nhour' ? 'selected' : ''}>${t('kPerHours')}</option>
<option value="" ${!sc.period ? 'selected' : ''}>${t('kPerNothing')}</option>
<option value="hour" ${sc.period === 'hour' ? 'selected' : ''}>${t('kPerHour')}</option>
<option value="week" ${sc.period === 'week' ? 'selected' : ''}>${t('kPerWeek')}</option>
<option value="month" ${sc.period === 'month' ? 'selected' : ''}>${t('kPerMonth')}</option>
<option value="nhour" ${sc.period === 'nhour' ? 'selected' : ''}>${t('kPerHours')}</option>
</select>
<div id="sc-hours-box" style="display:none"><label>${t('kPerNHint')}</label>
<input id="sc-hours" type="number" min="1" step="1" value="${cur.hours || 24}"></div>
<p><button onclick="scopeSave('${escAttr(key)}','${escAttr(cur.model)}', this)">${t('kSaveScope')}</button>
<input id="sc-hours" type="number" min="1" step="1" value="${sc.hours || 24}"></div>
<p><button onclick="scopeSave('${escAttr(key)}','${escAttr(comb)}', this)">${t('kSaveScope')}</button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t('mCancel')}</button></p>
</div>`;
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);

View File

@ -60,6 +60,11 @@ type Provider struct {
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)
go func() {
defer p.Release()
defer close(ch)
sel := <-rc
if sel.err != nil {
p.reportError()
return
p.Release()
return nil, sel.err
}
defer sel.resp.Body.Close()
if sel.resp.StatusCode != 200 {
raw, _ := io.ReadAll(sel.resp.Body)
sel.resp.Body.Close()
p.ReportStatus(sel.resp.StatusCode)
_ = raw
return
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)
defer sel.resp.Body.Close()
scanner := bufio.NewScanner(sel.resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {

View File

@ -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,6 +221,7 @@ func (r *Registry) Status() []SourceStatus {
defer r.mu.RUnlock()
out := make([]SourceStatus, 0, len(r.providers))
for _, p := range r.providers {
live, lastErr, lastAt := p.LastProbe()
s := SourceStatus{
Name: p.Name(),
Adapter: p.Adapter(),
@ -176,6 +230,9 @@ func (r *Registry) Status() []SourceStatus {
Available: p.Available(),
Healthy: p.Available(),
MaxConcurrent: p.MaxConcurrent(),
LiveAvailable: live,
LastError: lastErr,
LastChecked: lastAt,
}
out = append(out, s)
}