package gateway import ( "fmt" "net/http" "strconv" "strings" "time" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // LLM 用量汇总**只读**端点(GET /api/v1/admin/llm/usage,切片 U1,主人裁决 // 「2.4 落」;契约 v0.64)。面板的「用量」小卡(近 24h 掉用数 / 输入 token / // 缓存命中率)与将来的成本视图都以它为数源。 // // 四条纪律: // // 1. **只读**:只有 SELECT(汇总)—— 不写、不迁移,复用组合根注入的活连接 // (见 Deps.Usage)。 // 2. **不回显凭据**:响应里只有 token 计数与模型/用途标签 —— `llm_usage` 表里 // 根本没有凭据与消息正文,因此没有任何「回显 DSN/apiKey」的代码路径。 // 3. **不猜**:没接汇总出口、或查库失败,一律 500 + 明确 code,绝不回一份 // 全 0 的假汇总(面板据此显示错误,而不是一个看着正常的假数字)。 // 4. **不静默裁剪窗口**:`since`/`until` 跨度超过 90 天时回 400 并点名上限 —— // 静默裁剪会让客户端把「我没查那么久」误读成「就那么少」。 const llmUsageLogSource = "gateway/admin_llm_usage" // llmUsageWindow 回显本次生效的时间窗与用途过滤(缺省值补齐后的真值,客户端 // 不必自己猜)。`purpose` 为空 = 未过滤。 type llmUsageWindow struct { Since string `json:"since"` Until string `json:"until"` Purpose string `json:"purpose"` } // llmUsageTotals 是一个窗口/分组内的用量合计。`hitRate` = cacheHit / // (cacheHit + cacheMiss),**分母为 0 时下发 0**(口径与 model.UsageTotals.HitRate // 同源,绝不下发 NaN/Inf)。 type llmUsageTotals struct { Calls int64 `json:"calls"` Prompt int64 `json:"prompt"` Completion int64 `json:"completion"` CacheHit int64 `json:"cacheHit"` CacheMiss int64 `json:"cacheMiss"` HitRate float64 `json:"hitRate"` } // llmUsagePurposeRow / llmUsageModelRow 是按用途 / 按模型分解的一行。两类分开 // 建模(而不是共用一个带 `key` 的结构),好让线上字段名就是它真实的分组名。 type llmUsagePurposeRow struct { Purpose string `json:"purpose"` llmUsageTotals } type llmUsageModelRow struct { Model string `json:"model"` llmUsageTotals } // llmUsageResponse 是响应 data 本体。 type llmUsageResponse struct { Window llmUsageWindow `json:"window"` Totals llmUsageTotals `json:"totals"` ByPurpose []llmUsagePurposeRow `json:"byPurpose"` ByModel []llmUsageModelRow `json:"byModel"` } // adminLlmUsage 处理 GET /api/v1/admin/llm/usage(owner|admin 读,与同页其它 // 管理读同门)。它是只读查询,因此不审计(审计只覆盖动作)。 func (s *Server) adminLlmUsage(w http.ResponseWriter, r *http.Request) { if s.usage == nil { writeError(w, apperr.Wrap(apperr.ErrInternal, "usage_unavailable", "LLM 用量汇总未接线")) return } query, err := llmUsageQuery(r) if err != nil { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "%s", err.Error())) return } summary, err := s.usage.Summary(r.Context(), query) if err != nil { // 原始错误(可能带 SQL/路径)只进日志;响应只给稳定 code + 中文说明。 logger.WithSource(s.log, llmUsageLogSource).Error("read llm usage summary", "err", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "usage_unavailable", "读取 LLM 用量汇总失败")) return } writeJSON(w, http.StatusOK, llmUsageResponseOf(summary)) } // llmUsageQuery 解析并**校验**查询参数:`since`/`until` 见 parseInstantParam, // 缺省窗口为近 24h,跨度上限见 model.UsageMaxWindow。 // // 校验顺序是「形状先于语义」:时间点解析不了就直接 400,不会拿一个零值窗口去查库。 func llmUsageQuery(r *http.Request) (model.UsageQuery, error) { params := r.URL.Query() since, err := parseInstantParam(params.Get("since")) if err != nil { return model.UsageQuery{}, fmt.Errorf("since 非法:%w", err) } until, err := parseInstantParam(params.Get("until")) if err != nil { return model.UsageQuery{}, fmt.Errorf("until 非法:%w", err) } if until.IsZero() { until = time.Now() } if since.IsZero() { since = until.Add(-model.UsageDefaultWindow) } if !since.Before(until) { return model.UsageQuery{}, fmt.Errorf("since 必须早于 until") } if until.Sub(since) > model.UsageMaxWindow { return model.UsageQuery{}, fmt.Errorf("时间窗超过上限 %s", model.UsageMaxWindow) } return model.UsageQuery{ Since: since, Until: until, Purpose: strings.TrimSpace(params.Get("purpose")), }, nil } // parseInstantParam 解析一个时间点参数。接受两种形态: // // - **RFC3339**(如 `2026-10-05T00:00:00Z`,JS 的 `toISOString()` 就是这个); // - **Unix 毫秒**(纯数字,如 `1759622400000`)—— 与 `/sessions/{id}/messages` // 的 `before`/`after` 游标同一种口径,便于客户端复用游标。 // // 空串 = 未给(返回零值)。其它形态报错(绝不静默当成「现在」)。 // // 它是**跨端点共用**的解析器:`/admin/llm/usage` 与 `/admin/channels/events` // (切片 E6)的 `since`/`until` 口径相同(同一形态 ⇒ 同一解析 ⇒ 同一比较语义), // 因此只此一份,不各写一遍。 func parseInstantParam(raw string) (time.Time, error) { raw = strings.TrimSpace(raw) if raw == "" { return time.Time{}, nil } if allDigits(raw) { ms, err := strconv.ParseInt(raw, 10, 64) if err != nil { return time.Time{}, fmt.Errorf("毫秒时间戳 %q 越界", raw) } return time.UnixMilli(ms), nil } parsed, err := time.Parse(time.RFC3339, raw) if err != nil { return time.Time{}, fmt.Errorf("不是 RFC3339 或毫秒时间戳:%q", raw) } return parsed, nil } // allDigits 报告 s 是否由纯 ASCII 数字构成(空串为 false)。 func allDigits(s string) bool { if s == "" { return false } for i := 0; i < len(s); i++ { if s[i] < '0' || s[i] > '9' { return false } } return true } // llmUsageResponseOf 把存储层汇总翻成线上结构。时间一律以 **UTC RFC3339** 回显; // 分组列表恒为非 nil 切片(无数据时是 `[]` 而非 `null`)。 func llmUsageResponseOf(summary model.UsageSummary) llmUsageResponse { out := llmUsageResponse{ Window: llmUsageWindow{ Since: summary.Window.Since.UTC().Format(time.RFC3339), Until: summary.Window.Until.UTC().Format(time.RFC3339), Purpose: summary.Window.Purpose, }, Totals: llmUsageTotalsOf(summary.Totals), ByPurpose: make([]llmUsagePurposeRow, 0, len(summary.ByPurpose)), ByModel: make([]llmUsageModelRow, 0, len(summary.ByModel)), } for _, group := range summary.ByPurpose { out.ByPurpose = append(out.ByPurpose, llmUsagePurposeRow{ Purpose: group.Key, llmUsageTotals: llmUsageTotalsOf(group.UsageTotals), }) } for _, group := range summary.ByModel { out.ByModel = append(out.ByModel, llmUsageModelRow{ Model: group.Key, llmUsageTotals: llmUsageTotalsOf(group.UsageTotals), }) } return out } // llmUsageTotalsOf 是一处换算:合计计数 + 命中率(分母 0 ⇒ 0)。 func llmUsageTotalsOf(t model.UsageTotals) llmUsageTotals { return llmUsageTotals{ Calls: t.Calls, Prompt: t.Prompt, Completion: t.Completion, CacheHit: t.CacheHit, CacheMiss: t.CacheMiss, HitRate: t.HitRate(), } }