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 侧在请求上打标,留列后接
This commit is contained in:
1 parent
18381f65fd
commit
be38651ba4
32 files changed
+2233
-44
No files matched your search
@@ -92,6 +92,13 @@ type ServerConfig struct {
|
||||
// 「立即回收」,一次笔误不该把整库未引用附件清空。判据与动作见
|
||||
// internal/runtime/documents/gc.go。
|
||||
DocumentRetention time.Duration `env:"DOCUMENT_RETENTION" default:"168h"`
|
||||
|
||||
// LLMUsageRetention 是 LLM 用量样本(`llm_usage` 表)的保留期(Go duration
|
||||
// string),默认 720h = 30 天(切片 U1,主人裁决「2.4 落」)。**≤0 = 关闭
|
||||
// 清理**:0 同样刻意不解释成「立即清空」,而是「不清理、全留」——一次笔误
|
||||
// 不该把成本视图清空。清理在启动期跑一次,此后按固定间隔(见
|
||||
// cmd/cyrene/wire_usage.go)继续。
|
||||
LLMUsageRetention time.Duration `env:"LLM_USAGE_RETENTION" default:"720h"`
|
||||
}
|
||||
|
||||
// AdminPathPrefix 返回管理面板前端路由的绝对路径前缀(默认 `/admin`)。
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 0017_llm_usage down: 回滚用量表。
|
||||
--
|
||||
-- 回滚后的状态是「用量只落日志、库里查不到」的旧形态(切片 U1 之前):已记录的
|
||||
-- 历史样本随表一起丢弃,汇总端点(GET /api/v1/admin/llm/usage)随之报错 —— 这是
|
||||
-- 诚实的失败,而不是回一个全 0 的假汇总。丢弃是安全的:本表不被任何外键引用,
|
||||
-- 运行时的写入路径(model.UsageWriter)也只在表存在时才有意义。
|
||||
DROP INDEX IF EXISTS idx_llm_usage_session_id;
|
||||
|
||||
DROP INDEX IF EXISTS idx_llm_usage_created_at;
|
||||
|
||||
DROP TABLE IF EXISTS llm_usage;
|
||||
@@ -0,0 +1,46 @@
|
||||
-- 0017_llm_usage: 每次成功模型调用的用量落库(切片 U1,主人裁决「2.4 落」)。
|
||||
--
|
||||
-- 背景:T1 已在每次模型调用后记一条结构化日志(prompt / completion / total /
|
||||
-- reasoning / cacheHit / cacheMiss / hitRate / tookMs),但**只落日志** ——
|
||||
-- 看不见趋势、算不出按用途/模型的成本,面板也没有数据源。本表把同一条样本
|
||||
-- 落进库里,供只读汇总端点(GET /api/v1/admin/llm/usage)聚合。
|
||||
--
|
||||
-- 口径:
|
||||
-- * **无凭据、无正文**:本表只有 token 计数、模型名、用途标签与耗时 ——
|
||||
-- apiKey / token / 消息正文一个字都不入库(写入方 model.UsageWriter 连
|
||||
-- 参数都拿不到凭据)。
|
||||
-- * cache_hit_tokens / cache_miss_tokens 是**输入侧两档**(提供商按命中/未命中
|
||||
-- 两档计价,命中价约为未命中的 1/50),命中率 = hit / (hit + miss),分母为 0
|
||||
-- 时按 0 处理(口径与 model.Response.CacheHitRate 同源)。
|
||||
-- * session_id 可空。**当前尚无写入方**(恒为 NULL):模型适配器跑在 loop 之下、
|
||||
-- 没有会话概念(见 internal/runtime/model/stream.go 的 StreamEvent.SessionID
|
||||
-- 注释),把会话标识带到适配器边界需要 loop 侧在请求上打标 —— 那份文件本轮
|
||||
-- 不在本切片改动范围内,故先留列、后接线。
|
||||
-- * created_at 由 SQLite 以 UTC ISO-8601(毫秒)写入,与 0001 起的口径一致;
|
||||
-- 汇总端点的时间窗按同一格式做**字典序**比较(定宽格式下等价于时间序)。
|
||||
-- * 表名用复数,援引 AGENTS §10 已登记的例外(既有迁移口径一致)。
|
||||
CREATE TABLE IF NOT EXISTS llm_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT,
|
||||
purpose TEXT NOT NULL DEFAULT '',
|
||||
adapter TEXT NOT NULL DEFAULT '',
|
||||
model TEXT NOT NULL DEFAULT '',
|
||||
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_hit_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_miss_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
took_ms INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
|
||||
-- 汇总端点与保留期清理都按时间窗扫表(`created_at >= ? AND created_at <= ?` /
|
||||
-- `created_at < ?`),这条索引是两者的唯一依赖。
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_usage_created_at
|
||||
ON llm_usage (created_at);
|
||||
|
||||
-- 按会话下钻(面板将来要「这个会话烧了多少」)。当前 session_id 恒为 NULL,
|
||||
-- 索引仍然建好 —— 它不影响写入正确性,接线后立刻可用。
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_usage_session_id
|
||||
ON llm_usage (session_id);
|
||||
@@ -133,6 +133,9 @@ type AnthropicChatAdapter struct {
|
||||
model string
|
||||
client *http.Client
|
||||
recorder Recorder
|
||||
// usageSink 是可选的用量落库出口(切片 U1)。为 nil 时只记结构化日志,
|
||||
// 行为逐字等于引入落库之前。
|
||||
usageSink UsageSink
|
||||
// log 上报推理调整与被忽略的指令。它永不为 nil:
|
||||
// 调用方未提供时,会装入一个带有本适配器来源标记的丢弃式日志器,
|
||||
// 因此提供商的每个决定都可追溯。
|
||||
@@ -153,6 +156,12 @@ func WithAnthropicRecorder(r Recorder) AnthropicOption {
|
||||
return func(a *AnthropicChatAdapter) { a.recorder = r }
|
||||
}
|
||||
|
||||
// WithAnthropicUsageSink 装入用量落库出口(切片 U1)。nil 会被忽略,从而保留
|
||||
// 「只记日志」的默认行为;实现方必须非阻塞(见 UsageSink)。
|
||||
func WithAnthropicUsageSink(s UsageSink) AnthropicOption {
|
||||
return func(a *AnthropicChatAdapter) { a.usageSink = s }
|
||||
}
|
||||
|
||||
// WithAnthropicModel 覆盖适配器的默认模型名。
|
||||
func WithAnthropicModel(m string) AnthropicOption {
|
||||
return func(a *AnthropicChatAdapter) { a.model = m }
|
||||
@@ -403,7 +412,7 @@ func (a *AnthropicChatAdapter) upstreamError(resp *http.Response) error {
|
||||
// record 上报一次成功调用的用量:先记一条结构化用量日志(含缓存命中率,
|
||||
// 所有调用点都覆盖 —— 见 usage.go),再喂给可选的 CallRecord 计账出口。
|
||||
func (a *AnthropicChatAdapter) record(req Request, start time.Time, resp Response) {
|
||||
logUsage(a.log, a.name, req, resp, time.Since(start))
|
||||
logUsage(a.log, a.usageSink, a.name, req, resp, time.Since(start))
|
||||
if a.recorder == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -57,20 +57,26 @@ func NewChatChain(cfg config.LLMConfig) (*Chain, error) {
|
||||
// 配置好时才会追加;否则链保持两级形状,并
|
||||
// 上报原因。
|
||||
func NewChatChainWithLog(cfg config.LLMConfig, log *slog.Logger) (*Chain, error) {
|
||||
return newChainWith(cfg, log, nil)
|
||||
}
|
||||
|
||||
// newChainWith 是 NewChatChainWithLog 加上用量 sink(切片 U1)。sink 为 nil 时
|
||||
// 逐字等于后者:适配器只记日志。组合根经 RuntimeLLM 注入真实写入器。
|
||||
func newChainWith(cfg config.LLMConfig, log *slog.Logger, sink UsageSink) (*Chain, error) {
|
||||
var chain *Chain
|
||||
var err error
|
||||
switch cfg.Provider {
|
||||
case ProviderOpenAI, "":
|
||||
chain, err = newOpenAIChain(cfg, WithOpenAILogger(log))
|
||||
chain, err = newOpenAIChain(cfg, WithOpenAILogger(log), WithUsageSink(sink))
|
||||
case ProviderAnthropic:
|
||||
chain, err = newAnthropicChain(cfg, WithAnthropicLogger(log))
|
||||
chain, err = newAnthropicChain(cfg, WithAnthropicLogger(log), WithAnthropicUsageSink(sink))
|
||||
default:
|
||||
return nil, fmt.Errorf("model: %w: provider %q", ErrUnknownProvider, cfg.Provider)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if step, ok := localChainStep(cfg, log); ok {
|
||||
if step, ok := localChainStep(cfg, log, sink); ok {
|
||||
chain.steps = append(chain.steps, step)
|
||||
}
|
||||
return chain, nil
|
||||
@@ -80,8 +86,8 @@ func NewChatChainWithLog(cfg config.LLMConfig, log *slog.Logger) (*Chain, error)
|
||||
// 当该级关闭、未配置或其提供商未知时,返回 ok=false
|
||||
// (若该级被要求但配置不全,则附一条点出缺失字段的 WARN)
|
||||
// ——一个半配置的步骤只会失败,那还不如没有
|
||||
// 它。
|
||||
func localChainStep(cfg config.LLMConfig, log *slog.Logger) (Step, bool) {
|
||||
// 它。用量 sink 照旧透传:本地级也是模型调用,同样要计成本。
|
||||
func localChainStep(cfg config.LLMConfig, log *slog.Logger, sink UsageSink) (Step, bool) {
|
||||
local := cfg.Local
|
||||
// Configured() 本身已要求 Enabled,所以该级默认关闭,
|
||||
// 被禁用的级永远不会成为链步骤。只有当该级确实被
|
||||
@@ -102,9 +108,9 @@ func localChainStep(cfg config.LLMConfig, log *slog.Logger) (Step, bool) {
|
||||
}
|
||||
switch local.Provider {
|
||||
case ProviderAnthropic:
|
||||
return Step{Model: local.Model, Adapter: NewAnthropicChat(localCfg, WithAnthropicLogger(log))}, true
|
||||
return Step{Model: local.Model, Adapter: NewAnthropicChat(localCfg, WithAnthropicLogger(log), WithAnthropicUsageSink(sink))}, true
|
||||
case ProviderOpenAI, "":
|
||||
return Step{Model: local.Model, Adapter: NewOpenAIChat(localCfg, WithOpenAILogger(log))}, true
|
||||
return Step{Model: local.Model, Adapter: NewOpenAIChat(localCfg, WithOpenAILogger(log), WithUsageSink(sink))}, true
|
||||
default:
|
||||
warn(log, "local model rung skipped: unknown provider",
|
||||
"provider", local.Provider, "field", "LLM_LOCAL_PROVIDER")
|
||||
|
||||
@@ -40,6 +40,9 @@ type OpenAIChatAdapter struct {
|
||||
model string
|
||||
client *http.Client
|
||||
recorder Recorder
|
||||
// usageSink 是可选的用量落库出口(切片 U1)。为 nil 时只记结构化日志,
|
||||
// 行为逐字等于引入落库之前 —— 这也是所有既有测试与未接线调用方的形态。
|
||||
usageSink UsageSink
|
||||
// reasoningKind 是本端点已配置的推理开关形状
|
||||
// (models.json 组的 reasoning.kind / LLM_REASONING_KIND,裁决 03.4.2/08.4.1)。
|
||||
// 为空表示 "auto":按模型名/base URL 推断厂商,
|
||||
@@ -65,6 +68,12 @@ func WithRecorder(r Recorder) OpenAIOption {
|
||||
return func(a *OpenAIChatAdapter) { a.recorder = r }
|
||||
}
|
||||
|
||||
// WithUsageSink 装入用量落库出口(切片 U1)。nil 会被忽略,从而保留
|
||||
// 「只记日志」的默认行为;实现方必须非阻塞(见 UsageSink)。
|
||||
func WithUsageSink(s UsageSink) OpenAIOption {
|
||||
return func(a *OpenAIChatAdapter) { a.usageSink = s }
|
||||
}
|
||||
|
||||
// WithModel 覆盖适配器的默认模型名。
|
||||
func WithModel(m string) OpenAIOption {
|
||||
return func(a *OpenAIChatAdapter) { a.model = m }
|
||||
@@ -233,7 +242,7 @@ func (a *OpenAIChatAdapter) upstreamError(resp *http.Response) error {
|
||||
// record 上报一次成功调用的用量:先记一条结构化用量日志(含缓存命中率,
|
||||
// 所有调用点都覆盖 —— 见 usage.go),再喂给可选的 CallRecord 计账出口。
|
||||
func (a *OpenAIChatAdapter) record(req Request, start time.Time, resp Response) {
|
||||
logUsage(a.log, a.name, req, resp, time.Since(start))
|
||||
logUsage(a.log, a.usageSink, a.name, req, resp, time.Since(start))
|
||||
if a.recorder == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -45,17 +45,41 @@ type RuntimeLLM struct {
|
||||
path string
|
||||
env config.LLMConfig
|
||||
log *slog.Logger
|
||||
sink UsageSink
|
||||
current atomic.Value // 类型:*runtimeChainSnapshot
|
||||
}
|
||||
|
||||
// RuntimeOption 用于配置 RuntimeLLM。
|
||||
type RuntimeOption func(*RuntimeLLM)
|
||||
|
||||
// WithRuntimeUsageSink 装入用量落库出口(切片 U1):它透传到本栈构建的**每一条**
|
||||
// 链、每一步适配器(含按角色跨协议的链与本地第三级),因此任何一条路径上的模型
|
||||
// 调用都会被记入成本。nil 会被忽略,从而保留「只记日志」的默认行为。
|
||||
//
|
||||
// 为什么 sink 挂在栈上而不是适配器上:适配器由本栈在**每次热重载**时重建,sink
|
||||
// 必须是那段生命周期之外的稳定引用 —— 否则面板每保存一次 models.json,落库就
|
||||
// 静默断一次。
|
||||
func WithRuntimeUsageSink(sink UsageSink) RuntimeOption {
|
||||
return func(m *RuntimeLLM) {
|
||||
if sink != nil {
|
||||
m.sink = sink
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewRuntimeLLM 由 path 处的 models.json 构建提供商栈,并回退
|
||||
// 到 env。它会加载初始快照,因此运行时立即可用。
|
||||
// log 可为 nil(此时使用丢弃式日志器)。
|
||||
func NewRuntimeLLM(path string, env config.LLMConfig, log *slog.Logger) *RuntimeLLM {
|
||||
func NewRuntimeLLM(path string, env config.LLMConfig, log *slog.Logger, opts ...RuntimeOption) *RuntimeLLM {
|
||||
if log == nil {
|
||||
log = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
m := &RuntimeLLM{path: path, env: env, log: logger.WithSource(log, "model")}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(m)
|
||||
}
|
||||
}
|
||||
m.loadInitial()
|
||||
return m
|
||||
}
|
||||
@@ -184,7 +208,7 @@ func (m *RuntimeLLM) swap(groups []runtimeconfig.Group) error {
|
||||
if len(groups) == 0 {
|
||||
groups = runtimeconfig.GroupFromEnv(m.env)
|
||||
}
|
||||
snap, err := buildSnapshot(groups, m.env, m.log)
|
||||
snap, err := buildSnapshot(groups, m.env, m.log, m.sink)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -213,11 +237,11 @@ func (m *RuntimeLLM) load() *runtimeChainSnapshot {
|
||||
// (裁决 03.4.1)。它刻意不给 env 的视觉模型:图像路由
|
||||
// 归 vision.Router 所有,而这里曾有的「带图 → 切换模型」规则绕过了
|
||||
// 那个决策(裁决 14.4.1/14.4.5)。
|
||||
func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog.Logger) (*runtimeChainSnapshot, error) {
|
||||
func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog.Logger, sink UsageSink) (*runtimeChainSnapshot, error) {
|
||||
active := groups[0]
|
||||
cfg := active.ToLLMConfig()
|
||||
cfg.Local = env.Local
|
||||
chain, err := NewChatChainWithLog(cfg, log)
|
||||
chain, err := newChainWith(cfg, log, sink)
|
||||
if err != nil && !errors.Is(err, ErrNoModel) {
|
||||
return nil, err
|
||||
}
|
||||
@@ -233,7 +257,7 @@ func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog
|
||||
router: router,
|
||||
group: active,
|
||||
groups: groups,
|
||||
providerChains: personaProviderChains(groups, active, env, log),
|
||||
providerChains: personaProviderChains(groups, active, env, log, sink),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -248,7 +272,7 @@ func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog
|
||||
// 模型名经路由器到达它。personaProviders 中协议
|
||||
// 没有对应组的条目会被上报并跳过——半构建的链只会
|
||||
// 失败,该 persona 改为保留默认链。
|
||||
func personaProviderChains(groups []runtimeconfig.Group, active runtimeconfig.Group, env config.LLMConfig, log *slog.Logger) map[string]*Chain {
|
||||
func personaProviderChains(groups []runtimeconfig.Group, active runtimeconfig.Group, env config.LLMConfig, log *slog.Logger, sink UsageSink) map[string]*Chain {
|
||||
if len(active.PersonaProviders) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -272,7 +296,7 @@ func personaProviderChains(groups []runtimeconfig.Group, active runtimeconfig.Gr
|
||||
"persona", persona, "provider", provider)
|
||||
continue
|
||||
}
|
||||
chain := personaChain(g, model, env, log)
|
||||
chain := personaChain(g, model, env, log, sink)
|
||||
if chain != nil {
|
||||
out[provider] = chain
|
||||
}
|
||||
@@ -289,7 +313,7 @@ func personaProviderChains(groups []runtimeconfig.Group, active runtimeconfig.Gr
|
||||
// (reasoning.kind)随组的配置一起走,因此被路由到
|
||||
// 另一厂商的 persona 仍保持该厂商的开关形状。它会报告原因,
|
||||
// 并在该组无法服务一条链时返回 nil。
|
||||
func personaChain(g runtimeconfig.Group, model string, env config.LLMConfig, log *slog.Logger) *Chain {
|
||||
func personaChain(g runtimeconfig.Group, model string, env config.LLMConfig, log *slog.Logger, sink UsageSink) *Chain {
|
||||
cfg := g.ToLLMConfig()
|
||||
if cfg.Provider == ProviderAnthropic {
|
||||
cfg.AnthropicModel = model
|
||||
@@ -297,7 +321,7 @@ func personaChain(g runtimeconfig.Group, model string, env config.LLMConfig, log
|
||||
cfg.Model = model
|
||||
}
|
||||
cfg.Local = env.Local
|
||||
chain, err := NewChatChainWithLog(cfg, log)
|
||||
chain, err := newChainWith(cfg, log, sink)
|
||||
if err != nil && !errors.Is(err, ErrNoModel) {
|
||||
warn(log, "per-persona chain skipped", "group", g.ID, "model", model, "error", err)
|
||||
return nil
|
||||
|
||||
@@ -16,32 +16,96 @@ import (
|
||||
// 这样流式、非流式、后台思考、子代理、管理面板试连等**所有**模型调用点都覆盖到,
|
||||
// 不会有「换个调用入口就没了成本日志」的缺口。
|
||||
//
|
||||
// 红线:日志里只出现模型名、用途、token 数与耗时 —— **凭据(apiKey / token)绝不
|
||||
// 出现在这里**,它连参数都进不来。
|
||||
// 出口有**两条**,互不阻塞:
|
||||
//
|
||||
// 1. 结构化日志(本条一直如此,T1);
|
||||
// 2. 落库(切片 U1)—— 经可选的 UsageSink 交给组合根注入的异步写入器。
|
||||
// sink 为 nil 时**只记日志、行为逐字不变**(既有测试因此零改动)。
|
||||
//
|
||||
// 红线:日志与落库样本里只出现模型名、用途、token 数与耗时 —— **凭据(apiKey /
|
||||
// token)与消息正文绝不出现**,它们连参数都进不来。
|
||||
|
||||
// usageLogEvent 是每条用量日志的事件名,便于按事件过滤与统计。
|
||||
const usageLogEvent = "model usage"
|
||||
|
||||
// UsageSample 是**一次成功模型调用的用量样本**:既是那条结构化日志的字段来源,
|
||||
// 也是落库写入(UsageSink)的载荷。
|
||||
//
|
||||
// 它刻意只带非敏感计数与标签:凭据与正文不在字段集里,因此无论日志还是落库都
|
||||
// 没有把它们写出去的可能。
|
||||
type UsageSample struct {
|
||||
// Adapter 是服务该次调用的适配器/提供商分组标识。
|
||||
Adapter string
|
||||
// Model 是产出该响应的模型名。
|
||||
Model string
|
||||
// Purpose 是调用用途标签(如 "chat" / "memory_extract" / "engage_judge")。
|
||||
Purpose string
|
||||
// PromptTokens / CompletionTokens / TotalTokens 是提供商上报的用量。
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
TotalTokens int
|
||||
// CacheHitTokens / CacheMissTokens 是**输入侧**的两档 token 数。
|
||||
CacheHitTokens int
|
||||
CacheMissTokens int
|
||||
// ReasoningTokens 是完成侧里属于思维链的 token 数。
|
||||
ReasoningTokens int
|
||||
// TookMs 是端到端调用耗时(毫秒)。
|
||||
TookMs int64
|
||||
}
|
||||
|
||||
// UsageSink 接收每次成功调用的用量样本。实现方必须**非阻塞**:它在模型调用的
|
||||
// 返回路径上被同步调用,任何等待都会把成本记到主人的每次对话上。
|
||||
//
|
||||
// 组合根注入的 model.UsageWriter 满足它(有界队列 + 单写者,满则丢弃并限流告警)。
|
||||
// 为 nil = 不落库,只记日志 —— 这是「没接 sink」与「接了 sink」唯一的行为差别。
|
||||
type UsageSink interface {
|
||||
// RecordUsage 收下一次调用的用量样本。实现方绝不阻塞、绝不 panic。
|
||||
RecordUsage(sample UsageSample)
|
||||
}
|
||||
|
||||
// usageSampleOf 把一次调用归一化成 UsageSample。它是日志与落库**唯一**的字段来源,
|
||||
// 两处口径因此不可能分叉。
|
||||
func usageSampleOf(adapter string, req Request, resp Response, took time.Duration) UsageSample {
|
||||
return UsageSample{
|
||||
Adapter: adapter,
|
||||
Model: resp.Model,
|
||||
Purpose: req.Purpose,
|
||||
PromptTokens: resp.PromptTokens,
|
||||
CompletionTokens: resp.CompletionTokens,
|
||||
TotalTokens: resp.TotalTokens,
|
||||
CacheHitTokens: resp.CacheHitTokens,
|
||||
CacheMissTokens: resp.CacheMissTokens,
|
||||
ReasoningTokens: resp.ReasoningTokens,
|
||||
TookMs: took.Milliseconds(),
|
||||
}
|
||||
}
|
||||
|
||||
// logUsage 记一条结构化用量日志:prompt / completion / total / reasoning /
|
||||
// cacheHit / cacheMiss / hitRate,外加模型名、用途、适配器与耗时。
|
||||
// cacheHit / cacheMiss / hitRate,外加模型名、用途、适配器与耗时;随后把同一个
|
||||
// 样本交给可选的 sink 落库。
|
||||
//
|
||||
// log 可以为 nil(适配器在未接日志时用的是丢弃 logger),这里仍做一次防御:
|
||||
// 缺字段容错、绝不 panic。
|
||||
func logUsage(log *slog.Logger, adapter string, req Request, resp Response, took time.Duration) {
|
||||
if log == nil {
|
||||
// 缺字段容错、绝不 panic。sink 为 nil 时**这一步是空操作** —— 逐字等于引入落库
|
||||
// 之前的行为。
|
||||
func logUsage(log *slog.Logger, sink UsageSink, adapter string, req Request, resp Response, took time.Duration) {
|
||||
sample := usageSampleOf(adapter, req, resp, took)
|
||||
if log != nil {
|
||||
log.Info(usageLogEvent,
|
||||
"adapter", sample.Adapter,
|
||||
"model", sample.Model,
|
||||
"purpose", sample.Purpose,
|
||||
"prompt", sample.PromptTokens,
|
||||
"completion", sample.CompletionTokens,
|
||||
"total", sample.TotalTokens,
|
||||
"reasoning", sample.ReasoningTokens,
|
||||
"cacheHit", sample.CacheHitTokens,
|
||||
"cacheMiss", sample.CacheMissTokens,
|
||||
"hitRate", resp.CacheHitRate(),
|
||||
"tookMs", sample.TookMs,
|
||||
)
|
||||
}
|
||||
if sink == nil {
|
||||
return
|
||||
}
|
||||
log.Info(usageLogEvent,
|
||||
"adapter", adapter,
|
||||
"model", resp.Model,
|
||||
"purpose", req.Purpose,
|
||||
"prompt", resp.PromptTokens,
|
||||
"completion", resp.CompletionTokens,
|
||||
"total", resp.TotalTokens,
|
||||
"reasoning", resp.ReasoningTokens,
|
||||
"cacheHit", resp.CacheHitTokens,
|
||||
"cacheMiss", resp.CacheMissTokens,
|
||||
"hitRate", resp.CacheHitRate(),
|
||||
"tookMs", took.Milliseconds(),
|
||||
)
|
||||
sink.RecordUsage(sample)
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrUsageUnavailable 表示用量汇总出口不可用(未接库 / 未构造读取器)。
|
||||
// 网关据此回 500 usage_unavailable,而不是回一份全 0 的假汇总。
|
||||
var ErrUsageUnavailable = errors.New("model: llm usage store unavailable")
|
||||
|
||||
// 本文件是**用量落库的读取侧**(切片 U1):把 `llm_usage` 表在给定时间窗与用途
|
||||
// 过滤下聚合成一份只读汇总,供管理端点 `GET /api/v1/admin/llm/usage` 与面板的
|
||||
// 用量小卡消费。
|
||||
//
|
||||
// 三条纪律:
|
||||
//
|
||||
// 1. **只读**:只有 SELECT(外加保留期清理的 DELETE,见 PurgeUsage),不建表、
|
||||
// 不迁移,复用组合根注入的活连接。
|
||||
// 2. **不含凭据**:查出来的字段只有 token 计数与模型/用途标签 —— 表里本来就没有
|
||||
// 凭据与正文,这条红线在写入侧就已经成立。
|
||||
// 3. **不编造**:读不到就返回错误,绝不用一份全 0 的假汇总假装成功(面板据此
|
||||
// 显示错误,而不是一个看着正常的假数字)。
|
||||
|
||||
const (
|
||||
// UsageDefaultWindow 是汇总的缺省时间窗(近 24 小时):不带 since/until 时
|
||||
// 「现在」往前推这么久。
|
||||
UsageDefaultWindow = 24 * time.Hour
|
||||
// UsageMaxWindow 是一次汇总查询允许的最大时间窗(90 天)。超过它的请求由
|
||||
// 网关以 400 明确拒绝,**不静默裁剪** —— 静默裁剪会让客户端以为拿到的是
|
||||
// 全量,把「我没查那么久」误读成「就那么少」。
|
||||
UsageMaxWindow = 90 * 24 * time.Hour
|
||||
// usageTimeLayout 与迁移 0017 的 `strftime('%Y-%m-%dT%H:%M:%fZ')` 逐字同形
|
||||
// (定宽 UTC ISO-8601,毫秒)。定宽使字典序比较等价于时间序,时间窗因此能
|
||||
// 直接用字符串比较、吃上 idx_llm_usage_created_at。
|
||||
usageTimeLayout = "2006-01-02T15:04:05.000Z"
|
||||
)
|
||||
|
||||
// UsageQuery 是一次汇总查询的条件。Since/Until 为零值时由调用方按缺省窗口补齐
|
||||
// (见 UsageDefaultWindow);Purpose 为空表示不过滤。
|
||||
type UsageQuery struct {
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
Purpose string
|
||||
}
|
||||
|
||||
// UsageTotals 是一个时间窗(或一个分组)内的用量合计。全部是**非敏感计数**。
|
||||
type UsageTotals struct {
|
||||
// Calls 是窗口内成功调用次数。
|
||||
Calls int64
|
||||
// Prompt / Completion 是输入侧与输出侧 token 合计。
|
||||
Prompt int64
|
||||
Completion int64
|
||||
// CacheHit / CacheMiss 是输入侧两档 token 合计。
|
||||
CacheHit int64
|
||||
CacheMiss int64
|
||||
}
|
||||
|
||||
// HitRate 返回缓存命中率 = cacheHit / (cacheHit + cacheMiss)。分母为 0(窗口内没有
|
||||
// 任何输入侧明细,或根本没有调用)时返回 0 —— 口径与 Response.CacheHitRate 同源,
|
||||
// 不编造 100% 或 0% 以外的假值。
|
||||
func (t UsageTotals) HitRate() float64 {
|
||||
total := t.CacheHit + t.CacheMiss
|
||||
if total <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(t.CacheHit) / float64(total)
|
||||
}
|
||||
|
||||
// UsageGroup 是按某个键(用途 / 模型)聚合的一行。
|
||||
type UsageGroup struct {
|
||||
// Key 是分组键(purpose 或 model 的取值)。
|
||||
Key string
|
||||
UsageTotals
|
||||
}
|
||||
|
||||
// UsageSummary 是一次汇总查询的结果。
|
||||
type UsageSummary struct {
|
||||
// Window 是本次实际生效的时间窗与过滤条件(回显给客户端,而不是让它猜
|
||||
// 缺省值)。
|
||||
Window UsageQuery
|
||||
// Totals 是整窗合计。
|
||||
Totals UsageTotals
|
||||
// ByPurpose / ByModel 是按用途与按模型的分解(按调用次数降序,同次数按键
|
||||
// 升序 —— 输出确定,客户端可安全 diff)。
|
||||
ByPurpose []UsageGroup
|
||||
ByModel []UsageGroup
|
||||
}
|
||||
|
||||
// UsageReader 是 `llm_usage` 表的只读出口。
|
||||
type UsageReader struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewUsageReader 构造读取器;db 为 nil(未接库)时返回 nil。
|
||||
func NewUsageReader(db *sql.DB) *UsageReader {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
return &UsageReader{db: db}
|
||||
}
|
||||
|
||||
// usageRowScanner 是 *sql.Row 与 *sql.Rows 共有的扫码能力,使合计与分组两条
|
||||
// 查询路径共用同一个扫码函数。
|
||||
type usageRowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
// usageAggregateCols 是合计列清单(顺序与 scanUsageTotals 的扫码顺序逐字一致)。
|
||||
const usageAggregateCols = `COUNT(*),
|
||||
COALESCE(SUM(prompt_tokens),0),
|
||||
COALESCE(SUM(completion_tokens),0),
|
||||
COALESCE(SUM(cache_hit_tokens),0),
|
||||
COALESCE(SUM(cache_miss_tokens),0)`
|
||||
|
||||
// Summary 聚合一个时间窗内的用量。q 的时间窗必须已由调用方校验并补齐(零值窗口
|
||||
// 会原样查库、什么都查不到)。
|
||||
func (r *UsageReader) Summary(ctx context.Context, q UsageQuery) (UsageSummary, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return UsageSummary{}, fmt.Errorf("model: %w", ErrUsageUnavailable)
|
||||
}
|
||||
totals, err := r.totalsOf(ctx, q)
|
||||
if err != nil {
|
||||
return UsageSummary{}, err
|
||||
}
|
||||
byPurpose, err := r.groupsOf(ctx, q, usageGroupByPurpose)
|
||||
if err != nil {
|
||||
return UsageSummary{}, err
|
||||
}
|
||||
byModel, err := r.groupsOf(ctx, q, usageGroupByModel)
|
||||
if err != nil {
|
||||
return UsageSummary{}, err
|
||||
}
|
||||
return UsageSummary{Window: q, Totals: totals, ByPurpose: byPurpose, ByModel: byModel}, nil
|
||||
}
|
||||
|
||||
// totalsOf 读整窗合计。空窗时 SUM 为 NULL,COALESCE 收敛成 0;表不存在(迁移没跑)
|
||||
// 则直接报错,不静默报 0。
|
||||
func (r *UsageReader) totalsOf(ctx context.Context, q UsageQuery) (UsageTotals, error) {
|
||||
query := `SELECT ` + usageAggregateCols + ` FROM llm_usage WHERE created_at >= ? AND created_at <= ?`
|
||||
args := []any{usageTime(q.Since), usageTime(q.Until)}
|
||||
if q.Purpose != "" {
|
||||
query += ` AND purpose = ?`
|
||||
args = append(args, q.Purpose)
|
||||
}
|
||||
totals, err := scanUsageTotals(r.db.QueryRowContext(ctx, query, args...))
|
||||
if err != nil {
|
||||
return UsageTotals{}, fmt.Errorf("model: read usage totals: %w", err)
|
||||
}
|
||||
return totals, nil
|
||||
}
|
||||
|
||||
// usageGroupBy* 是允许的分组列:它们是代码常量,**不是**用户输入(分组列无法参数化,
|
||||
// 因此这里以白名单常量而非拼接来保证不注入)。
|
||||
const (
|
||||
usageGroupByPurpose = "purpose"
|
||||
usageGroupByModel = "model"
|
||||
)
|
||||
|
||||
// groupsOf 按 groupBy 列聚合。groupBy 只接受 usageGroupBy* 常量,未知值一律报错
|
||||
// (绝不把任意字符串拼进 SQL)。
|
||||
func (r *UsageReader) groupsOf(ctx context.Context, q UsageQuery, groupBy string) ([]UsageGroup, error) {
|
||||
if groupBy != usageGroupByPurpose && groupBy != usageGroupByModel {
|
||||
return nil, fmt.Errorf("model: %w: unknown usage group %q", ErrBadRequest, groupBy)
|
||||
}
|
||||
query := `SELECT ` + groupBy + `, ` + usageAggregateCols +
|
||||
` FROM llm_usage WHERE created_at >= ? AND created_at <= ?`
|
||||
args := []any{usageTime(q.Since), usageTime(q.Until)}
|
||||
if q.Purpose != "" {
|
||||
query += ` AND purpose = ?`
|
||||
args = append(args, q.Purpose)
|
||||
}
|
||||
query += ` GROUP BY ` + groupBy + ` ORDER BY COUNT(*) DESC, ` + groupBy + ` ASC`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("model: read usage groups by %s: %w", groupBy, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]UsageGroup, 0, 8)
|
||||
for rows.Next() {
|
||||
group, err := scanUsageGroup(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("model: scan usage group by %s: %w", groupBy, err)
|
||||
}
|
||||
out = append(out, group)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("model: iterate usage groups by %s: %w", groupBy, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// scanUsageTotals 按 usageAggregateCols 的顺序扫码。
|
||||
func scanUsageTotals(sc usageRowScanner) (UsageTotals, error) {
|
||||
var t UsageTotals
|
||||
if err := sc.Scan(&t.Calls, &t.Prompt, &t.Completion, &t.CacheHit, &t.CacheMiss); err != nil {
|
||||
return UsageTotals{}, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// scanUsageGroup 扫码「分组键 + 合计列」。
|
||||
func scanUsageGroup(sc usageRowScanner) (UsageGroup, error) {
|
||||
var key string
|
||||
var t UsageTotals
|
||||
if err := sc.Scan(&key, &t.Calls, &t.Prompt, &t.Completion, &t.CacheHit, &t.CacheMiss); err != nil {
|
||||
return UsageGroup{}, err
|
||||
}
|
||||
return UsageGroup{Key: key, UsageTotals: t}, nil
|
||||
}
|
||||
|
||||
// PurgeUsage 删除 `created_at` 早于 before 的用量行,返回删除条数。db 为 nil 时是
|
||||
// 空操作。它与保留期清理(组合根按 LLM_USAGE_RETENTION 定时调用)是唯一会写这张
|
||||
// 表、又不是追加样本的语句。
|
||||
func PurgeUsage(ctx context.Context, db *sql.DB, before time.Time) (int64, error) {
|
||||
if db == nil {
|
||||
return 0, nil
|
||||
}
|
||||
res, err := db.ExecContext(ctx, `DELETE FROM llm_usage WHERE created_at < ?`, usageTime(before))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("model: purge llm usage: %w", err)
|
||||
}
|
||||
removed, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("model: purge llm usage rows affected: %w", err)
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
// usageTime 把时间点转成与迁移 0017 的 DEFAULT 逐字同形的 UTC 字符串(定宽毫秒),
|
||||
// 使时间窗比较是纯字符串比较、且能走 idx_llm_usage_created_at。
|
||||
func usageTime(t time.Time) string {
|
||||
return t.UTC().Format(usageTimeLayout)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// 本文件是**用量落库的写入侧**(切片 U1,主人裁决「2.4 落」):一个有界队列 +
|
||||
// 单写者 + 批量事务的异步写入器。
|
||||
//
|
||||
// 为什么不是「在 logUsage 里直接 INSERT」:那条语句跑在**每一次模型调用的返回
|
||||
// 路径**上,库慢一点、锁一下,主人的每次对话就跟着慢一点 —— 记成本的开销绝不
|
||||
// 能加到被记成本的那次调用上。这里因此把两者彻底解耦:
|
||||
//
|
||||
// 1. `RecordUsage` 只往**有界** channel 里塞一个样本,塞不进就**丢弃并限流
|
||||
// WARN**(绝不阻塞、绝不 panic、绝不无界增长);
|
||||
// 2. 一个常驻单写者把队列攒成批次,在一个事务里落库;
|
||||
// 3. 整批失败只记一条带来源的 WARN(**不静默**),调用路径毫无感知。
|
||||
//
|
||||
// 写入样本里没有凭据、没有正文(见 UsageSample),这条红线在类型层面就成立。
|
||||
|
||||
// usageSource 是本文件每条日志的来源标签(跨模块可追溯定位,项目规范 §10)。
|
||||
const usageSource = "model/usage_store"
|
||||
|
||||
const (
|
||||
// usageQueueCapacity 是落库队列的容量。它是**有界**的:满了就丢弃,
|
||||
// 而不是把模型调用拖住。
|
||||
usageQueueCapacity = 1024
|
||||
// usageBatchMax 是单次批量事务最多写入的行数。
|
||||
usageBatchMax = 128
|
||||
// usageFlushInterval 是队列非空时的最长等待:低频调用不会因为没攒满一批
|
||||
// 就一直不落库(面板看得到的是近 24h,不该差这几十秒)。
|
||||
usageFlushInterval = 2 * time.Second
|
||||
// usageDropWarnInterval 是「队列满、已丢弃样本」告警的限流间隔:真实部署里
|
||||
// 库挂了会让每一次调用都丢弃,不限流就会把日志刷爆。
|
||||
usageDropWarnInterval = 30 * time.Second
|
||||
// usageFinalFlushTimeout 是停止时把队列剩余样本写完的期限。
|
||||
usageFinalFlushTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// insertUsageSQL 写入一条用量样本。**session_id 当前恒为 NULL**(模型适配器跑在
|
||||
// loop 之下、没有会话概念;见迁移 0017 的说明),列留着是为了接线时只改这一处。
|
||||
const insertUsageSQL = `INSERT INTO llm_usage
|
||||
(session_id, purpose, adapter, model, prompt_tokens, completion_tokens,
|
||||
cache_hit_tokens, cache_miss_tokens, reasoning_tokens, total_tokens, took_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
// UsageWriter 把用量样本异步落进 `llm_usage`。它实现 UsageSink,因此在模型适配器
|
||||
// 里就是一个「可选出口」:注入即有、不注入则只记日志。
|
||||
//
|
||||
// 构造后必须 Start 才会真的落库;未 Start 时队列照样收、塞满后照样丢弃并告警
|
||||
// (不静默失败)。Stop 必须在关库之前调用。
|
||||
type UsageWriter struct {
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
|
||||
queue chan UsageSample
|
||||
done chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
started bool
|
||||
dropped int64
|
||||
lastWarn time.Time
|
||||
}
|
||||
|
||||
// NewUsageWriter 构造写入器。db 为 nil(未接库)时返回 nil —— 调用方据此把
|
||||
// 「无 sink」原样传给适配器,行为等于这一版之前。
|
||||
func NewUsageWriter(db *sql.DB, log *slog.Logger) *UsageWriter {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
if log == nil {
|
||||
log = discardLogger(usageSource)
|
||||
}
|
||||
return &UsageWriter{
|
||||
db: db,
|
||||
log: logger.WithSource(log, usageSource),
|
||||
queue: make(chan UsageSample, usageQueueCapacity),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// RecordUsage 实现 UsageSink:把样本塞进有界队列,塞不进就丢弃并(限流)告警。
|
||||
//
|
||||
// 它**绝不阻塞**:整个函数是一次非阻塞 channel 发送加一次可能的加锁自增,
|
||||
// 因此模型调用路径的额外开销与库的健康状况无关。
|
||||
func (w *UsageWriter) RecordUsage(sample UsageSample) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case w.queue <- sample:
|
||||
default:
|
||||
w.noteDrop()
|
||||
}
|
||||
}
|
||||
|
||||
// Start 拉起单写者循环。重复调用是空操作;ctx 取消(或 Stop)后循环退出。
|
||||
func (w *UsageWriter) Start(ctx context.Context) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.started {
|
||||
return
|
||||
}
|
||||
w.started = true
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
w.cancel = cancel
|
||||
go w.loop(runCtx)
|
||||
}
|
||||
|
||||
// Stop 停掉循环并等它退出,退出前尽力把队列里剩下的样本写完。未启动时是空操作。
|
||||
// 它必须在关库**之前**调用(收尾写入要用那条连接池)。
|
||||
func (w *UsageWriter) Stop() {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.mu.Lock()
|
||||
cancel := w.cancel
|
||||
started := w.started
|
||||
w.mu.Unlock()
|
||||
if !started || cancel == nil {
|
||||
return
|
||||
}
|
||||
cancel()
|
||||
<-w.done
|
||||
}
|
||||
|
||||
// loop 是单写者:攒批 → 批量事务落库,直到 ctx 取消。
|
||||
func (w *UsageWriter) loop(ctx context.Context) {
|
||||
defer close(w.done)
|
||||
ticker := time.NewTicker(usageFlushInterval)
|
||||
defer ticker.Stop()
|
||||
batch := make([]UsageSample, 0, usageBatchMax)
|
||||
flush := func() {
|
||||
if len(batch) == 0 {
|
||||
return
|
||||
}
|
||||
w.writeBatch(ctx, batch)
|
||||
batch = batch[:0]
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// 收尾用**脱离取消**的上下文:ctx 已经取消,沿用它会让最后一批
|
||||
// 必然写失败,等于丢掉已经收下的样本。
|
||||
w.flushFinal(drainQueue(w.queue, batch))
|
||||
return
|
||||
case sample := <-w.queue:
|
||||
batch = append(batch, sample)
|
||||
if len(batch) >= usageBatchMax {
|
||||
flush()
|
||||
}
|
||||
case <-ticker.C:
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flushFinal 在停机路径上把剩余样本写完(有期限,绝不无限等库)。
|
||||
func (w *UsageWriter) flushFinal(remaining []UsageSample) {
|
||||
if len(remaining) == 0 {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), usageFinalFlushTimeout)
|
||||
defer cancel()
|
||||
w.writeBatch(ctx, remaining)
|
||||
}
|
||||
|
||||
// writeBatch 在一个事务里落一批样本。失败只记 WARN(带批次大小与原因),
|
||||
// 绝不上抛 —— 调用路径早已返回,没有任何人在等这个结果。
|
||||
func (w *UsageWriter) writeBatch(ctx context.Context, batch []UsageSample) {
|
||||
if err := insertUsageBatch(ctx, w.db, batch); err != nil {
|
||||
w.log.Warn("llm usage batch insert failed; batch dropped", "count", len(batch), "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// noteDrop 记一次「队列满、样本被丢弃」,并按 usageDropWarnInterval 限流告警。
|
||||
// 丢弃计数是累计值,因此告警里能看到这一段到底丢了多少条。
|
||||
func (w *UsageWriter) noteDrop() {
|
||||
w.mu.Lock()
|
||||
w.dropped++
|
||||
dropped := w.dropped
|
||||
now := time.Now()
|
||||
shouldWarn := w.lastWarn.IsZero() || now.Sub(w.lastWarn) >= usageDropWarnInterval
|
||||
if shouldWarn {
|
||||
w.lastWarn = now
|
||||
}
|
||||
w.mu.Unlock()
|
||||
if shouldWarn {
|
||||
w.log.Warn("llm usage queue full; samples dropped (never blocks the model call)",
|
||||
"dropped", dropped, "capacity", usageQueueCapacity)
|
||||
}
|
||||
}
|
||||
|
||||
// drainQueue 把队列里当前排着的样本全部取出(不阻塞),追加到 dst 之后返回。
|
||||
func drainQueue(queue chan UsageSample, dst []UsageSample) []UsageSample {
|
||||
for {
|
||||
select {
|
||||
case sample := <-queue:
|
||||
dst = append(dst, sample)
|
||||
default:
|
||||
return dst
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// insertUsageBatch 在一个事务里写入一批样本:单条 prepared statement 循环执行,
|
||||
// 一次提交。用 prepared statement 而不是多值 INSERT,是为了避开各家 SQLite 对
|
||||
// 绑定参数个数的不同上限(11 列 × 128 行)。
|
||||
func insertUsageBatch(ctx context.Context, db *sql.DB, batch []UsageSample) error {
|
||||
if db == nil || len(batch) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin usage batch: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
stmt, err := tx.PrepareContext(ctx, insertUsageSQL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare usage insert: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for i, sample := range batch {
|
||||
// session_id 传 NULL:见 insertUsageSQL 的说明。
|
||||
if _, err := stmt.ExecContext(ctx,
|
||||
nil,
|
||||
sample.Purpose,
|
||||
sample.Adapter,
|
||||
sample.Model,
|
||||
sample.PromptTokens,
|
||||
sample.CompletionTokens,
|
||||
sample.CacheHitTokens,
|
||||
sample.CacheMissTokens,
|
||||
sample.ReasoningTokens,
|
||||
sample.TotalTokens,
|
||||
sample.TookMs,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert usage sample %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit usage batch: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
)
|
||||
|
||||
// 本文件钉住 LLM 用量**落库**(切片 U1):
|
||||
// - sink 为 nil ⇒ 行为逐字不变(只记日志);
|
||||
// - sink 非 nil ⇒ 样本抵达写入器,字段与日志同源;
|
||||
// - 队列满 ⇒ 丢弃 + 限流 WARN,**绝不阻塞**模型调用路径;
|
||||
// - 批量落库后读回,命中率按 cacheHit / (cacheHit + cacheMiss) 算出。
|
||||
|
||||
// usageSinkProbeTimeout 是「RecordUsage 不许阻塞」的判定预算:真实现是微秒级,
|
||||
// 这里给 5 秒是为了让判定只对「真的在等」敏感,不对机器忙敏感。
|
||||
const usageSinkProbeTimeout = 5 * time.Second
|
||||
|
||||
// usageTestDB 打开一个带全部迁移的内存库(`:memory:` 每个句柄一份私有库,
|
||||
// 并行测试天然隔离)。
|
||||
func usageTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
conn, err := db.Open(context.Background(), ":memory:", discardLogger("model/usage_test"))
|
||||
if err != nil {
|
||||
t.Fatalf("open in-memory db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
if err := db.MigrateUp(context.Background(), conn, discardLogger("model/usage_test")); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return conn
|
||||
}
|
||||
|
||||
// fakeUsageSink 捕获被投递的样本,用于断言 sink ↔ logUsage 调用点的字段一致。
|
||||
type fakeUsageSink struct {
|
||||
samples []UsageSample
|
||||
}
|
||||
|
||||
func (f *fakeUsageSink) RecordUsage(sample UsageSample) { f.samples = append(f.samples, sample) }
|
||||
|
||||
// TestUsageSink_NilKeepsLogOnlyBehavior 钉住「不注入 sink ⇒ 行为逐字不变」:
|
||||
// 适配器照常完成调用,且**恰好**记一条结构化用量日志。
|
||||
func TestUsageSink_NilKeepsLogOnlyBehavior(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, nil)
|
||||
log, buf := captureJSONLog()
|
||||
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "gpt-test"}, WithOpenAILogger(log))
|
||||
|
||||
resp, err := ad.Chat(context.Background(), Request{
|
||||
Purpose: "chat",
|
||||
Messages: []Message{TextMessage(RoleUser, "hi")},
|
||||
}, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if resp.Content != "Hello!" {
|
||||
t.Fatalf("正文受落库改动影响: %q", resp.Content)
|
||||
}
|
||||
// 没有 sink 时也恰好一条用量日志(多一条或少一条都会在这里变红)。
|
||||
rec := usageLogRecord(t, buf.String())
|
||||
if rec["purpose"] != "chat" || rec["prompt"] == nil || rec["cacheMiss"] == nil {
|
||||
t.Fatalf("用量日志字段缺失:%v", rec)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUsageSink_ReceivesSameFieldsAsLog 钉住 sink ↔ 既有 logUsage 调用点的成对
|
||||
// 契约:同一个样本既进日志、也进 sink,字段逐项一致(两处口径不分叉)。
|
||||
func TestUsageSink_ReceivesSameFieldsAsLog(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := `{
|
||||
"id": "cmpl-1", "model": "deepseek-chat",
|
||||
"choices": [{"index":0,"message":{"role":"assistant","content":"你好"},"finish_reason":"stop"}],
|
||||
"usage": {"prompt_tokens": 1000, "completion_tokens": 200, "total_tokens": 1200,
|
||||
"prompt_cache_hit_tokens": 900, "prompt_cache_miss_tokens": 100,
|
||||
"completion_tokens_details": {"reasoning_tokens": 150}}
|
||||
}`
|
||||
srv := newCompletionServer(t, http.StatusOK, "application/json", body, nil)
|
||||
sink := &fakeUsageSink{}
|
||||
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "deepseek-chat"}, WithUsageSink(sink))
|
||||
|
||||
if _, err := ad.Chat(context.Background(), Request{
|
||||
Purpose: "chat",
|
||||
Messages: []Message{TextMessage(RoleUser, "hi")},
|
||||
}, Options{}); err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if len(sink.samples) != 1 {
|
||||
t.Fatalf("sink 收到 %d 条样本, want 1", len(sink.samples))
|
||||
}
|
||||
got := sink.samples[0]
|
||||
want := UsageSample{
|
||||
Adapter: ad.Name(),
|
||||
Model: "deepseek-chat",
|
||||
Purpose: "chat",
|
||||
PromptTokens: 1000,
|
||||
CompletionTokens: 200,
|
||||
TotalTokens: 1200,
|
||||
CacheHitTokens: 900,
|
||||
CacheMissTokens: 100,
|
||||
ReasoningTokens: 150,
|
||||
}
|
||||
got.TookMs = 0 // 耗时不做等值断言(它是真实的墙钟量)
|
||||
if got != want {
|
||||
t.Fatalf("sink 样本 = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUsageWriter_QueueFullDropsWithoutBlocking 钉住「写入绝不阻塞模型调用」:
|
||||
// 队列满时再投递只丢弃(并限流 WARN),投递方**不等待**。
|
||||
//
|
||||
// 写入器刻意不 Start(没有消费者),队列因此停在满的状态 —— 这正是真实部署里
|
||||
// 库卡住/变慢时的形态。
|
||||
func TestUsageWriter_QueueFullDropsWithoutBlocking(t *testing.T) {
|
||||
t.Parallel()
|
||||
conn := usageTestDB(t)
|
||||
log, buf := captureJSONLog()
|
||||
writer := NewUsageWriter(conn, log)
|
||||
if writer == nil {
|
||||
t.Fatal("NewUsageWriter 在接了库时不该返回 nil")
|
||||
}
|
||||
sample := UsageSample{Adapter: "openai", Model: "m", Purpose: "chat", PromptTokens: 1}
|
||||
|
||||
for i := 0; i < usageQueueCapacity; i++ {
|
||||
writer.RecordUsage(sample)
|
||||
}
|
||||
if got := len(writer.queue); got != usageQueueCapacity {
|
||||
t.Fatalf("队列长度 = %d, want 满 %d", got, usageQueueCapacity)
|
||||
}
|
||||
|
||||
const overflow = 64
|
||||
delivered := make(chan struct{})
|
||||
go func() {
|
||||
for i := 0; i < overflow; i++ {
|
||||
writer.RecordUsage(sample)
|
||||
}
|
||||
close(delivered)
|
||||
}()
|
||||
select {
|
||||
case <-delivered:
|
||||
case <-time.After(usageSinkProbeTimeout):
|
||||
t.Fatal("队列满时 RecordUsage 阻塞了 —— 它必须丢弃而不能等")
|
||||
}
|
||||
|
||||
// 丢弃不许静默:一条限流 WARN(后续丢弃被 30s 窗口压住,不刷屏),
|
||||
// 且累计丢弃数是**全部**溢出条数。
|
||||
raw := buf.String()
|
||||
if !strings.Contains(raw, "llm usage queue full") {
|
||||
t.Fatalf("队列满没有告警(不静默的红线):%s", raw)
|
||||
}
|
||||
if got := strings.Count(raw, "llm usage queue full"); got != 1 {
|
||||
t.Fatalf("限流告警记了 %d 条, want 1(否则库一挂就把日志刷爆):%s", got, raw)
|
||||
}
|
||||
if writer.dropped != overflow {
|
||||
t.Fatalf("累计丢弃 = %d, want %d", writer.dropped, overflow)
|
||||
}
|
||||
// 没有 Start ⇒ 一条都没落库(丢弃是真的丢弃,不是「攒着」)。
|
||||
if rows := usageRowCount(t, conn); rows != 0 {
|
||||
t.Fatalf("未启动写入器却落了 %d 行", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUsageWriter_PersistsBatchAndReaderSummarizes 钉住「批量写入 → 读回」:
|
||||
// Stop 会把队列里剩下的样本在一个事务里写完,读侧按时间窗聚合,命中率按
|
||||
// cacheHit / (cacheHit + cacheMiss) 算出。
|
||||
func TestUsageWriter_PersistsBatchAndReaderSummarizes(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
conn := usageTestDB(t)
|
||||
log, buf := captureJSONLog()
|
||||
writer := NewUsageWriter(conn, log)
|
||||
writer.Start(ctx)
|
||||
|
||||
samples := []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()
|
||||
|
||||
if rows := usageRowCount(t, conn); rows != int64(len(samples)) {
|
||||
t.Fatalf("落库行数 = %d, want %d(日志:%s)", rows, len(samples), buf.String())
|
||||
}
|
||||
// session_id 当前恒为 NULL(模型层无会话概念):列在、但序列化面已说明。
|
||||
var nullSessions int64
|
||||
if err := conn.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM llm_usage WHERE session_id IS NULL`).Scan(&nullSessions); err != nil {
|
||||
t.Fatalf("count null session_id: %v", err)
|
||||
}
|
||||
if nullSessions != int64(len(samples)) {
|
||||
t.Fatalf("session_id 为 NULL 的行 = %d, want %d", nullSessions, len(samples))
|
||||
}
|
||||
|
||||
until := time.Now().Add(time.Minute)
|
||||
summary, err := NewUsageReader(conn).Summary(ctx, UsageQuery{Since: until.Add(-time.Hour), Until: until})
|
||||
if err != nil {
|
||||
t.Fatalf("Summary: %v", err)
|
||||
}
|
||||
wantTotals := UsageTotals{Calls: 3, Prompt: 2500, Completion: 350, CacheHit: 1400, CacheMiss: 1100}
|
||||
if summary.Totals != wantTotals {
|
||||
t.Fatalf("totals = %+v, want %+v", summary.Totals, wantTotals)
|
||||
}
|
||||
if got := summary.Totals.HitRate(); got != 0.56 {
|
||||
t.Fatalf("hitRate = %v, want 0.56(1400/2500)", got)
|
||||
}
|
||||
if len(summary.ByPurpose) != 2 || summary.ByPurpose[0].Key != "chat" {
|
||||
t.Fatalf("byPurpose = %+v, want chat 在前(2 次调用)", summary.ByPurpose)
|
||||
}
|
||||
if summary.ByPurpose[0].Calls != 2 || summary.ByPurpose[0].HitRate() != 0.45 {
|
||||
t.Fatalf("byPurpose[0] = %+v, want calls 2 / hitRate 0.45", summary.ByPurpose[0])
|
||||
}
|
||||
if summary.ByPurpose[1].Key != "memory_extract" || summary.ByPurpose[1].HitRate() != 1 {
|
||||
t.Fatalf("byPurpose[1] = %+v, want memory_extract / hitRate 1", summary.ByPurpose[1])
|
||||
}
|
||||
if len(summary.ByModel) != 2 || summary.ByModel[0].Key != "deepseek-chat" || summary.ByModel[0].Calls != 2 {
|
||||
t.Fatalf("byModel = %+v, want deepseek-chat 在前(2 次调用)", summary.ByModel)
|
||||
}
|
||||
// 空窗(无调用)不是错误:零合计 + 命中率 0(分母 0 的口径)。
|
||||
empty, err := NewUsageReader(conn).Summary(ctx, UsageQuery{
|
||||
Since: until.Add(-48 * time.Hour), Until: until.Add(-47 * time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Summary(empty): %v", err)
|
||||
}
|
||||
if empty.Totals != (UsageTotals{}) || empty.Totals.HitRate() != 0 {
|
||||
t.Fatalf("空窗 = %+v, want 全零且 hitRate 0", empty.Totals)
|
||||
}
|
||||
if len(empty.ByPurpose) != 0 || len(empty.ByModel) != 0 {
|
||||
t.Fatalf("空窗不该有分组行:%+v / %+v", empty.ByPurpose, empty.ByModel)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUsageReader_WindowAndPurposeFilter 钉住时间窗与用途过滤真的生效(写入侧
|
||||
// 逐行给定 created_at,因此窗口边界是确定的,不靠墙钟)。
|
||||
func TestUsageReader_WindowAndPurposeFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
conn := usageTestDB(t)
|
||||
insertUsageRow(t, conn, "chat", "m1", "2026-10-01T00:00:00.000Z", 100, 10, 90, 10)
|
||||
insertUsageRow(t, conn, "chat", "m1", "2026-10-03T00:00:00.000Z", 200, 20, 180, 20)
|
||||
insertUsageRow(t, conn, "noise_review", "m2", "2026-10-03T00:00:00.000Z", 40, 4, 0, 40)
|
||||
|
||||
reader := NewUsageReader(conn)
|
||||
day := func(iso string) time.Time {
|
||||
parsed, err := time.Parse(time.RFC3339, iso)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", iso, err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// 只覆盖第二天 ⇒ 两条算进来(m1 的 200 与 noise_review 的 40)。
|
||||
all, err := reader.Summary(ctx, UsageQuery{
|
||||
Since: day("2026-10-02T00:00:00Z"),
|
||||
Until: day("2026-10-04T00:00:00Z"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Summary(window): %v", err)
|
||||
}
|
||||
if all.Totals.Calls != 2 || all.Totals.Prompt != 240 {
|
||||
t.Fatalf("窗口内合计 = %+v, want calls 2 / prompt 240", all.Totals)
|
||||
}
|
||||
// 同窗口 + purpose 过滤 ⇒ 只剩 chat 那条。
|
||||
chatOnly, err := reader.Summary(ctx, UsageQuery{
|
||||
Since: day("2026-10-02T00:00:00Z"),
|
||||
Until: day("2026-10-04T00:00:00Z"),
|
||||
Purpose: "chat",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Summary(purpose): %v", err)
|
||||
}
|
||||
if chatOnly.Totals.Calls != 1 || chatOnly.Totals.Prompt != 200 || chatOnly.ByPurpose[0].Key != "chat" {
|
||||
t.Fatalf("purpose 过滤结果 = %+v, want 只剩 chat 的 200", chatOnly.Totals)
|
||||
}
|
||||
// 全窗(含第一天)⇒ 三条。
|
||||
full, err := reader.Summary(ctx, UsageQuery{
|
||||
Since: day("2026-09-30T00:00:00Z"),
|
||||
Until: day("2026-10-05T00:00:00Z"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Summary(full): %v", err)
|
||||
}
|
||||
if full.Totals.Calls != 3 || full.Totals.CacheHit != 270 || full.Totals.CacheMiss != 70 {
|
||||
t.Fatalf("全窗合计 = %+v, want calls 3 / hit 270 / miss 70", full.Totals)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPurgeUsageRemovesExpiredOnly 钉住保留期清理的判据:早于 before 的删掉、
|
||||
// 之后的原样保留(before 恰好等于某行的时刻时保留 —— 判据是**严格早于**)。
|
||||
func TestPurgeUsageRemovesExpiredOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
conn := usageTestDB(t)
|
||||
insertUsageRow(t, conn, "chat", "m1", "2026-09-01T00:00:00.000Z", 10, 1, 5, 5)
|
||||
insertUsageRow(t, conn, "chat", "m1", "2026-10-04T00:00:00.000Z", 20, 2, 10, 10)
|
||||
|
||||
cutoff, err := time.Parse(time.RFC3339, "2026-10-04T00:00:00Z")
|
||||
if err != nil {
|
||||
t.Fatalf("parse cutoff: %v", err)
|
||||
}
|
||||
removed, err := PurgeUsage(ctx, conn, cutoff)
|
||||
if err != nil {
|
||||
t.Fatalf("PurgeUsage: %v", err)
|
||||
}
|
||||
if removed != 1 {
|
||||
t.Fatalf("删除 %d 行, want 1(只删严格早于 cutoff 的那条)", removed)
|
||||
}
|
||||
if rows := usageRowCount(t, conn); rows != 1 {
|
||||
t.Fatalf("剩余行数 = %d, want 1", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// insertUsageRow 直接写一行样本,created_at 由调用方给定(窗口/清理用例靠它把
|
||||
// 时间钉死,不依赖墙钟)。
|
||||
func insertUsageRow(t *testing.T, conn *sql.DB, purpose, model, createdAt string, prompt, completion, hit, miss int) {
|
||||
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', ?, ?, ?, ?, ?, ?, 0, ?)`,
|
||||
purpose, model, prompt, completion, hit, miss, prompt+completion, createdAt); err != nil {
|
||||
t.Fatalf("insert usage row: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// usageRowCount 返回 llm_usage 的当前行数。
|
||||
func usageRowCount(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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)))
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user