feat(config,runtime): 上下文预算按模型窗口推导 + usage/缓存可观测 + sidecar 存全文(T1)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 窗口声明: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)。
This commit is contained in:
AskaEth committed 2026-10-04 17:57:08 +08:00
1 parent 5b2b385524
commit 72b95df22b
39 files changed
+2496 -377

No files matched your search

+32 -11
View File
@@ -97,10 +97,29 @@ type anthropicBlock struct {
}
// anthropicUsage 上报 token 消耗。Anthropic 上报的是 input_tokens
// 与 output_tokens,而不是单一总量。
// 与 output_tokens,而不是单一总量;提示缓存另有两个字段
// (cache_creation_input_tokens = 本次写入缓存的输入 token,
// cache_read_input_tokens = 本次从缓存读到的输入 token)。它们全都容忍缺失:
// 未启用缓存的端点只会给前两个。
type anthropicUsage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
}
// applyAnthropicUsage 把一个 usage 对象折进 Response(只填用量字段,
// input_tokens 与 output_tokens 的既有口径不变)。缓存两档的对应关系:
// 命中 = cache_read_input_tokens;未命中 = input_tokens + cache_creation_input_tokens
// —— Anthropic 的 input_tokens **不含**缓存读到的部分,若只取它,命中率会被算成 0。
func applyAnthropicUsage(out *Response, u *anthropicUsage) {
if u == nil {
return
}
out.PromptTokens = u.InputTokens
out.CompletionTokens = u.OutputTokens
out.CacheHitTokens = u.CacheReadInputTokens
out.CacheMissTokens = u.InputTokens + u.CacheCreationInputTokens
}
// AnthropicChatAdapter 调用 Anthropic Messages(/v1/messages)端点。
@@ -206,7 +225,7 @@ func (a *AnthropicChatAdapter) Chat(ctx context.Context, req Request, opts Optio
if err != nil {
return Response{}, err
}
a.record(req, start, out.TotalTokens)
a.record(req, start, out)
return out, nil
}
@@ -263,7 +282,7 @@ func (a *AnthropicChatAdapter) ChatStream(ctx context.Context, req Request, onEv
return Response{}, err
}
out.Model = model
a.record(req, start, out.TotalTokens)
a.record(req, start, out)
return out, nil
}
@@ -381,14 +400,17 @@ func (a *AnthropicChatAdapter) upstreamError(resp *http.Response) error {
return fmt.Errorf("model: %w: status %d: %s", ErrUpstream, resp.StatusCode, strings.TrimSpace(string(snippet)))
}
func (a *AnthropicChatAdapter) record(req Request, start time.Time, tokens int) {
// 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))
if a.recorder == nil {
return
}
a.recorder(CallRecord{
Model: req.Model,
Purpose: req.Purpose,
Tokens: tokens,
Tokens: resp.TotalTokens,
TookMs: time.Since(start).Milliseconds(),
})
}
@@ -413,10 +435,9 @@ func responseFromAnthropicWire(wire *anthropicResponse) Response {
out.Message = Message{Role: RoleAssistant, Content: []Content{{Type: ContentTypeText, Text: out.Content}}}
out.ToolCalls = toolCalls
out.StopReason = wire.StopReason
if u := wire.Usage; u != nil {
out.PromptTokens = u.InputTokens
out.CompletionTokens = u.OutputTokens
out.TotalTokens = u.InputTokens + u.OutputTokens
applyAnthropicUsage(&out, wire.Usage)
if wire.Usage != nil {
out.TotalTokens = wire.Usage.InputTokens + wire.Usage.OutputTokens
}
return out
}
+4 -3
View File
@@ -114,9 +114,10 @@ func applyAnthropicEvent(
case "message_start":
if evt.Message != nil {
agg.Model = evt.Message.Model
if evt.Message.Usage != nil {
agg.PromptTokens = evt.Message.Usage.InputTokens
}
// 输入侧用量(含缓存读/写两档)在 message_start 就齐了,
// 因此在这里一次折进去 —— 它与 message_delta 的输出侧用量
// 合起来构成这次调用的完整成本面。
applyAnthropicUsage(agg, evt.Message.Usage)
}
case "content_block_start":
if evt.Index == nil || evt.Content == nil {
+27
View File
@@ -267,6 +267,33 @@ type Response struct {
PromptTokens int
CompletionTokens int
TotalTokens int
// CacheHitTokens / CacheMissTokens 是**输入侧**的两档 token 数(design/04
// 附录 A.1:提供商按缓存命中/未命中两档计价,命中价约为未命中的 1/50)。
// 两种线格式都解析进这里:
//
// - OpenAI 兼容:usage.prompt_cache_hit_tokens / prompt_cache_miss_tokens
// (DeepSeek),缺失时用 prompt_tokens_details.cached_tokens 作命中数;
// - Anthropic:usage.cache_read_input_tokens 作命中数,
// input_tokens + cache_creation_input_tokens 作未命中数。
//
// 提供商未上报(缺字段)时为零 —— 缺字段是**容错**路径,绝不 panic、绝不
// 影响正文解析。
CacheHitTokens int
CacheMissTokens int
// ReasoningTokens 是完成侧里属于**思维链**的 token 数(OpenAI 兼容的
// usage.completion_tokens_details.reasoning_tokens)。Anthropic 不上报它
// (思考 token 已计入 output_tokens),因此那里恒为 0。
ReasoningTokens int
}
// CacheHitRate 返回输入侧的缓存命中率(hit / (hit+miss))。提供商未上报任何
// 输入侧明细、或两档都是 0 时返回 0 —— 不编造一个 100% 或 0% 之外的假值。
func (r Response) CacheHitRate() float64 {
total := r.CacheHitTokens + r.CacheMissTokens
if total <= 0 {
return 0
}
return float64(r.CacheHitTokens) / float64(total)
}
// CallRecord 描述一次成功完成的 LLM 调用,供 M9
+7 -4
View File
@@ -143,7 +143,7 @@ func (a *OpenAIChatAdapter) Chat(ctx context.Context, req Request, opts Options)
return Response{}, fmt.Errorf("model: %w: decode response: %v", ErrUpstream, err)
}
out := responseFromWire(&wire)
a.record(req, start, out.TotalTokens)
a.record(req, start, out)
return out, nil
}
@@ -180,7 +180,7 @@ func (a *OpenAIChatAdapter) ChatStream(ctx context.Context, req Request, onEvent
return Response{}, err
}
out.Model = model
a.record(req, start, out.TotalTokens)
a.record(req, start, out)
return out, nil
}
@@ -230,14 +230,17 @@ func (a *OpenAIChatAdapter) upstreamError(resp *http.Response) error {
return fmt.Errorf("model: %w: status %d: %s", ErrUpstream, resp.StatusCode, strings.TrimSpace(string(snippet)))
}
func (a *OpenAIChatAdapter) record(req Request, start time.Time, tokens int) {
// 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))
if a.recorder == nil {
return
}
a.recorder(CallRecord{
Model: req.Model,
Purpose: req.Purpose,
Tokens: tokens,
Tokens: resp.TotalTokens,
TookMs: time.Since(start).Milliseconds(),
})
}
+47 -9
View File
@@ -48,11 +48,53 @@ type openAIToolCallDelta struct {
Function openAIToolCallFn `json:"function"`
}
// openAIUsage 上报 token 消耗。
// openAIUsage 上报 token 消耗。缓存两档(prompt_cache_hit_tokens /
// prompt_cache_miss_tokens)由 DeepSeek 上报;OpenAI 兼容家族里另有
// prompt_tokens_details.cached_tokens 表达同一件事,两者都解析、缺一个就用
// 另一个(见 applyOpenAIUsage)。
type openAIUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
PromptCacheHitTokens int `json:"prompt_cache_hit_tokens"`
PromptCacheMissTokens int `json:"prompt_cache_miss_tokens"`
PromptTokensDetails openAIPromptTokenDetails `json:"prompt_tokens_details"`
CompletionTokenDetails openAICompletionTokenDetails `json:"completion_tokens_details"`
}
// openAIPromptTokenDetails 是输入侧明细(OpenAI / DashScope 形状)。
type openAIPromptTokenDetails struct {
// CachedTokens 是命中上下文缓存的 token 数,与 prompt_cache_hit_tokens 同义。
CachedTokens int `json:"cached_tokens"`
}
// openAICompletionTokenDetails 是完成侧明细:推理模型把思维链 token 报在这里。
type openAICompletionTokenDetails struct {
// ReasoningTokens 是思维链 token 数(design/04 附录 A.3 第 4 条要观测它)。
ReasoningTokens int `json:"reasoning_tokens"`
}
// applyOpenAIUsage 把一个 usage 对象折进 Response。它容忍任何字段缺失:
// 缺失即零值,绝不 panic,也绝不因为用量字段的形态问题影响正文解析。
//
// 未命中的补齐用的是提供商自己的不变式「prompt_tokens = hit + miss」(见
// DeepSeek 文档)—— 只在提供商没给 miss 时才补,免得把命中率算成 100%。
func applyOpenAIUsage(out *Response, u *openAIUsage) {
if u == nil {
return
}
out.PromptTokens = u.PromptTokens
out.CompletionTokens = u.CompletionTokens
out.TotalTokens = u.TotalTokens
out.CacheHitTokens = u.PromptCacheHitTokens
if out.CacheHitTokens == 0 {
out.CacheHitTokens = u.PromptTokensDetails.CachedTokens
}
out.CacheMissTokens = u.PromptCacheMissTokens
if out.CacheMissTokens == 0 && out.PromptTokens > out.CacheHitTokens {
out.CacheMissTokens = out.PromptTokens - out.CacheHitTokens
}
out.ReasoningTokens = u.CompletionTokenDetails.ReasoningTokens
}
func responseFromWire(wire *openAIChatResponse) Response {
@@ -70,10 +112,6 @@ func responseFromWire(wire *openAIChatResponse) Response {
out.ToolCalls = append(out.ToolCalls, ToolCall{Name: tc.Function.Name, ID: tc.ID, Args: args})
}
}
if u := wire.Usage; u != nil {
out.PromptTokens = u.PromptTokens
out.CompletionTokens = u.CompletionTokens
out.TotalTokens = u.TotalTokens
}
applyOpenAIUsage(&out, wire.Usage)
return out
}
+3 -3
View File
@@ -88,9 +88,9 @@ func (st *openAIStreamState) emit(ev StreamEvent) {
// 片段增量(若有)则被路由到其处理器。
func (st *openAIStreamState) applyChunk(chunk openAIChatResponse) {
if u := chunk.Usage; u != nil {
st.agg.PromptTokens = u.PromptTokens
st.agg.CompletionTokens = u.CompletionTokens
st.agg.TotalTokens = u.TotalTokens
// 用量块(含缓存的 hit/miss 与思维链 token)只在启用 include_usage 时
// 随末块到达,且必须原样折进聚合结果 —— 这正是「成本可观测」的落点。
applyOpenAIUsage(&st.agg, u)
}
if len(chunk.Choices) == 0 {
return
+4 -2
View File
@@ -170,8 +170,10 @@ func TestOpenAIChat_ReasoningEffortPassthrough(t *testing.T) {
if got := (<-ch).body["reasoning_effort"]; got != "high" {
t.Fatalf("body.reasoning_effort = %v, want high", got)
}
if buf.Len() != 0 {
t.Errorf("unexpected log for a supported value: %q", buf.String())
// 用量日志(每次调用一条 Info)不算「意外日志」:这里断言的是**没有 WARN**
// —— 受支持的档位不该被上报成问题。
if strings.Contains(buf.String(), "level=WARN") {
t.Errorf("unexpected WARN for a supported value: %q", buf.String())
}
}
+47
View File
@@ -0,0 +1,47 @@
package model
import (
"log/slog"
"time"
)
// 本文件是**用量与缓存的可观测出口**(design/04 附录 A.3 第 4 条)。
//
// 背景(附录 A.2 第 3 条):在此之前输入侧用量从未被记录 —— 全仓只消费了
// PromptTokens / TotalTokens,`prompt_cache_hit_tokens` / `miss` 一律没解析、没落
// 日志,于是「大上下文到底贵不贵」根本无法实测。而提供商按命中/未命中两档计价,
// 命中价约为未命中的 1/50:看不见命中率,就等于看不见成本。
//
// 记录点选在**适配器内部**(每个适配器的 record 都调它一次),而不是循环里:
// 这样流式、非流式、后台思考、子代理、管理面板试连等**所有**模型调用点都覆盖到,
// 不会有「换个调用入口就没了成本日志」的缺口。
//
// 红线:日志里只出现模型名、用途、token 数与耗时 —— **凭据(apiKey / token)绝不
// 出现在这里**,它连参数都进不来。
// usageLogEvent 是每条用量日志的事件名,便于按事件过滤与统计。
const usageLogEvent = "model usage"
// logUsage 记一条结构化用量日志:prompt / completion / total / reasoning /
// cacheHit / cacheMiss / hitRate,外加模型名、用途、适配器与耗时。
//
// log 可以为 nil(适配器在未接日志时用的是丢弃 logger),这里仍做一次防御:
// 缺字段容错、绝不 panic。
func logUsage(log *slog.Logger, adapter string, req Request, resp Response, took time.Duration) {
if log == 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(),
)
}
+247
View File
@@ -0,0 +1,247 @@
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]
}