Files
Cyrene_v2/internal/services/gateway/admin_llm_usage.go
T
AskaEth c8053b1f36
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): 渠道事件查询面 + 面板「渠道事件」页(design/26 E6)
- 两个只读端点:GET /admin/channels/events(列表,8 个过滤条件 + 分页钳制)与 .../events/counters(逐类计数,恒带 approx:true 如实标「近似」)
- 诚实边界:>90 天窗口 ⇒ 400(不静默裁剪);未知 platform/档位 ⇒ 400;**未接线 ⇒ 500 channel_events_unavailable**(绝不回空 200 假装「没有事件」);微信平台的空如实说明「该渠道不提供非消息事件」而不借别平台顶替
- 面板:新 section「渠道事件」(计数卡 + 事件行 + 过滤条 + 30s 静默刷新),空/错误态分开且失败显示错误不显示 0
- 组合根接线(主会话补):wire_frontend.go 的 Deps 注入 EventStore: a.channelEventStore()(未接线时为真 nil ⇒ 保留诚实 500)
- 契约不升版(查询面 v0.67 已登记):就地改「未实现 → 已实现 + 端点路径」,并补登记 5 处原先留白的落地值(camelCase / 通用 bad_request / RBAC owner|admin / data.approx / total 语义)
- 顺带把 §5 三个 CHANNEL_EVENT_* env 键由「尚未实现」改「已接线」(E4)
- 变异实测:去掉 90 天上限 ⇒ status 200 want 400;未接线回空 200 ⇒ status 200 want 500
- 债务登记:events.Filter 无 AccountID 列 ⇒ 服务端暂不能按账号筛(列表仍如实下发 accountId),要加需先改 internal/runtime/events
2026-10-05 14:38:37 +08:00

208 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(),
}
}