Files
AskaEth 59a1ac4056 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 过滤,面板汇总口径不变。
2026-10-05 08:04:49 +08:00

138 lines
5.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package 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)
}
}