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(),
}
}
@@ -0,0 +1,320 @@
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)
}
}
+15
View File
@@ -246,3 +246,18 @@ type RagStore interface {
// 不存在该文档时返回 knowledge.ErrDocumentNotFound。
DeleteDocument(ctx context.Context, id string) error
}
// LlmUsageStore 是 LLM 用量汇总的**只读**出口(切片 U1,
// GET /api/v1/admin/llm/usage)。*runtime/model.UsageReader 是生产
// 实现:它在一块时间窗内聚合 `llm_usage` 表的 token 计数与缓存两档,
// 按用途与模型分解。
//
// 汇总里**没有任何凭据、也没有消息正文** —— 那些东西根本没进
// `llm_usage` 表(写入侧 model.UsageWriter 的样本里就没有这些字段)。
// 为 nil 时端点诚实作答 500 usage_unavailable,绝不回一份全 0
// 的假汇总。
type LlmUsageStore interface {
// Summary 聚合 q 描述的时间窗与用途过滤。q 的时间窗由
// handler 校验并补齐(缺省近 24h、上限 90 天)。
Summary(ctx context.Context, q model.UsageQuery) (model.UsageSummary, error)
}
+4
View File
@@ -138,4 +138,8 @@ type Deps struct {
// handler 自身强制其签名/授权(webhook HMAC)。
// 可选。
Webhooks map[string]http.Handler
// Usage 是 LLM 用量汇总的只读出口(切片 U1)。可选;为 nil 时
// GET /api/v1/admin/llm/usage 诚实作答 500 usage_unavailable,
// 绝不回一份全 0 的假汇总。
Usage LlmUsageStore
}
+3
View File
@@ -168,6 +168,9 @@ func (s *Server) registerAdminRoutes(
mux.Handle("GET /api/v1/admin/channels/messages", writeAuth(http.HandlerFunc(s.handleChannelMessages)))
mux.Handle("GET /api/v1/admin/channels/{platform}/messages", writeAuth(http.HandlerFunc(s.handleChannelPlatformLog)))
mux.Handle("GET /api/v1/admin/llm", writeAuth(http.HandlerFunc(s.adminGetLlm)))
// LLM 用量汇总(切片 U1,**v0.64**):只读、不审计,与同页的 /admin/llm
// 同门(owner|admin)—— 它含成本与模型/用途分解,不是给 viewer 的监控投影。
mux.Handle("GET /api/v1/admin/llm/usage", writeAuth(http.HandlerFunc(s.adminLlmUsage)))
// 模型目录候选(裁决 03.4.2):面板「添加提供商 / 添加模型」的
// baseURL / 模型清单 / 能力位来源,服务端快照 + 离线兜底。
mux.Handle("GET /api/v1/admin/llm/catalog", writeAuth(http.HandlerFunc(s.adminGetLlmCatalog)))
+4
View File
@@ -83,6 +83,9 @@ type Server struct {
// (尽力而为)。见 Deps.OwnerRegistrar。
ownerRegistrar OwnerRegistrar
webhooks map[string]http.Handler
// usage 是 LLM 用量汇总的只读出口(切片 U1,见 Deps.Usage)。为 nil 时
// GET /api/v1/admin/llm/usage 回 500 usage_unavailable。
usage LlmUsageStore
// db 是组合根注入的活 SQLite 连接池(见 Deps.Database)。管理侧
// 「存储状态」只读端点用它读页级统计与 documents 表;为 nil 时该
// 端点回 500 storage_unavailable(不另开池、不假装有数据)。
@@ -134,6 +137,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
plugins: deps.Plugins,
ownerRegistrar: deps.OwnerRegistrar,
webhooks: deps.Webhooks,
usage: deps.Usage,
dbPath: deps.DatabasePath,
dataDir: deps.DataDir,
db: deps.Database,