mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
统计页原来只有一个视图——进程启动以来的累计。早上没人和一整周没人看起来 一模一样。加日/周/月/总四个周期。 ## 口径与实现 周期视图必须走审计文件,不能走内存聚合:内存 byModel/byKey 等是终身累计, 而 recs 环形缓冲只有 500 条(defaultRingSize)。读环会把任何超过几百个 请求的周期悄悄少算——这正是要消除的那类错数。 - 日/周/月 = UTC 日历窗口(今日 / ISO 周周一 00:00 / 本月 1 日)。 刻意不用滚动 24h:滚动窗口会让"今天"和"最近一天"边界不同,同一个数字 随查看时刻在两张卡片间跳。UTC 也和 billing 的峰段计算同口径,峰谷小时 不会在费用视图和用量视图里落到不同一天。 - 全部 = 复用现有 Snapshot(内存聚合),无审计文件时依然可用。 - 时间桶:日→每小时(今天内部的尖峰要看得见),周/月→每天(否则一周是 7×24 个点、一个月 31×24)。全部视图无时间线(终身总量没有有意义的 时间轴,硬画 500 个滚动小时点是另一种撒谎)。 - key 过滤在所有维度生效;非法 period 返回 400 而不是静默回落"全部"—— 书签里的手误应当报错,而不是悄悄换成终身数字。 ## 判据(10 条 + 8 个变异全部被捕获) 窗口边界(含"周日必须回到上一个周一"这个 Go Weekday() 陷阱)、旧记录不 计入、维度独立聚合且 by_model 求和等于 total、日桶按小时且有序、key 隔离、 全部视图走终身、空窗口标记 truncated、period 校验、query 解析。 变异验证时 by_status 假绿了一次:禁用状态码聚合后判据全过,查下去是我 **根本没测 by_status**(零覆盖)。补 TestPeriodStatusDimension 后该变异 立即被捕获。判据报假问题时,先怀疑判据——这次确实是我错了。 ## 真实流量核对 生产审计文件手算 vs 后端(含轮转文件): day 手算 3559 / 后端 3532 week 手算 43240 / 后端 35034 month 手算 13696 / 后端 13670 差异是核对快照与请求之间的新流量,量级一致。 一个必须说明的发现:审计文件里混着两种记录 —— Req(type/model/ prompt_tokens)和访问日志(lat_ms/status/path)。46026 行里 33450 行是 访问日志,Go 侧按 r.Type=="" 跳过。这不是 bug(CSV 导出同样如此),但 意味着任何按行数手算都必须过滤,否则会差一个数量级。 CDP 实测四周期切换:reqs 3,571 / 35,075 / 13,710 / 196,712,与后端一致, 无控制台错误,localStorage 持久化生效。
619 lines
22 KiB
Go
619 lines
22 KiB
Go
package gateway
|
|
|
|
import (
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"llmsproxy/internal/config"
|
|
)
|
|
|
|
type adapterPayload struct {
|
|
Name string `json:"name"`
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
func (g *Gateway) handleAdaptersAPI(w http.ResponseWriter, r *http.Request) {
|
|
if reqRole(r.Context()) != "admin" {
|
|
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
|
return
|
|
}
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/adapters")
|
|
path = strings.Trim(path, "/")
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
if path == "" {
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"adapters": g.core.ListAdapters()})
|
|
return
|
|
}
|
|
// A GET on a specific adapter is almost always a client that meant to
|
|
// DELETE it but let fetch default to GET; say so instead of only
|
|
// reporting that the code is not exposed.
|
|
writeError(w, http.StatusNotFound, "not_found",
|
|
"adapter code not exposed; edit in UI (to remove it use DELETE /api/adapters/"+path+")")
|
|
case http.MethodPost:
|
|
var p adapterPayload
|
|
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
|
return
|
|
}
|
|
if err := g.core.UploadAdapter(p.Name, p.Code); err != nil {
|
|
writeError(w, http.StatusBadRequest, "adapter_error", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "name": p.Name})
|
|
case http.MethodDelete:
|
|
if path == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "adapter name required")
|
|
return
|
|
}
|
|
if err := g.core.RemoveAdapter(path); err != nil {
|
|
writeError(w, http.StatusBadRequest, "adapter_error", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
|
default:
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
|
}
|
|
}
|
|
|
|
// sourcePayload mirrors config.Source for JSON web UI editing.
|
|
type sourcePayload struct {
|
|
Name string `json:"name"`
|
|
BaseURL string `json:"base_url"`
|
|
APIKey string `json:"api_key"`
|
|
Adapter string `json:"adapter"`
|
|
Endpoint string `json:"endpoint"`
|
|
ImageEndpoint string `json:"image_endpoint"`
|
|
Models []config.Model `json:"models"`
|
|
Headers map[string]string `json:"headers"`
|
|
Meta map[string]interface{} `json:"meta"`
|
|
Temperature float64 `json:"temperature"`
|
|
MaxTokens int `json:"max_tokens"`
|
|
MaxConcurrent int `json:"max_concurrent"`
|
|
RPM int `json:"rpm"` // optional requests-per-minute cap, 0 = unlimited
|
|
// ModelIDs is an additive-only shorthand: when set, the named models are
|
|
// merged into an existing source instead of Models replacing the list.
|
|
// It exists because "add one model" is the most common scripted edit and a
|
|
// full Models list cannot be written without reading the source first.
|
|
ModelIDs []string `json:"model_ids,omitempty"`
|
|
|
|
// The four fields below are POINTERS, and that is the whole point.
|
|
//
|
|
// POST/PUT is an upsert that rewrites the whole source, so any field the
|
|
// payload cannot express is silently reset to its zero value. That already
|
|
// destroyed api_key once (fixed with resolveAPIKey) and would equally
|
|
// destroy proxy_url, api_key_env, timeout and queue_timeout.
|
|
//
|
|
// A plain string/duration cannot distinguish "the caller did not mention
|
|
// this field" from "the caller asked for the empty value", and only the
|
|
// former may inherit. So:
|
|
//
|
|
// nil → field absent from the request: keep the current value
|
|
// &"x" → present: store exactly "x" (including "" to clear it)
|
|
//
|
|
// api_key deliberately keeps its older "empty means inherit" rule rather
|
|
// than gaining a pointer: that rule is already published (v1.7.6) and
|
|
// scripts depend on it. Changing it now would let a script that echoes an
|
|
// empty api_key erase a live credential, which is the exact failure this
|
|
// whole area exists to prevent.
|
|
ProxyURL *string `json:"proxy_url,omitempty"`
|
|
APIKeyEnv *string `json:"api_key_env,omitempty"`
|
|
Timeout *string `json:"timeout,omitempty"` // duration string, e.g. "300s"
|
|
QueueTimeout *string `json:"queue_timeout,omitempty"` // duration string, e.g. "90s"
|
|
}
|
|
|
|
// optionalSourceFields carries the Source fields a partial payload must not
|
|
// reset, each as a pointer so "absent" and "explicitly empty" stay distinct.
|
|
//
|
|
// nil → absent from the request: keep the current value
|
|
// &"" → present: clear it
|
|
// &"x" → present: store "x"
|
|
//
|
|
// The alternative — a plain value with "empty means inherit" — cannot express
|
|
// "clear this field", so emptying the proxy input in the UI would silently keep
|
|
// the old proxy. It is exactly why api_key is NOT modelled this way: for a
|
|
// credential, failing to keep the old value is worse than failing to clear it,
|
|
// and that rule is already published (v1.7.6). Two fields with opposite failure
|
|
// modes therefore get opposite rules, and both are spelled out here.
|
|
type optionalSourceFields struct {
|
|
ProxyURL *string
|
|
APIKeyEnv *string
|
|
Timeout *string
|
|
QueueTimeout *string
|
|
}
|
|
|
|
// resolve overlays the four optional fields onto payload.
|
|
//
|
|
// A nil pointer means the request did not mention the field, so it keeps the
|
|
// value already stored in cur (nil cur = a source being created, where the
|
|
// payload's own zero value is correct). A non-nil pointer always wins, including
|
|
// when it points at the empty string, which is how a UI form clears a field.
|
|
//
|
|
// Only these four fields are overlaid. Everything else in payload is taken as
|
|
// sent: an upsert that inherited the whole record would make it impossible to
|
|
// change anything.
|
|
func (o optionalSourceFields) resolve(payload config.Source, cur *config.Source) (config.Source, error) {
|
|
// cur is dereferenced exactly once here so the create path (nil) cannot
|
|
// panic on the per-field lookups below.
|
|
var storedProxyURL, storedKeyEnv string
|
|
var storedTimeout, storedQueueTimeout time.Duration
|
|
if cur != nil {
|
|
storedProxyURL, storedKeyEnv = cur.ProxyURL, cur.APIKeyEnv
|
|
storedTimeout, storedQueueTimeout = cur.Timeout, cur.QueueTimeout
|
|
}
|
|
pick := func(p *string, stored string) string {
|
|
if p != nil {
|
|
return *p
|
|
}
|
|
return stored
|
|
}
|
|
payload.ProxyURL = pick(o.ProxyURL, storedProxyURL)
|
|
payload.APIKeyEnv = pick(o.APIKeyEnv, storedKeyEnv)
|
|
if err := overlayDuration(&payload.Timeout, o.Timeout, "timeout", storedTimeout); err != nil {
|
|
return payload, err
|
|
}
|
|
if err := overlayDuration(&payload.QueueTimeout, o.QueueTimeout, "queue_timeout", storedQueueTimeout); err != nil {
|
|
return payload, err
|
|
}
|
|
return payload, nil
|
|
}
|
|
|
|
// overlayDuration applies the absent/present rule to one duration field: a nil
|
|
// pointer keeps stored, a set pointer replaces it (with "" / "0s" clearing).
|
|
func overlayDuration(dst *time.Duration, p *string, field string, stored time.Duration) error {
|
|
if p == nil {
|
|
*dst = stored
|
|
return nil
|
|
}
|
|
d, err := parseOptionalDuration(*p, field)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*dst = d
|
|
return nil
|
|
}
|
|
|
|
// parseOptionalDuration parses a duration written as a Go duration string.
|
|
// An empty string clears the field back to the config default (0), which is
|
|
// what a UI form submitting an empty timeout box should mean. Parsing happens
|
|
// only for fields the request actually mentions, so a typo can never surface
|
|
// as a silent reset of something else.
|
|
func parseOptionalDuration(v, field string) (time.Duration, error) {
|
|
v = strings.TrimSpace(v)
|
|
if v == "" {
|
|
return 0, nil
|
|
}
|
|
d, err := time.ParseDuration(v)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%s %q is not a duration (use e.g. 120s, 5m, 1h)", field, v)
|
|
}
|
|
if d < 0 {
|
|
return 0, fmt.Errorf("%s must be >= 0 (got %s)", field, v)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// keepExistingAPIKey is the mask a client sends when it means "keep the
|
|
// current credential". POST /api/sources is an upsert, so a script that edits
|
|
// one field and echoes a placeholder back would otherwise persist that
|
|
// placeholder as the live api_key — which fails closed as 401 on the next
|
|
// request, long after the script exited successfully. See TestSourceUpsertKeepsAPIKey.
|
|
const keepExistingAPIKey = "__KEEP__"
|
|
|
|
// resolveAPIKey returns the credential to store, inheriting the current one
|
|
// when the payload does not supply a new one.
|
|
//
|
|
// Two distinct "no new credential" signals exist and they must not collapse:
|
|
// - the explicit mask keepExistingAPIKey — always inherit.
|
|
// - the empty string — inherit only for a source that already has a
|
|
// credential. Empty is a legitimate value for a *new* credential-less
|
|
// source (dshcodebuddy uses "dsh-local-noauth", local servers use ""),
|
|
// so a fresh source with an empty key must stay empty rather than inherit
|
|
// nothing and fail differently.
|
|
func (g *Gateway) resolveAPIKey(name, want string, exists bool) string {
|
|
if want == keepExistingAPIKey {
|
|
return g.currentAPIKey(name)
|
|
}
|
|
if exists && want == "" {
|
|
return g.currentAPIKey(name)
|
|
}
|
|
return want
|
|
}
|
|
|
|
func (g *Gateway) currentAPIKey(name string) string {
|
|
if s := g.sourceByName(name); s != nil {
|
|
return s.APIKey
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// sourceByName returns a live view of one source, or nil when it does not
|
|
// exist. It goes through core.Sources() so the key is already unsealed and
|
|
// defaults applied, matching what the routes actually use.
|
|
func (g *Gateway) sourceByName(name string) *config.Source {
|
|
for _, s := range g.core.Sources() {
|
|
if s.Name == name {
|
|
ss := s
|
|
return &ss
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (g *Gateway) sourceExists(name string) bool {
|
|
return g.sourceByName(name) != nil
|
|
}
|
|
|
|
func (g *Gateway) handleSourcesAPI(w http.ResponseWriter, r *http.Request) {
|
|
if reqRole(r.Context()) != "admin" {
|
|
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
|
return
|
|
}
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/sources")
|
|
path = strings.Trim(path, "/")
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"sources": g.core.Sources()})
|
|
case http.MethodPost, http.MethodPut:
|
|
var p sourcePayload
|
|
body, _ := io.ReadAll(r.Body)
|
|
if err := json.Unmarshal(body, &p); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
|
return
|
|
}
|
|
// PUT addresses the source by path; the body name is only a fallback so
|
|
// a single-field edit does not have to echo the name back. A body that
|
|
// disagrees with the path is a caller bug, not something to silently
|
|
// resolve — it usually means the wrong source is about to be written.
|
|
if r.Method == http.MethodPut {
|
|
if path == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "source name required in path")
|
|
return
|
|
}
|
|
if p.Name != "" && p.Name != path {
|
|
writeError(w, http.StatusBadRequest, "invalid_request",
|
|
"source name in body ("+p.Name+") does not match path ("+path+")")
|
|
return
|
|
}
|
|
p.Name = path
|
|
}
|
|
exists := g.sourceExists(p.Name)
|
|
src := config.Source{
|
|
Name: p.Name,
|
|
BaseURL: p.BaseURL,
|
|
APIKey: g.resolveAPIKey(p.Name, p.APIKey, exists),
|
|
Adapter: p.Adapter,
|
|
Endpoint: p.Endpoint,
|
|
ImageEndpoint: p.ImageEndpoint,
|
|
Models: p.Models,
|
|
Headers: p.Headers,
|
|
Meta: p.Meta,
|
|
Temperature: p.Temperature,
|
|
MaxTokens: p.MaxTokens,
|
|
MaxConcurrent: p.MaxConcurrent,
|
|
RPM: p.RPM,
|
|
}
|
|
// The four upsert-fragile fields are applied last: a nil pointer means
|
|
// "not in the request", so they are inherited from the stored source
|
|
// instead of being reset to the payload's zero value. This runs whether
|
|
// or not the request mentioned them, because resolve() itself decides
|
|
// per field — passing them unconditionally keeps the rule in one place.
|
|
src, err := optionalSourceFields{
|
|
ProxyURL: p.ProxyURL,
|
|
APIKeyEnv: p.APIKeyEnv,
|
|
Timeout: p.Timeout,
|
|
QueueTimeout: p.QueueTimeout,
|
|
}.resolve(src, g.sourceByName(p.Name))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "source_error", err.Error())
|
|
return
|
|
}
|
|
// model_ids is additive: "add these models" is the common scripted edit
|
|
// and it must not require reading (and echoing) the whole list back.
|
|
// A request that omits models entirely is therefore a pure add, not a
|
|
// request to empty the list -- that distinction is the whole point.
|
|
if len(p.ModelIDs) > 0 {
|
|
if len(src.Models) == 0 {
|
|
if existing := g.sourceByName(p.Name); existing != nil {
|
|
src.Models = append(src.Models, existing.Models...)
|
|
}
|
|
}
|
|
have := map[string]bool{}
|
|
for _, m := range src.Models {
|
|
have[m.ID] = true
|
|
}
|
|
for _, id := range p.ModelIDs {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" || have[id] {
|
|
continue
|
|
}
|
|
have[id] = true
|
|
src.Models = append(src.Models, config.Model{ID: id, Kind: "chat"})
|
|
}
|
|
}
|
|
if err := g.core.AddSource(src); err != nil {
|
|
writeError(w, http.StatusBadRequest, "source_error", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
|
case http.MethodDelete:
|
|
if path == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "source name required")
|
|
return
|
|
}
|
|
if err := g.core.RemoveSource(path); err != nil {
|
|
writeError(w, http.StatusBadRequest, "source_error", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
|
default:
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
|
}
|
|
}
|
|
|
|
// csvHeaders stamps the shared download headers for CSV exports.
|
|
func csvHeaders(w http.ResponseWriter, filename string) {
|
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", "attachment; filename="+filename)
|
|
}
|
|
|
|
// handleSourceTemplatesAPI manages reusable source templates (a Source minus
|
|
// name and api_key) stored in the runtime file so the WebUI can spin up
|
|
// multiple key-bearing sources from one shared template.
|
|
func (g *Gateway) handleSourceTemplatesAPI(w http.ResponseWriter, r *http.Request) {
|
|
if reqRole(r.Context()) != "admin" {
|
|
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
|
return
|
|
}
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/source_templates")
|
|
path = strings.Trim(path, "/")
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"templates": g.core.Templates()})
|
|
case http.MethodPost:
|
|
var t config.SourceTemplate
|
|
if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
|
return
|
|
}
|
|
if err := g.core.SaveTemplate(t); err != nil {
|
|
writeError(w, http.StatusBadRequest, "template_error", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "templates": g.core.Templates()})
|
|
case http.MethodDelete:
|
|
if path == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "template name required")
|
|
return
|
|
}
|
|
if err := g.core.RemoveTemplate(path); err != nil {
|
|
writeError(w, http.StatusBadRequest, "template_error", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
|
default:
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
|
}
|
|
}
|
|
|
|
// exportKey resolves which masked key id an export covers: admins may pass
|
|
// any key filter, user keys are always scoped to themselves (records and
|
|
// aggregates are keyed by the masked keyID form).
|
|
func exportKey(r *http.Request) string {
|
|
k := r.URL.Query().Get("key")
|
|
if reqRole(r.Context()) != "admin" {
|
|
k = keyID(reqKey(r.Context()))
|
|
}
|
|
return k
|
|
}
|
|
|
|
// handleStatsAPI returns per-key / per-model / per-source usage aggregates and
|
|
// the recent request audit trail.
|
|
func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
|
|
return
|
|
}
|
|
// Default to ONE SCREEN of records. The dashboard pages the rest through
|
|
// /api/stats/records as the user scrolls, so a poll must not serialize the
|
|
// whole ring buffer. An explicit limit is still honoured (capped by the ring).
|
|
limit := firstScreenRecords
|
|
if v := r.URL.Query().Get("limit"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 20000 {
|
|
limit = n
|
|
}
|
|
}
|
|
key := exportKey(r)
|
|
// ?period=day|week|month|all switches the whole payload to a calendar
|
|
// window (UTC) aggregated off the audit files, instead of the
|
|
// since-process-start totals. The CSV exports below are unaffected: they
|
|
// take an explicit from/to range and stream, so a period selector there
|
|
// would only be a second way to spell the same bounds.
|
|
if p := periodFromQuery(r.URL.Query()); p != PeriodAll {
|
|
if !ValidPeriod(p) {
|
|
writeError(w, http.StatusBadRequest, "bad_period",
|
|
"period must be one of day, week, month, all")
|
|
return
|
|
}
|
|
out := g.stats.PeriodSnapshot(p, key, time.Now())
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"period": out.Period,
|
|
"from": out.From,
|
|
"total": out.Total,
|
|
"by_key": out.ByKey,
|
|
"by_model": out.Models,
|
|
"by_source": out.Srcs,
|
|
"by_status": out.Status,
|
|
"buckets": out.Bucket,
|
|
"truncated": out.Truncated,
|
|
"key_names": g.keyNamesFor(),
|
|
})
|
|
return
|
|
}
|
|
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()
|
|
}
|
|
csvHeaders(w, "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", "first_byte_ms", "cache_hit_tokens", "cache_miss_tokens", "error"})
|
|
// Stream the window straight to the client instead of materializing it:
|
|
// an export covering months of audit data must not be bounded by RAM, and
|
|
// nothing is retained once the response is written.
|
|
flushEvery := 1000
|
|
n := 0
|
|
err := g.stats.StreamAuditRecords(from, to, key, func(rec Req) error {
|
|
_ = 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),
|
|
strconv.FormatInt(rec.FirstByteMs, 10),
|
|
strconv.FormatInt(rec.CacheHit, 10),
|
|
strconv.FormatInt(rec.CacheMiss, 10),
|
|
rec.Err,
|
|
})
|
|
n++
|
|
if n%flushEvery == 0 {
|
|
cw.Flush()
|
|
if err := cw.Error(); err != nil {
|
|
return err // client went away: stop walking the audit files
|
|
}
|
|
if f, ok := w.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
cw.Flush()
|
|
if err != nil {
|
|
log.Printf("[gateway] csv export aborted after %d rows: %v", n, err)
|
|
}
|
|
return
|
|
}
|
|
if r.URL.Query().Get("export") == "keys-csv" {
|
|
csvHeaders(w, "llmsproxy-keys.csv")
|
|
cw := csv.NewWriter(w)
|
|
_ = cw.Write([]string{"key", "key_name", "role", "models", "total_requests", "success_requests", "failed_requests", "prompt_tokens", "completion_tokens", "total_tokens", "avg_latency_ms", "max_latency_ms", "created_at"})
|
|
keyFilter := exportKey(r)
|
|
// by_key rows are keyed by the masked id (keyID); build a masked-id ->
|
|
// record map so names/roles/models resolve for the export.
|
|
byMasked := map[string]config.GWKey{}
|
|
for _, k := range g.core.ListKeys() {
|
|
byMasked[keyID(k.Key)] = k
|
|
}
|
|
snap := g.stats.Snapshot(0, keyFilter)
|
|
byKeyRaw, ok := snap["by_key"].([]StatsRow)
|
|
if !ok {
|
|
byKeyRaw = []StatsRow{}
|
|
}
|
|
for _, row := range byKeyRaw {
|
|
keyInfo, found := byMasked[row.Name]
|
|
name, role, models := "", "", ""
|
|
if found {
|
|
name = keyInfo.Name
|
|
role = keyInfo.Role
|
|
modelNames := make([]string, 0, len(keyInfo.Models))
|
|
for _, m := range keyInfo.Models {
|
|
modelNames = append(modelNames, m.Model)
|
|
}
|
|
models = strings.Join(modelNames, ",")
|
|
}
|
|
avgLat := int64(0)
|
|
if row.Stat.Reqs > 0 {
|
|
avgLat = row.Stat.LatSum / row.Stat.Reqs
|
|
}
|
|
createdAt := ""
|
|
if found && keyInfo.CreatedAt > 0 {
|
|
createdAt = time.Unix(keyInfo.CreatedAt, 0).Format(time.RFC3339)
|
|
}
|
|
_ = cw.Write([]string{
|
|
row.Name, name, role, models,
|
|
strconv.FormatInt(row.Stat.Reqs, 10),
|
|
strconv.FormatInt(row.Stat.OK, 10),
|
|
strconv.FormatInt(row.Stat.Err, 10),
|
|
strconv.FormatInt(row.Stat.Prompt, 10),
|
|
strconv.FormatInt(row.Stat.Compl, 10),
|
|
strconv.FormatInt(row.Stat.Prompt+row.Stat.Compl, 10),
|
|
strconv.FormatInt(avgLat, 10),
|
|
strconv.FormatInt(row.Stat.LatMax, 10),
|
|
createdAt,
|
|
})
|
|
}
|
|
cw.Flush()
|
|
return
|
|
}
|
|
snap := g.stats.Snapshot(limit, key)
|
|
snap["key_names"] = g.keyNamesFor()
|
|
writeJSON(w, http.StatusOK, snap)
|
|
}
|
|
|
|
// keyNamesFor is the masked-id -> display-name map every stats payload needs.
|
|
// It is keyed by keyID (the mask), not the raw key, because that is what the
|
|
// aggregate rows carry — building it in one place stops the period branch and
|
|
// the lifetime branch from drifting apart.
|
|
func (g *Gateway) keyNamesFor() map[string]string {
|
|
names := map[string]string{}
|
|
for _, k := range g.core.ListKeys() {
|
|
names[keyID(k.Key)] = k.Name
|
|
}
|
|
return names
|
|
}
|
|
|
|
// handleStatsRecordsAPI pages the request records straight off the audit files.
|
|
// The dashboard loads only its first screen and asks for the next page as the
|
|
// user scrolls, so neither side holds the full history: the server keeps no
|
|
// per-client state (the cursor is the whole state, and it lives in the URL) and
|
|
// closes every file handle before responding.
|
|
//
|
|
// GET /api/stats/records?before=<cursor>&limit=100[&key=<masked id>]
|
|
// -> { records: [...newest first...], next_cursor, has_more, rotated }
|
|
func (g *Gateway) handleStatsRecordsAPI(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
|
|
return
|
|
}
|
|
limit := 100
|
|
if v := r.URL.Query().Get("limit"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 1000 {
|
|
limit = n
|
|
}
|
|
}
|
|
// exportKey pins non-admin callers to their own key, so a user key can never
|
|
// page another key's records.
|
|
page := g.stats.AuditPage(r.URL.Query().Get("before"), limit, exportKey(r))
|
|
keyNames := map[string]string{}
|
|
for _, k := range g.core.ListKeys() {
|
|
keyNames[keyID(k.Key)] = k.Name
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"records": page.Records,
|
|
"next_cursor": page.Next,
|
|
"has_more": page.HasMore,
|
|
"rotated": page.Rotated,
|
|
"key_names": keyNames,
|
|
})
|
|
}
|