- 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 侧在请求上打标,留列后接
204 lines
7.2 KiB
Go
204 lines
7.2 KiB
Go
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(),
|
||
}
|
||
}
|