diff --git a/internal/gateway/stats.go b/internal/gateway/stats.go index 68d6f1b..30a4b3d 100644 --- a/internal/gateway/stats.go +++ b/internal/gateway/stats.go @@ -228,6 +228,26 @@ func incStatus(a *Stat, name string, r Req) { } } +// addStat merges an already aggregated row into a total. It deliberately +// includes failed requests without a model: routing can fail before a model is +// resolved, but that request still belongs in the gateway total. +func addStat(dst, src *Stat) { + if dst == nil || src == nil { + return + } + dst.Reqs += src.Reqs + dst.OK += src.OK + dst.Err += src.Err + dst.Tokens += src.Tokens + dst.Prompt += src.Prompt + dst.Compl += src.Compl + dst.LatSum += src.LatSum + dst.FirstByteSum += src.FirstByteSum + if src.LatMax > dst.LatMax { + dst.LatMax = src.LatMax + } +} + // LoadAudit primes the in-memory state from the audit file. // // The aggregates (totals, per-key/model/source rows, status counts and the @@ -1219,17 +1239,13 @@ func (s *Stats) Snapshot(limit int, key string) map[string]interface{} { byModel = s.byKeyModel[key] bySrc = s.byKeySrc[key] } - for _, a := range byModel { - total.Reqs += a.Reqs - total.OK += a.OK - total.Err += a.Err - total.Tokens += a.Tokens - total.Prompt += a.Prompt - total.Compl += a.Compl - total.LatSum += a.LatSum - if a.LatMax > total.LatMax { - total.LatMax = a.LatMax - } + // The total is the sum of every recorded request, not the model rows. + // Routing failures are valid requests but may have no resolved model; deriving + // total from byModel silently dropped those 502/503 records. Use byKey, which + // is populated for every request (including an empty key), and keep the same + // scope as the selected key filter. + for _, a := range byKey { + addStat(&total, a) } bs := make([]agrRow, 0, len(s.byStatus)) for code, st := range s.byStatus { diff --git a/internal/gateway/stats_test.go b/internal/gateway/stats_test.go index 04c7089..0549382 100644 --- a/internal/gateway/stats_test.go +++ b/internal/gateway/stats_test.go @@ -560,3 +560,17 @@ func TestAuditRotationThresholds(t *testing.T) { t.Fatalf("total audit budget = %d, want 256 MiB", total) } } + +func TestSnapshotTotalIncludesRequestsWithoutModel(t *testing.T) { + s := NewStats(100) + s.Record(Req{Key: "k", Status: 200, OK: true}) + s.Record(Req{Key: "k", Model: "m", Status: 200, OK: true, Prompt: 3, Compl: 2}) + snap := s.Snapshot(0, "") + total, ok := snap["total"].(Stat) + if !ok { + t.Fatalf("total missing: %#v", snap["total"]) + } + if total.Reqs != 2 || total.OK != 2 || total.Tokens != 5 { + t.Fatalf("total must include model-less requests: %#v", total) + } +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 6114998..e73f8b9 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -7110,27 +7110,24 @@ function plugin.usage_token_ledger(req) if key ~= "" then url = url .. "&key=" .. (key:len() <= 6 and key or ("***" .. key:sub(-6))) end - local resp, err = __llmsproxy_http({ url = url, method = "GET", timeout_ms = 4000 }) + local headers = {} + if key ~= "" then + headers["Authorization"] = "Bearer " .. key + end + local resp, err = __llmsproxy_http({ url = url, method = "GET", timeout_ms = 4000, headers = headers }) if err or not resp or resp.status ~= 200 then - return { status = 200, json = { ok = true, snapshot = { available = false, - error = err or (resp and tostring(resp.status) or "no response") } } } + return { status = 503, json = { ok = false, error = "usage snapshot unavailable: " .. tostring(err or (resp and resp.status or "no response")) } } end local ok, rec = pcall(json.decode, resp.body or "") if not ok or type(rec) ~= "table" then - return { status = 200, json = { ok = true, snapshot = { available = false, error = "stats 无法解析" } } } - end - -- 汇总 token 总量(兼容 stats 的不同形状) - local prompt, completion, requests = 0, 0, 0 - local rows = rec.requests or rec.rows or rec.data or {} - if type(rows) == "table" then - for _, r in ipairs(rows) do - if type(r) == "table" then - prompt = prompt + (tonumber(r.prompt_tokens) or 0) - completion = completion + (tonumber(r.completion_tokens) or 0) - requests = requests + 1 - end - end + return { status = 502, json = { ok = false, error = "usage snapshot 无法解析" } } end + -- 汇总 token 总量。/api/stats 的聚合在 `total` 下,不在顶层数组中。 + -- 之前错误地遍历 rec.requests/rows/data,导致该端点永远返回 0。 + local total = type(rec.total) == "table" and rec.total or {} + local prompt = tonumber(total.prompt_tokens) or 0 + local completion = tonumber(total.completion_tokens) or 0 + local requests = tonumber(total.reqs) or 0 return { status = 200, json = { ok = true, snapshot = { available = true, tokens = { prompt = prompt, completion = completion, total = prompt + completion }, requests = requests, raw = rec } } }