- 窗口声明:models.json 组级 contextWindow + env LLM_CONTEXT_WINDOW(per-group materialize 播种,0 不写盘、负数报错)。 - 预算三态:CONTEXT_MAX_TOKENS 未设置 ⇒ 按窗口推导;>0 显式覆盖;0|负 关闭压缩(不变)。推导 budget = min(窗口×比例, 窗口−输出保留),比例默认 2/3(CONTEXT_BUDGET_RATIO,1M ⇒ 699050 tokens ≈ 683Ki);输出保留 = max(32768, 该组 maxTokens) 且封顶窗口/2(防配置矛盾时退化成 1 token);窗口未声明 ⇒ 回落 131072 + 恰好一条 WARN。接线在组合根 wire_context.go(唯一决策点)。 - usage/缓存可观测(此前**完全不可观测**):Response 增 CacheHit/CacheMiss/Reasoning tokens + CacheHitRate;解析 OpenAI 兼容(prompt_cache_hit_tokens/prompt_cache_miss_tokens/cached_tokens/reasoning_tokens)与 anthropic(cache_read_input_tokens/cache_creation_input_tokens)两种形状;记录点在适配器 record(换入口不丢成本日志),日志无凭据;单次输入 ≥预算 90% 记 WARN(不阻断)。 - 根治清单「续读」假承诺:抽取期不再按注入预算截断(sidecar 存全文,只受 MaxSidecarBytes=8 MiB 安全上限,ExtractorVersion v1→v2 旧 sidecar 失效重抽);注入预算改在装配期生效 ⇒ ref.Chars/Truncated 是注入侧事实、游标真是继续读的起点;document-read 的 limit 上限改为独立具名常量。 - 顺手修越界:config.go 505 行(超 500)拆出 llm.go。 - 主会话独立验收:隔离 worktree(HEAD + 本切片 39 文件,排除在途 gateway)build/vet 绿、全树 59 包 0 FAIL、linux+darwin 交叉编译绿;变异独立复现(比例改 0.25 ⇒ 推导用例红 262144 vs 699050)。
248 lines
11 KiB
Go
248 lines
11 KiB
Go
package model
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"log/slog"
|
||
"net/http"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
)
|
||
|
||
// 本文件钉住 usage/缓存可观测(design/04 附录 A.3 第 4 条):
|
||
// - 两种线格式的缓存字段都解析进 Response,**缺字段是容错路径**(不 panic、不影响正文);
|
||
// - 每次模型调用后记一条结构化用量日志,字段齐全且**不含任何凭据**。
|
||
|
||
// TestOpenAIUsage_ParsesCacheAndReasoningTokens 钉住 OpenAI 兼容形状:DeepSeek 式的
|
||
// prompt_cache_hit_tokens / prompt_cache_miss_tokens 与 completion_tokens_details
|
||
// .reasoning_tokens 都进 Response。
|
||
func TestOpenAIUsage_ParsesCacheAndReasoningTokens(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)
|
||
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "deepseek-chat"})
|
||
|
||
resp, err := ad.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{})
|
||
if err != nil {
|
||
t.Fatalf("Chat: %v", err)
|
||
}
|
||
if resp.PromptTokens != 1000 || resp.CompletionTokens != 200 || resp.TotalTokens != 1200 {
|
||
t.Fatalf("用量 = (%d,%d,%d), want (1000,200,1200)", resp.PromptTokens, resp.CompletionTokens, resp.TotalTokens)
|
||
}
|
||
if resp.CacheHitTokens != 900 || resp.CacheMissTokens != 100 {
|
||
t.Fatalf("缓存两档 = (%d,%d), want (900,100)", resp.CacheHitTokens, resp.CacheMissTokens)
|
||
}
|
||
if resp.ReasoningTokens != 150 {
|
||
t.Fatalf("ReasoningTokens = %d, want 150", resp.ReasoningTokens)
|
||
}
|
||
if got := resp.CacheHitRate(); got != 0.9 {
|
||
t.Fatalf("命中率 = %v, want 0.9", got)
|
||
}
|
||
}
|
||
|
||
// TestOpenAIUsage_FallsBackToCachedTokensDetails 钉住另一条 OpenAI 兼容形态:
|
||
// 只有 prompt_tokens_details.cached_tokens(OpenAI / DashScope)时也能得到命中数,
|
||
// 且未命中数按提供商自己的不变式(prompt = hit + miss)补齐,而不是把命中率算成 100%。
|
||
func TestOpenAIUsage_FallsBackToCachedTokensDetails(t *testing.T) {
|
||
t.Parallel()
|
||
body := `{
|
||
"id": "cmpl-2", "model": "gpt-test",
|
||
"choices": [{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],
|
||
"usage": {"prompt_tokens": 400, "completion_tokens": 10, "total_tokens": 410,
|
||
"prompt_tokens_details": {"cached_tokens": 300}}
|
||
}`
|
||
srv := newCompletionServer(t, http.StatusOK, "application/json", body, nil)
|
||
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "gpt-test"})
|
||
|
||
resp, err := ad.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{})
|
||
if err != nil {
|
||
t.Fatalf("Chat: %v", err)
|
||
}
|
||
if resp.CacheHitTokens != 300 || resp.CacheMissTokens != 100 {
|
||
t.Fatalf("缓存两档 = (%d,%d), want (300,100)", resp.CacheHitTokens, resp.CacheMissTokens)
|
||
}
|
||
}
|
||
|
||
// TestUsage_MissingFieldsAreTolerated 钉住缺字段容错:用量块只有最基本的三个数时
|
||
// 不 panic、不报错,缓存命中数为零,未命中数按提供商自己的不变式(prompt = hit +
|
||
// miss)补齐 —— 也就是「这次输入全都没命中缓存」,命中率因此是 0 而不是编造值。
|
||
func TestUsage_MissingFieldsAreTolerated(t *testing.T) {
|
||
t.Parallel()
|
||
srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, nil)
|
||
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "gpt-test"})
|
||
|
||
resp, err := ad.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{})
|
||
if err != nil {
|
||
t.Fatalf("Chat: %v", err)
|
||
}
|
||
if resp.CacheHitTokens != 0 || resp.CacheMissTokens != 5 || resp.ReasoningTokens != 0 {
|
||
t.Fatalf("缺失字段 = (%d,%d,%d), want (0,5,0)", resp.CacheHitTokens, resp.CacheMissTokens, resp.ReasoningTokens)
|
||
}
|
||
if resp.CacheHitRate() != 0 {
|
||
t.Fatalf("无缓存命中时的命中率 = %v, want 0", resp.CacheHitRate())
|
||
}
|
||
if resp.Content != "Hello!" {
|
||
t.Fatalf("正文受用量字段影响: %q", resp.Content)
|
||
}
|
||
}
|
||
|
||
// TestOpenAIStreamUsage_ParsesCacheFields 钉住流式末块的用量块:缓存的 hit/miss 与
|
||
// 思维链 token 一样原样折进聚合结果(流式是最常用的路径,漏了它等于没做)。
|
||
func TestOpenAIStreamUsage_ParsesCacheFields(t *testing.T) {
|
||
t.Parallel()
|
||
lines := []string{
|
||
`data: {"model":"deepseek-chat","choices":[{"index":0,"delta":{"content":"你好"}}]}`,
|
||
`data: {"model":"deepseek-chat","choices":[],"usage":{"prompt_tokens":500,"completion_tokens":50,"total_tokens":550,"prompt_cache_hit_tokens":480,"prompt_cache_miss_tokens":20,"completion_tokens_details":{"reasoning_tokens":30}}}`,
|
||
`data: [DONE]`,
|
||
}
|
||
srv := newCompletionServer(t, http.StatusOK, "text/event-stream", strings.Join(lines, "\n")+"\n", nil)
|
||
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "deepseek-chat"})
|
||
|
||
resp, err := ad.ChatStream(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, nil)
|
||
if err != nil {
|
||
t.Fatalf("ChatStream: %v", err)
|
||
}
|
||
if resp.PromptTokens != 500 || resp.CacheHitTokens != 480 || resp.CacheMissTokens != 20 || resp.ReasoningTokens != 30 {
|
||
t.Fatalf("流式用量 = %+v, want prompt 500 / hit 480 / miss 20 / reasoning 30", resp)
|
||
}
|
||
if resp.Content != "你好" {
|
||
t.Fatalf("流式正文 = %q, want 你好", resp.Content)
|
||
}
|
||
}
|
||
|
||
// TestAnthropicUsage_ParsesPromptCacheFields 钉住 Anthropic 形状:命中 =
|
||
// cache_read_input_tokens,未命中 = input_tokens + cache_creation_input_tokens
|
||
// (Anthropic 的 input_tokens 不含缓存读到的部分,只取它会把命中率算成 0)。
|
||
func TestAnthropicUsage_ParsesPromptCacheFields(t *testing.T) {
|
||
t.Parallel()
|
||
body := `{
|
||
"id": "msg_1", "model": "claude-test", "stop_reason": "end_turn",
|
||
"content": [{"type":"text","text":"你好"}],
|
||
"usage": {"input_tokens": 100, "output_tokens": 20,
|
||
"cache_creation_input_tokens": 300, "cache_read_input_tokens": 700}
|
||
}`
|
||
srv := newAnthropicServer(t, http.StatusOK, "application/json", body, nil)
|
||
ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"})
|
||
|
||
resp, err := ad.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{})
|
||
if err != nil {
|
||
t.Fatalf("Chat: %v", err)
|
||
}
|
||
if resp.CacheHitTokens != 700 || resp.CacheMissTokens != 400 {
|
||
t.Fatalf("缓存两档 = (%d,%d), want (700,400)", resp.CacheHitTokens, resp.CacheMissTokens)
|
||
}
|
||
// 既有口径不变:input_tokens 仍然只算 PromptTokens。
|
||
if resp.PromptTokens != 100 || resp.TotalTokens != 120 {
|
||
t.Fatalf("用量 = (%d,...,%d), want prompt 100 / total 120", resp.PromptTokens, resp.TotalTokens)
|
||
}
|
||
if resp.ReasoningTokens != 0 {
|
||
t.Fatalf("Anthropic 不单独上报思维链 token,ReasoningTokens = %d, want 0", resp.ReasoningTokens)
|
||
}
|
||
}
|
||
|
||
// TestAnthropicUsage_MissingCacheFieldsAreTolerated 钉住 Anthropic 的缺字段容错:
|
||
// 未启用提示缓存的端点只给 input/output 两个数,解析照常、缓存为零。
|
||
func TestAnthropicUsage_MissingCacheFieldsAreTolerated(t *testing.T) {
|
||
t.Parallel()
|
||
srv := newAnthropicServer(t, http.StatusOK, "application/json", anthropicTextResponse, nil)
|
||
ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"})
|
||
|
||
resp, err := ad.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{})
|
||
if err != nil {
|
||
t.Fatalf("Chat: %v", err)
|
||
}
|
||
if resp.CacheHitTokens != 0 || resp.CacheMissTokens != 5 {
|
||
// anthropicTextResponse 的 usage 是 input_tokens=5 / output_tokens=2:
|
||
// 未命中数 = input + cache_creation = 5(缓存字段缺失时即为输入本身)。
|
||
t.Fatalf("缓存两档 = (%d,%d), want (0,5)", resp.CacheHitTokens, resp.CacheMissTokens)
|
||
}
|
||
}
|
||
|
||
// TestUsageLog_HasAllFieldsAndNoCredentials 钉住每次模型调用后那条结构化日志:
|
||
// prompt / completion / total / reasoning / cacheHit / cacheMiss / hitRate 一个不少,
|
||
// 且**绝不出现凭据**(apiKey / token)。
|
||
func TestUsageLog_HasAllFieldsAndNoCredentials(t *testing.T) {
|
||
t.Parallel()
|
||
const secret = "sk-super-secret-credential"
|
||
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)
|
||
log, buf := captureJSONLog()
|
||
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, APIKey: secret, Model: "deepseek-chat"},
|
||
WithOpenAILogger(log))
|
||
|
||
if _, err := ad.Chat(context.Background(), Request{
|
||
Purpose: "chat",
|
||
Messages: []Message{TextMessage(RoleUser, "hi")},
|
||
}, Options{}); err != nil {
|
||
t.Fatalf("Chat: %v", err)
|
||
}
|
||
|
||
rec := usageLogRecord(t, buf.String())
|
||
if rec["msg"] != usageLogEvent {
|
||
t.Fatalf("事件名 = %v, want %q", rec["msg"], usageLogEvent)
|
||
}
|
||
for field, want := range map[string]float64{
|
||
"prompt": 1000, "completion": 200, "total": 1200, "reasoning": 150,
|
||
"cacheHit": 900, "cacheMiss": 100, "hitRate": 0.9,
|
||
} {
|
||
if got, ok := rec[field].(float64); !ok || got != want {
|
||
t.Fatalf("日志字段 %s = %v, want %v(完整记录:%v)", field, rec[field], want, rec)
|
||
}
|
||
}
|
||
if rec["model"] != "deepseek-chat" || rec["purpose"] != "chat" {
|
||
t.Fatalf("日志缺少模型/用途:%v", rec)
|
||
}
|
||
raw := buf.String()
|
||
for _, credential := range []string{secret, "Authorization", "apiKey", "Bearer"} {
|
||
if strings.Contains(raw, credential) {
|
||
t.Fatalf("用量日志里出现了凭据相关内容 %q:%s", credential, raw)
|
||
}
|
||
}
|
||
}
|
||
|
||
// captureJSONLog 返回输出 JSON 记录的 logger 与缓冲:用量日志要按**字段**断言,
|
||
// 因此不能像 reasoning 用例那样只看文本子串。
|
||
func captureJSONLog() (*slog.Logger, *bytes.Buffer) {
|
||
buf := &bytes.Buffer{}
|
||
return slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})), buf
|
||
}
|
||
|
||
// usageLogRecord 从捕获的日志文本里取出唯一一条用量记录(找不到或不止一条就报错)。
|
||
func usageLogRecord(t *testing.T, raw string) map[string]any {
|
||
t.Helper()
|
||
var found []map[string]any
|
||
for _, line := range strings.Split(strings.TrimSpace(raw), "\n") {
|
||
line = strings.TrimSpace(line)
|
||
if line == "" || !strings.Contains(line, `"msg":"`+usageLogEvent+`"`) {
|
||
continue
|
||
}
|
||
rec := map[string]any{}
|
||
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||
t.Fatalf("用量日志不是合法 JSON 行: %v(%q)", err, line)
|
||
}
|
||
found = append(found, rec)
|
||
}
|
||
if len(found) != 1 {
|
||
t.Fatalf("用量日志条数 = %d, want 1(每次调用恰好一条):%s", len(found), raw)
|
||
}
|
||
return found[0]
|
||
}
|