feat(llm): 用量落库 + 只读汇总端点 + 面板用量小卡(契约 v0.64)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- model 层新增可选 UsageSink(nil ⇒ 逐字不变,既有用例零改动)
- 组合根注入 UsageWriter:有界队列 1024 + 单写者 + 批量事务(128/批、2s 兜底),
  队列满丢弃 + 限流 WARN,绝不阻塞模型调用;sink 挂 RuntimeLLM(热重载不断)
- 迁移 0017:表 llm_usage(13 列,session_id 可空)+ idx_created_at + idx_session_id
- 新端点 GET /api/v1/admin/llm/usage(owner|admin 读;缺省近 24h、上限 90 天、
  超限 400 不静默裁剪;hitRate = cacheHit/(cacheHit+cacheMiss),分母 0 ⇒ 0;
  绝不含凭据与正文;未接线/查库失败 ⇒ 500 usage_unavailable)
- LLM_USAGE_RETENTION(默认 720h,≤0 = 关闭清理):启动清一次 + 每小时
- 面板 LLM 页「用量(近 24h)」小卡 + Docs 两份同步 v0.64
- 已知缺口(如实登记):session_id 恒 NULL,需 loop 侧在请求上打标,留列后接
This commit is contained in:
AskaEth committed 2026-10-04 22:16:26 +08:00
1 parent 18381f65fd
commit be38651ba4
32 files changed
+2233 -44

No files matched your search

@@ -0,0 +1,203 @@
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` 见 parseLlmUsageInstant,
// 缺省窗口为近 24h,跨度上限见 model.UsageMaxWindow。
//
// 校验顺序是「形状先于语义」:时间点解析不了就直接 400,不会拿一个零值窗口去查库。
func llmUsageQuery(r *http.Request) (model.UsageQuery, error) {
params := r.URL.Query()
since, err := parseLlmUsageInstant(params.Get("since"))
if err != nil {
return model.UsageQuery{}, fmt.Errorf("since 非法:%w", err)
}
until, err := parseLlmUsageInstant(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
}
// parseLlmUsageInstant 解析一个时间点参数。接受两种形态:
//
// - **RFC3339**(如 `2026-10-05T00:00:00Z`,JS 的 `toISOString()` 就是这个);
// - **Unix 毫秒**(纯数字,如 `1759622400000`)—— 与 `/sessions/{id}/messages`
// 的 `before`/`after` 游标同一种口径,便于客户端复用游标。
//
// 空串 = 未给(返回零值)。其它形态报错(绝不静默当成「现在」)。
func parseLlmUsageInstant(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(),
}
}