Files
AskaEth 72b95df22b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(config,runtime): 上下文预算按模型窗口推导 + usage/缓存可观测 + sidecar 存全文(T1)
- 窗口声明: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)。
2026-10-04 17:57:08 +08:00

248 lines
11 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 (
"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]
}