Files
Cyrene_v2/internal/services/gateway/admin_llm_usage_test.go
T
AskaEth be38651ba4
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(llm): 用量落库 + 只读汇总端点 + 面板用量小卡(契约 v0.64)
- 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 侧在请求上打标,留列后接
2026-10-04 22:16:26 +08:00

321 lines
12 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 (
"context"
"database/sql"
"encoding/json"
"net/http"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件钉住 LLM 用量汇总只读端点(GET /api/v1/admin/llm/usage,切片 U1,
// 契约 v0.64):数值正确(含 hitRate 公式)、时间窗与用途过滤生效、
// 超上限窗口 400、鉴权(401 / viewer 403)、不回显凭据。
// llmUsageTotalsDTO 与 internal/services/gateway/admin_llm_usage.go 的字段逐字
// 对齐;字段名写错就解不出来。
type llmUsageTotalsDTO 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"`
}
type llmUsageResponseDTO struct {
Window struct {
Since string `json:"since"`
Until string `json:"until"`
Purpose string `json:"purpose"`
} `json:"window"`
Totals llmUsageTotalsDTO `json:"totals"`
ByPurpose []struct {
Purpose string `json:"purpose"`
llmUsageTotalsDTO
} `json:"byPurpose"`
ByModel []struct {
Model string `json:"model"`
llmUsageTotalsDTO
} `json:"byModel"`
}
// llmUsageTestDB 在 dir 下打开一个真实 SQLite 文件库并跑迁移(端点用的就是这条
// 活连接:Deps.Usage)。
func llmUsageTestDB(t *testing.T, dir string) *sql.DB {
t.Helper()
ctx := context.Background()
conn, err := db.Open(ctx, filepath.Join(dir, "cyrene.db"), gwTestLogger())
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.MigrateUp(ctx, conn, gwTestLogger()); err != nil {
t.Fatalf("migrate: %v", err)
}
return conn
}
// seedUsageSamples 用**真实的写入器**落库三条样本(批量事务路径),使端到端
// 「写入 → 汇总」与生产同一条路。
func seedUsageSamples(t *testing.T, conn *sql.DB) {
t.Helper()
writer := model.NewUsageWriter(conn, gwTestLogger())
writer.Start(context.Background())
samples := []model.UsageSample{
{Adapter: "openai", Model: "deepseek-chat", Purpose: "chat",
PromptTokens: 1000, CompletionTokens: 200, TotalTokens: 1200,
CacheHitTokens: 900, CacheMissTokens: 100, TookMs: 50},
{Adapter: "openai", Model: "deepseek-chat", Purpose: "chat",
PromptTokens: 1000, CompletionTokens: 100, TotalTokens: 1100,
CacheHitTokens: 0, CacheMissTokens: 1000, TookMs: 70},
{Adapter: "openai", Model: "gpt-x", Purpose: "memory_extract",
PromptTokens: 500, CompletionTokens: 50, TotalTokens: 550,
CacheHitTokens: 500, CacheMissTokens: 0, TookMs: 30},
}
for _, sample := range samples {
writer.RecordUsage(sample)
}
writer.Stop()
}
// insertOldUsageRow 写一行**窗口之外**的样本(created_at 显式给定)。
func insertOldUsageRow(t *testing.T, conn *sql.DB, purpose, model, createdAt string) {
t.Helper()
if _, err := conn.ExecContext(context.Background(),
`INSERT INTO llm_usage
(purpose, adapter, model, prompt_tokens, completion_tokens,
cache_hit_tokens, cache_miss_tokens, total_tokens, took_ms, created_at)
VALUES (?, 'openai', ?, 7, 1, 0, 7, 8, 5, ?)`,
purpose, model, createdAt); err != nil {
t.Fatalf("insert old usage row: %v", err)
}
}
// getLlmUsage 以 owner token 取一次用量汇总。
func getLlmUsage(t *testing.T, url, query, token string) (int, []byte) {
t.Helper()
path := "/api/v1/admin/llm/usage"
if query != "" {
path += "?" + query
}
resp, body := doReq(t, http.MethodGet, url+path, nil, token)
return resp.StatusCode, body
}
// decodeLlmUsage 解出 data 本体。
func decodeLlmUsage(t *testing.T, body []byte) llmUsageResponseDTO {
t.Helper()
var out llmUsageResponseDTO
if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil {
t.Fatalf("decode usage data: %v (body=%s)", err, body)
}
return out
}
// TestAdminLlmUsageSummarizesBudgetWindow 钉住默认窗口(近 24h)下的数值与
// hitRate 公式:窗口外的行**不算进来**,命中率 = cacheHit / (cacheHit + cacheMiss)。
func TestAdminLlmUsageSummarizesBudgetWindow(t *testing.T) {
t.Parallel()
conn := llmUsageTestDB(t, t.TempDir())
seedUsageSamples(t, conn)
// 窗口外(2020 年)的一行:默认近 24h 不该看到它。
insertOldUsageRow(t, conn, "chat", "ancient-model", "2020-01-01T00:00:00.000Z")
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Usage: model.NewUsageReader(conn)})
status, body := getLlmUsage(t, ts.URL, "", signAny(t, jwt, "acct", auth.RoleOwner))
if status != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", status, body)
}
got := decodeLlmUsage(t, body)
want := llmUsageTotalsDTO{Calls: 3, Prompt: 2500, Completion: 350, CacheHit: 1400, CacheMiss: 1100, HitRate: 0.56}
if got.Totals != want {
t.Fatalf("totals = %+v, want %+v", got.Totals, want)
}
if len(got.ByPurpose) != 2 || got.ByPurpose[0].Purpose != "chat" || got.ByPurpose[0].Calls != 2 {
t.Fatalf("byPurpose = %+v, want chat 在前且 2 次调用", got.ByPurpose)
}
if got.ByPurpose[0].HitRate != 0.45 {
t.Fatalf("byPurpose[chat].hitRate = %v, want 0.45", got.ByPurpose[0].HitRate)
}
if len(got.ByModel) != 2 || got.ByModel[0].Model != "deepseek-chat" {
t.Fatalf("byModel = %+v, want deepseek-chat 在前", got.ByModel)
}
// 窗口回显必须是**补齐后的真值**(缺省 = 近 24h),且是 UTC RFC3339。
since, err := time.Parse(time.RFC3339, got.Window.Since)
if err != nil {
t.Fatalf("window.since %q 不是 RFC3339: %v", got.Window.Since, err)
}
until, err := time.Parse(time.RFC3339, got.Window.Until)
if err != nil {
t.Fatalf("window.until %q 不是 RFC3339: %v", got.Window.Until, err)
}
if span := until.Sub(since); span != model.UsageDefaultWindow {
t.Fatalf("缺省窗口 = %s, want %s", span, model.UsageDefaultWindow)
}
if got.Window.Purpose != "" {
t.Fatalf("window.purpose = %q, want 空(未过滤)", got.Window.Purpose)
}
}
// TestAdminLlmUsageHonoursWindowAndPurposeParams 钉住 since/until/purpose 真的
// 生效(用一个把 2020 那条括进来的显式窗口)。
func TestAdminLlmUsageHonoursWindowAndPurposeParams(t *testing.T) {
t.Parallel()
conn := llmUsageTestDB(t, t.TempDir())
seedUsageSamples(t, conn)
insertOldUsageRow(t, conn, "memory_extract", "ancient-model", "2020-01-01T00:00:00.000Z")
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Usage: model.NewUsageReader(conn)})
token := signAny(t, jwt, "acct", auth.RoleOwner)
status, body := getLlmUsage(t, ts.URL,
"since=2019-12-31T00:00:00Z&until=2020-01-02T00:00:00Z", token)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", status, body)
}
old := decodeLlmUsage(t, body)
if old.Totals.Calls != 1 || old.Totals.Prompt != 7 {
t.Fatalf("历史窗口 totals = %+v, want 只剩 2020 那条(calls 1 / prompt 7)", old.Totals)
}
// 同一窗口 + purpose 不匹配 ⇒ 空(但仍 200,不是 404)。
status, body = getLlmUsage(t, ts.URL,
"since=2019-12-31T00:00:00Z&until=2020-01-02T00:00:00Z&purpose=chat", token)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", status, body)
}
filtered := decodeLlmUsage(t, body)
if filtered.Totals.Calls != 0 || len(filtered.ByPurpose) != 0 || len(filtered.ByModel) != 0 {
t.Fatalf("purpose 过滤未生效:%+v", filtered)
}
// 分组列表恒为数组(不是 null)——客户端 JSON 解析不该踩空。
if raw := string(decodeBody(t, body)["data"]); strings.Contains(raw, `"byPurpose":null`) {
t.Fatalf("byPurpose 下发成了 null:%s", raw)
}
// 毫秒时间戳与 RFC3339 同义(客户端可复用消息游标的形态)。
status, body = getLlmUsage(t, ts.URL, "since=1577750400000&until=1577923200000", token)
if status != http.StatusOK {
t.Fatalf("毫秒窗口 status = %d, want 200 (body=%s)", status, body)
}
if ms := decodeLlmUsage(t, body); ms.Totals.Calls != 1 {
t.Fatalf("毫秒窗口 totals = %+v, want calls 1(与 RFC3339 同义)", ms.Totals)
}
}
// TestAdminLlmUsageRejectsBadWindows 钉住窗口校验:形状错、区间反了、跨度超上限
// 一律 400 + bad_request,**不静默裁剪**。
func TestAdminLlmUsageRejectsBadWindows(t *testing.T) {
t.Parallel()
conn := llmUsageTestDB(t, t.TempDir())
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Usage: model.NewUsageReader(conn)})
token := signAny(t, jwt, "acct", auth.RoleOwner)
cases := map[string]string{
"since 非法格式": "since=昨天",
"until 非法格式": "until=not-a-time",
"since 不早于 until": "since=2026-10-05T00:00:00Z&until=2026-10-05T00:00:00Z",
"窗口超 90 天上限": "since=2026-01-01T00:00:00Z&until=2026-10-05T00:00:00Z",
}
for name, query := range cases {
status, body := getLlmUsage(t, ts.URL, query, token)
if status != http.StatusBadRequest {
t.Fatalf("%s: status = %d, want 400 (body=%s)", name, status, body)
}
if code := errorCode(t, body); code != "bad_request" {
t.Fatalf("%s: code = %q, want bad_request", name, code)
}
}
}
// TestAdminLlmUsageAuthAndRole 钉住角色门:无凭据 401;viewer 403(同一道
// owner|admin 门,与 /admin/llm 一致)。
func TestAdminLlmUsageAuthAndRole(t *testing.T) {
t.Parallel()
conn := llmUsageTestDB(t, t.TempDir())
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Usage: model.NewUsageReader(conn)})
status, body := getLlmUsage(t, ts.URL, "", "")
if status != http.StatusUnauthorized {
t.Fatalf("无凭据 status = %d, want 401 (body=%s)", status, body)
}
wantErrorShape(t, body)
status, body = getLlmUsage(t, ts.URL, "", signAny(t, jwt, "watch", auth.RoleViewer))
if status != http.StatusForbidden {
t.Fatalf("viewer status = %d, want 403 (body=%s)", status, body)
}
wantErrorShape(t, body)
// admin 与 owner 一样放行。
status, _ = getLlmUsage(t, ts.URL, "", signAny(t, jwt, "op", auth.RoleAdmin))
if status != http.StatusOK {
t.Fatalf("admin status = %d, want 200", status)
}
}
// TestAdminLlmUsageHidesCredentials 钉住红线:响应体里既没有凭据、也没有任何
// 「顺手带出来」的字段 —— `totals` 的键集恰好是契约里的那六个。
func TestAdminLlmUsageHidesCredentials(t *testing.T) {
t.Parallel()
conn := llmUsageTestDB(t, t.TempDir())
seedUsageSamples(t, conn)
cfg := cfgDefault()
cfg.DB.PGURL = "postgres://cyrene_user:s3cret-pw@db.internal:5432/cyrene"
_, ts, jwt := buildEnv(t, cfg, Deps{Usage: model.NewUsageReader(conn)})
status, body := getLlmUsage(t, ts.URL, "", signAny(t, jwt, "acct", auth.RoleOwner))
if status != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", status, body)
}
for _, secret := range []string{
"postgres://", "s3cret-pw", "cyrene_user", "db.internal",
"apiKey", "api_key", "DSN", "dsn", "Bearer", "sk-",
} {
if strings.Contains(string(body), secret) {
t.Fatalf("响应体泄露/夹带了 %q:%s", secret, body)
}
}
var envelope struct {
Data struct {
Totals map[string]json.RawMessage `json:"totals"`
} `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
t.Fatalf("decode envelope: %v", err)
}
wantKeys := []string{"calls", "prompt", "completion", "cacheHit", "cacheMiss", "hitRate"}
if len(envelope.Data.Totals) != len(wantKeys) {
t.Fatalf("totals 键集 = %v, want 恰好 %v", envelope.Data.Totals, wantKeys)
}
for _, key := range wantKeys {
if _, ok := envelope.Data.Totals[key]; !ok {
t.Fatalf("totals 缺字段 %q(契约 v0.64):%v", key, envelope.Data.Totals)
}
}
}
// TestAdminLlmUsageUnwired 钉住「没接线不假装成功」:未注入汇总出口时 500 +
// usage_unavailable,绝不回一份全 0 的假汇总。
func TestAdminLlmUsageUnwired(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
status, body := getLlmUsage(t, ts.URL, "", signAny(t, jwt, "acct", auth.RoleOwner))
if status != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500 (body=%s)", status, body)
}
if code := errorCode(t, body); code != "usage_unavailable" {
t.Fatalf("code = %q, want usage_unavailable", code)
}
}