feat(model): llm_usage.session_id 真正落库(补完 U1 缺口)

通道:新增 model/session_ctx.go 的 WithSessionID / SessionIDFrom(与 tool.WithTurnPersona
同族的 ctx 范式,避开 model 反向 import loop 的环),loop 在 Run 顶部播种;适配器
record(ctx,...) 接会话;usageSessionArg 空串映射为 NULL。
兼容:sink 未注入或会话未播种时行为逐字不变;既有 model 测试零断言改动即全绿。
文件:model/{session_ctx.go(新), usage.go, usage_store.go, openai.go, anthropic.go,
usage_session_test.go(新)} + loop/{run.go, usage_session_test.go(新)}。
变异三处:去 loop 打标 / 写侧不绑会话 / 适配器不读 ctx ⇒ 各自红行,还原复绿。
契约与日志字段集未动;读侧不按 session 过滤,面板汇总口径不变。
This commit is contained in:
AskaEth committed 2026-10-05 08:04:49 +08:00
1 parent cb564fe981
commit 59a1ac4056
9 files changed
+359 -18

No files matched your search

@@ -0,0 +1,137 @@
package model
import (
"context"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// 本文件钉住「会话标识端到端进 llm_usage.session_id」的前半程(模型层):
// loop 经 ctx 播种会话(session_ctx.go)⇒ 适配器在**两条**调用路径上都把
// 它读进 UsageSample ⇒ 没播种时是空串(落库据此写 NULL,而不是空串)。
//
// 后半程(真的写进列)由 usage_store_test.go 的
// TestUsageWriter_PersistsSessionID 钉住,loop 侧的播种由
// internal/runtime/loop/usage_session_test.go 端到端钉住。
// sessionUsageRequest 是最小可用请求(只关心用量样本里的会话字段)。
func sessionUsageRequest() Request {
return Request{Purpose: "chat", Messages: []Message{TextMessage(RoleUser, "hi")}}
}
// TestUsageSink_CarriesSessionFromContext 钉住打标通道:会话挂在 ctx 上时,
// 流式与非流式两条路径交给 sink 的样本都带着它。
//
// 两条路径都要覆盖:它们进入适配器的方式不同(ChatStream 由循环直接驱动,
// Chat 由 agent.RunStep 驱动),漏任何一条都会让那半边的用量归不了档。
func TestUsageSink_CarriesSessionFromContext(t *testing.T) {
t.Parallel()
const session = "web:sess-usage"
jsonSrv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, nil)
streamSrv := newCompletionServer(t, http.StatusOK, "text/event-stream", strings.Join([]string{
`data: {"model":"gpt-test","choices":[{"index":0,"delta":{"content":"你好"}}]}`,
`data: {"model":"gpt-test","choices":[],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}`,
`data: [DONE]`,
}, "\n")+"\n", nil)
anthropicSrv := newAnthropicServer(t, http.StatusOK, "application/json", anthropicTextResponse, nil)
ctx := WithSessionID(context.Background(), session)
probes := []struct {
name string
call func(sink UsageSink) error
}{
{"openai 非流式 Chat", func(sink UsageSink) error {
ad := NewOpenAIChat(config.LLMConfig{BaseURL: jsonSrv.URL, Model: "gpt-test"}, WithUsageSink(sink))
_, err := ad.Chat(ctx, sessionUsageRequest(), Options{})
return err
}},
{"openai 流式 ChatStream", func(sink UsageSink) error {
ad := NewOpenAIChat(config.LLMConfig{BaseURL: streamSrv.URL, Model: "gpt-test"}, WithUsageSink(sink))
_, err := ad.ChatStream(ctx, sessionUsageRequest(), nil)
return err
}},
{"anthropic 非流式 Chat", func(sink UsageSink) error {
ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: anthropicSrv.URL, AnthropicModel: "claude-test"},
WithAnthropicUsageSink(sink))
_, err := ad.Chat(ctx, sessionUsageRequest(), Options{})
return err
}},
}
for _, p := range probes {
sink := &fakeUsageSink{}
if err := p.call(sink); err != nil {
t.Fatalf("%s: 调用失败: %v", p.name, err)
}
if len(sink.samples) != 1 {
t.Fatalf("%s: sink 收到 %d 条样本, want 1", p.name, len(sink.samples))
}
if got := sink.samples[0].SessionID; got != session {
t.Fatalf("%s: 样本 SessionID = %q, want %q(会话打标在这一路上丢了)", p.name, got, session)
}
}
}
// TestUsageSink_UnseededSessionStaysEmpty 钉住「没播种 ⇒ 空串」:管理面板试连、
// 未接循环的后台流程本就不属于任何会话,样本里必须是空串 —— 写入侧据此写 NULL;
// 一旦这里变成别的值(比如 "unknown"),列里就分不出「无归属」与「真会话」。
func TestUsageSink_UnseededSessionStaysEmpty(t *testing.T) {
t.Parallel()
srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, nil)
sink := &fakeUsageSink{}
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "gpt-test"}, WithUsageSink(sink))
if _, err := ad.Chat(context.Background(), sessionUsageRequest(), Options{}); err != nil {
t.Fatalf("Chat: %v", err)
}
if len(sink.samples) != 1 {
t.Fatalf("sink 收到 %d 条样本, want 1", len(sink.samples))
}
if got := sink.samples[0].SessionID; got != "" {
t.Fatalf("未播种会话时样本 SessionID = %q, want 空串", got)
}
}
// TestUsageWriter_PersistsSessionID 钉住落库侧:样本带会话 ⇒ 该列写会话;
// 样本不带会话 ⇒ 写 NULL(**不是空串**),且读回来的会话就是写入的那个。
func TestUsageWriter_PersistsSessionID(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn := usageTestDB(t)
writer := NewUsageWriter(conn, discardLogger("model/usage_session_test"))
writer.Start(ctx)
const session = "web:sess-persist"
writer.RecordUsage(UsageSample{Adapter: "openai", Model: "gpt-test", Purpose: "chat", SessionID: session})
writer.RecordUsage(UsageSample{Adapter: "openai", Model: "gpt-test", Purpose: "chat"})
writer.Stop()
var stored string
if err := conn.QueryRowContext(ctx,
`SELECT session_id FROM llm_usage WHERE session_id IS NOT NULL`).Scan(&stored); err != nil {
t.Fatalf("读回 session_id: %v", err)
}
if stored != session {
t.Fatalf("落库 session_id = %q, want %q", stored, session)
}
var nullRows, emptyRows int64
if err := conn.QueryRowContext(ctx,
`SELECT COUNT(*) FROM llm_usage WHERE session_id IS NULL`).Scan(&nullRows); err != nil {
t.Fatalf("count null session_id: %v", err)
}
if err := conn.QueryRowContext(ctx,
`SELECT COUNT(*) FROM llm_usage WHERE session_id = ''`).Scan(&emptyRows); err != nil {
t.Fatalf("count empty session_id: %v", err)
}
if nullRows != 1 {
t.Fatalf("session_id IS NULL 的行 = %d, want 1(无归属的调用必须写 NULL)", nullRows)
}
if emptyRows != 0 {
t.Fatalf("session_id = '' 的行 = %d, want 0(空串会把「无归属」写成一个真实值)", emptyRows)
}
}