Files
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

198 lines
6.7 KiB
Go
Raw Permalink 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 main
import (
"context"
"database/sql"
"log/slog"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件钉住用量落库与保留期清理**被接上组合根**(切片 U1;与
// TestWireDocumentGC_IsWired 同一手法):
//
// - sink 必须装进模型栈,且模型栈必须由组合根构造 —— 少任一处,落库都会
// **静默不工作**(日志照记、库里一行没有),而这不会让任何既有用例变红;
// - 写入器与清理器必须在**关库之前**停掉(两者都要用那条连接池);
// - 启动期必须真的把两者拉起来(`a.startUsage(ctx)`);
// - 保留期 ≤0 = 关闭清理,且关闭是**显式**的(记一条 INFO)。
func TestWireUsage_IsWired(t *testing.T) {
t.Parallel()
seams := map[string][]string{
"wire_runtime.go": {
"a.usage = a.newUsageSink()",
"model.WithRuntimeUsageSink(a.usage)",
},
"wire_usage.go": {
"model.NewUsageWriter(a.db, a.log)",
"a.cfg.Server.LLMUsageRetention",
"model.PurgeUsage(ctx, g.db, before)",
"a.log.Info(\"llm usage retention disabled\"",
},
"wire_services.go": {
// 先让单写者把队列里剩下的样本写完,再停清理,最后才关库。
"a.stopUsage()",
},
"main.go": {
"a.startUsage(ctx)",
},
"wire_frontend.go": {
// 管理端点的只读汇总出口(网关复用组合根已打开的连接池)。
"Usage: model.NewUsageReader(a.db)",
},
}
for file, wants := range seams {
src, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
for _, want := range wants {
if !strings.Contains(string(src), want) {
t.Fatalf("%s 里找不到 %q —— 用量落库没有被接上组合根", file, want)
}
}
}
// 新 env 键必须与代码里的 env 标签逐字一致(AGENTS §10 铁律)。
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
if err != nil {
t.Fatalf("read .env.example: %v", err)
}
if !strings.Contains(string(raw), "LLM_USAGE_RETENTION=") {
t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10)", "LLM_USAGE_RETENTION=")
}
if tag := envTagOf(t, "LLMUsageRetention"); tag != "LLM_USAGE_RETENTION" {
t.Fatalf("config.ServerConfig.LLMUsageRetention 的 env 标签 = %q, 与模板不一致", tag)
}
}
// envTagOf 返回 config.ServerConfig 上某字段的 env 标签。
func envTagOf(t *testing.T, field string) string {
t.Helper()
typ, ok := reflect.TypeOf(config.ServerConfig{}).FieldByName(field)
if !ok {
t.Fatalf("config.ServerConfig 里没有字段 %q", field)
}
return typ.Tag.Get("env")
}
// usageGCDB 打开一个带迁移的内存库。
func usageGCDB(t *testing.T) *sql.DB {
t.Helper()
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", slog.New(slog.NewTextHandler(os.Stderr, nil)))
if err != nil {
t.Fatalf("open in-memory db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.MigrateUp(ctx, conn, slog.New(slog.NewTextHandler(os.Stderr, nil))); err != nil {
t.Fatalf("migrate: %v", err)
}
return conn
}
// usageGCApp 构造一个只带清理器所需依赖的 app。
func usageGCApp(t *testing.T, conn *sql.DB, retention time.Duration) *app {
t.Helper()
return &app{
cfg: &config.Config{Server: config.ServerConfig{LLMUsageRetention: retention}},
db: conn,
log: slog.New(slog.NewTextHandler(os.Stderr, nil)),
}
}
// TestUsageRetentionGC_SweepRemovesExpiredOnly 钉住清理判据:早于保留期的样本被
// 删、保留期内的原样留着。
func TestUsageRetentionGC_SweepRemovesExpiredOnly(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn := usageGCDB(t)
insertUsageAt(t, conn, "2020-01-01T00:00:00.000Z")
insertUsageAt(t, conn, "2099-01-01T00:00:00.000Z")
gc := usageGCApp(t, conn, 720*time.Hour).newUsageRetentionGC()
if gc == nil {
t.Fatal("保留期 > 0 时必须构造清理器")
}
gc.sweep(ctx)
if rows := countUsage(t, conn); rows != 1 {
t.Fatalf("清理后剩 %d 行, want 1(只删过期那条)", rows)
}
var remaining string
if err := conn.QueryRowContext(ctx, `SELECT created_at FROM llm_usage`).Scan(&remaining); err != nil {
t.Fatalf("read remaining: %v", err)
}
if remaining != "2099-01-01T00:00:00.000Z" {
t.Fatalf("剩下的是 %q, want 未过期那条", remaining)
}
}
// TestUsageRetentionGC_DisabledWhenNonPositive 钉住「≤0 = 关闭清理」是显式关:
// 0 与负值都不构造清理器(因而一行都不会被删),正数才构造。
func TestUsageRetentionGC_DisabledWhenNonPositive(t *testing.T) {
t.Parallel()
conn := usageGCDB(t)
for _, retention := range []time.Duration{0, -time.Hour} {
if gc := usageGCApp(t, conn, retention).newUsageRetentionGC(); gc != nil {
t.Fatalf("LLM_USAGE_RETENTION=%s 时不该构造清理器", retention)
}
}
// 关闭状态下过期行原样留着(没有任何代码路径去删它)。
insertUsageAt(t, conn, "2020-01-01T00:00:00.000Z")
if rows := countUsage(t, conn); rows != 1 {
t.Fatalf("关闭清理却动了数据:剩 %d 行", rows)
}
if gc := usageGCApp(t, conn, time.Hour).newUsageRetentionGC(); gc == nil {
t.Fatal("LLM_USAGE_RETENTION=1h 时必须构造清理器")
}
}
// TestUsageWriter_NilDBMeansNoSink 钉住「库未接 ⇒ sink 为 nil ⇒ 只记日志」的
// 组合根口径(与 model 层的 nil-sink 行为合成一条链)。
func TestUsageWriter_NilDBMeansNoSink(t *testing.T) {
t.Parallel()
a := &app{log: slog.New(slog.NewTextHandler(os.Stderr, nil))}
if sink := a.newUsageSink(); sink != nil {
t.Fatalf("库未接时 newUsageSink 必须返回 nil,实得 %#v", sink)
}
if gc := a.newUsageRetentionGC(); gc != nil {
t.Fatal("库未接时不该构造清理器")
}
// 未注入 sink 的模型栈照常构造(不 panic),且模型栈不因 nil sink 变红。
stack := model.NewRuntimeLLM(filepath.Join(t.TempDir(), "models.json"),
config.LLMConfig{}, a.log)
if stack == nil {
t.Fatal("nil sink 不该影响模型栈构造")
}
}
// insertUsageAt 写一行带指定 created_at 的用量样本。
func insertUsageAt(t *testing.T, conn *sql.DB, createdAt string) {
t.Helper()
if _, err := conn.ExecContext(context.Background(),
`INSERT INTO llm_usage (purpose, adapter, model, prompt_tokens, created_at)
VALUES ('chat', 'openai', 'm', 1, ?)`, createdAt); err != nil {
t.Fatalf("insert usage row: %v", err)
}
}
// countUsage 返回 llm_usage 行数。
func countUsage(t *testing.T, conn *sql.DB) int64 {
t.Helper()
var rows int64
if err := conn.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM llm_usage`).Scan(&rows); err != nil {
t.Fatalf("count llm_usage: %v", err)
}
return rows
}