Files
Cyrene_v2/internal/runtime/model/reasoning_test.go
T
AskaEth 3dc1232bc2
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · internal/runtime/model + internal/services/backthink(54 文件,纯替换)
- 只翻注释/文档字符串:`go/scanner` 剥注释后按 token 序列+literal 逐 token 比对,**54/54 文件 SAME**;
  改动文件总行数与 HEAD 漂移 = 0(严格 1:1,无合并/拆行)
- 裁决编号与 design 节号逐字保留(03.4.1/03.4.2/03.4.3/14.4.1/14.4.5/07.4.2-4、design/04 §3.1/§3.2 等);
  `chat_template_kwargs` / `enable_thinking` / `reasoning_effort` / `thinking:{type}` 字段名与形状说明未动
- 存疑译法(已登记):`persona` 在叙事语境用「角色」、标识符语境保留 `persona`(仓库既有模块本身混用);
  `rung`→「级」;`provider` 存在「提供商/提供者」两种落笔(可按需一键统一)
- 复核:build/vet 绿 + 两包测试绿;gofmt 干净;残留英文注释 0 条
- 既有存量(未修,只报告):`model/anthropic_test.go` 612 行 >500;`applyAnthropicEvent` 115 行 >80
2026-10-04 16:27:54 +08:00

366 lines
14 KiB
Go
Raw 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"
"log/slog"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// captureLog 返回一个把文本记录写进缓冲的日志器,外加那个
// 缓冲,好让测试能断言适配器发出的 WARN。
func captureLog() (*slog.Logger, *bytes.Buffer) {
buf := &bytes.Buffer{}
return slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})), buf
}
// boolPtr 是三态 Thinking.Enable 的便利构造。
func boolPtr(v bool) *bool { return &v }
// TestAnthropicChat_ThinkingBudgetLeavesRoomForTheAnswer 就是 design/04 §3.2
// 的红线:大到能吃光 max_tokens 的预算不得照原样发送——
// 适配器会抬高 max_tokens,让思考与可见答案都放得下,
// 并记录这次调整(绝不是一次静默改写)。
func TestAnthropicChat_ThinkingBudgetLeavesRoomForTheAnswer(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
srv := newAnthropicServer(t, http.StatusOK, "application/json", anthropicTextResponse, ch)
log, buf := captureLog()
ad := NewAnthropicChat(
config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"},
WithAnthropicLogger(log))
const budget = 4096
// max_tokens 恰好等于预算:没有这道守护,整个预算会被
// 思考吃光,答案将空着返回。
if _, err := ad.Chat(context.Background(), Request{
Model: "claude-test",
Messages: []Message{TextMessage(RoleUser, "hi")},
MaxTokens: budget,
Thinking: &Thinking{BudgetTokens: budget},
}, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
body := (<-ch).body
think, ok := body["thinking"].(map[string]any)
if !ok {
t.Fatalf("body.thinking = %v, want an enabled directive", body["thinking"])
}
if think["type"] != anthropicThinkingTypeEnabled {
t.Errorf("thinking.type = %v, want %q", think["type"], anthropicThinkingTypeEnabled)
}
gotBudget, _ := think["budget_tokens"].(float64)
maxTokens, _ := body["max_tokens"].(float64)
if int(gotBudget) != budget {
t.Errorf("thinking.budget_tokens = %v, want %d", gotBudget, budget)
}
if maxTokens <= gotBudget {
t.Fatalf("max_tokens = %v, want > budget_tokens %v (thinking would eat the answer)", maxTokens, gotBudget)
}
if maxTokens < gotBudget+anthropicThinkingBodyReserve {
t.Errorf("max_tokens = %v, want >= budget %v + body reserve %d", maxTokens, gotBudget, anthropicThinkingBodyReserve)
}
if !strings.Contains(buf.String(), "max_tokens raised") {
t.Errorf("log = %q, want a WARN explaining the max_tokens raise", buf.String())
}
}
// TestAnthropicChat_ThinkingBudgetBelowMaxTokensUnchanged 钉住未饱和
// 的情形:调用方的 max_tokens 本已留出空间时,它原样发送,
// 而预算严格低于它。
func TestAnthropicChat_ThinkingBudgetBelowMaxTokensUnchanged(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
srv := newAnthropicServer(t, http.StatusOK, "application/json", anthropicTextResponse, ch)
log, buf := captureLog()
ad := NewAnthropicChat(
config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"},
WithAnthropicLogger(log))
if _, err := ad.Chat(context.Background(), Request{
Model: "claude-test",
Messages: []Message{TextMessage(RoleUser, "hi")},
MaxTokens: anthropicDefaultMaxTokens,
Thinking: &Thinking{BudgetTokens: 4096},
}, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
body := (<-ch).body
think := body["thinking"].(map[string]any)
gotBudget, _ := think["budget_tokens"].(float64)
maxTokens, _ := body["max_tokens"].(float64)
if maxTokens != float64(anthropicDefaultMaxTokens) {
t.Errorf("max_tokens = %v, want the requested %d (unchanged)", maxTokens, anthropicDefaultMaxTokens)
}
if gotBudget >= maxTokens {
t.Errorf("budget_tokens %v >= max_tokens %v", gotBudget, maxTokens)
}
if strings.Contains(buf.String(), "max_tokens raised") {
t.Errorf("unexpected adjustment log: %q", buf.String())
}
}
// TestAnthropicChat_ThinkingDisabledOmitsField 断言显式关闭开关
// 完全不发送 thinking 指令(Anthropic 把字段缺失视为关闭)。
func TestAnthropicChat_ThinkingDisabledOmitsField(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
srv := newAnthropicServer(t, http.StatusOK, "application/json", anthropicTextResponse, ch)
ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"})
if _, err := ad.Chat(context.Background(), Request{
Model: "claude-test",
Messages: []Message{TextMessage(RoleUser, "hi")},
Thinking: &Thinking{BudgetTokens: 4096, Enable: boolPtr(false)},
}, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
if body := (<-ch).body; body["thinking"] != nil {
t.Fatalf("body.thinking = %v, want the field omitted", body["thinking"])
}
}
// TestAnthropicChat_ThinkingBudgetRaisedToProtocolMinimum 断言低于
// API 最小值的预算会被抬高(照发会 400)并上报。
func TestAnthropicChat_ThinkingBudgetRaisedToProtocolMinimum(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
srv := newAnthropicServer(t, http.StatusOK, "application/json", anthropicTextResponse, ch)
log, buf := captureLog()
ad := NewAnthropicChat(
config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"},
WithAnthropicLogger(log))
if _, err := ad.Chat(context.Background(), Request{
Model: "claude-test",
Messages: []Message{TextMessage(RoleUser, "hi")},
Thinking: &Thinking{BudgetTokens: 512},
}, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
think := (<-ch).body["thinking"].(map[string]any)
if got, _ := think["budget_tokens"].(float64); int(got) != anthropicMinThinkingBudget {
t.Errorf("thinking.budget_tokens = %v, want the minimum %d", got, anthropicMinThinkingBudget)
}
if !strings.Contains(buf.String(), "protocol minimum") {
t.Errorf("log = %q, want a WARN about the minimum budget", buf.String())
}
}
// TestOpenAIChat_ReasoningEffortPassthrough 断言已知提供商上的
// 受支持档位被原样转发,且不发出警告。
func TestOpenAIChat_ReasoningEffortPassthrough(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, ch)
log, buf := captureLog()
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "gpt-test"}, WithOpenAILogger(log))
if _, err := ad.Chat(context.Background(), Request{
Model: "gpt-4o",
Messages: []Message{TextMessage(RoleUser, "hi")},
ReasoningEffort: "high",
}, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
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())
}
}
// TestOpenAIChat_ReasoningEffortAliasMappedForDeepSeek 断言 DeepSeek 的
// 兼容别名被归一化为服务采用的档位(按官方映射表 medium →
// high)并上报,绝不按我们自己挑的
// 更弱档位降级。
func TestOpenAIChat_ReasoningEffortAliasMappedForDeepSeek(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, ch)
log, buf := captureLog()
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "deepseek-flash"}, WithOpenAILogger(log))
if _, err := ad.Chat(context.Background(), Request{
Model: "deepseek-flash",
Messages: []Message{TextMessage(RoleUser, "hi")},
ReasoningEffort: "medium",
}, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
if got := (<-ch).body["reasoning_effort"]; got != "high" {
t.Fatalf("body.reasoning_effort = %v, want the officially mapped high", got)
}
rec := buf.String()
for _, want := range []string{"level=WARN", "reasoning_effort", "deepseek", "medium", "high"} {
if !strings.Contains(rec, want) {
t.Errorf("log %q missing %q", rec, want)
}
}
}
// TestOpenAIChat_ReasoningEffortDroppedForUnknownProvider 断言无法识别的
// 提供商/模型会让请求保持不动,并上报被
// 忽略的字段(design/04 §3.2:绝不静默失效)。
func TestOpenAIChat_ReasoningEffortDroppedForUnknownProvider(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, ch)
log, buf := captureLog()
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "mystery-1"}, WithOpenAILogger(log))
if _, err := ad.Chat(context.Background(), Request{
Model: "mystery-1",
Messages: []Message{TextMessage(RoleUser, "hi")},
ReasoningEffort: "high",
}, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
body := (<-ch).body
if _, present := body["reasoning_effort"]; present {
t.Fatalf("body.reasoning_effort = %v, want the field dropped", body["reasoning_effort"])
}
rec := buf.String()
for _, want := range []string{"level=WARN", "reasoning_effort", "unknown", "high"} {
if !strings.Contains(rec, want) {
t.Errorf("log %q missing %q", rec, want)
}
}
}
// TestOpenAIChat_QwenEnableThinkingReportedNotForwarded 记录了针对 Qwen3
// 硬开关的刻意选择:OpenAI 兼容协议没有 enable_thinking 的
// 可移植线格式,所以本版本上报它,并让请求
// 逐字节保持原样,而不是去猜一个可能导致 400 的形状。
func TestOpenAIChat_QwenEnableThinkingReportedNotForwarded(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, ch)
log, buf := captureLog()
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "qwen3-max"}, WithOpenAILogger(log))
plain := make(chan capturedRequest, 1)
plainSrv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, plain)
plainAd := NewOpenAIChat(config.LLMConfig{BaseURL: plainSrv.URL, Model: "qwen3-max"})
if _, err := plainAd.Chat(context.Background(), Request{
Model: "qwen3-max",
Messages: []Message{TextMessage(RoleUser, "hi")},
}, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
if _, err := ad.Chat(context.Background(), Request{
Model: "qwen3-max",
Messages: []Message{TextMessage(RoleUser, "hi")},
Thinking: &Thinking{Enable: boolPtr(false)},
}, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
body := (<-ch).body
for _, key := range []string{"enable_thinking", "chat_template_kwargs", "extra_body", "thinking"} {
if _, present := body[key]; present {
t.Errorf("body.%s = %v, want the request unchanged", key, body[key])
}
}
if len(body) != len((<-plain).body) {
t.Errorf("request shape changed with a thinking directive: %v", body)
}
rec := buf.String()
for _, want := range []string{"level=WARN", "enable_thinking", "qwen"} {
if !strings.Contains(rec, want) {
t.Errorf("log %q missing %q", rec, want)
}
}
}
// TestOpenAIChat_ThinkingBudgetReportedIgnored 断言 token 预算
// (Anthropic 风格的指令)在 OpenAI 兼容端点上会被上报为
// 无法映射,而不是被静默丢弃。
func TestOpenAIChat_ThinkingBudgetReportedIgnored(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, ch)
log, buf := captureLog()
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "gpt-4o"}, WithOpenAILogger(log))
if _, err := ad.Chat(context.Background(), Request{
Model: "gpt-4o",
Messages: []Message{TextMessage(RoleUser, "hi")},
Thinking: &Thinking{BudgetTokens: 8192},
}, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
if _, present := (<-ch).body["thinking"]; present {
t.Fatal("body.thinking was sent to an OpenAI-compatible endpoint")
}
rec := buf.String()
for _, want := range []string{"level=WARN", "thinking.budget_tokens", "gpt"} {
if !strings.Contains(rec, want) {
t.Errorf("log %q missing %q", rec, want)
}
}
}
// TestAnthropicChatStream_ThinkingBudgetLeavesRoomForTheAnswer 在流式
// 路径上跑同一条红线检查,该路径会自建请求体。
func TestAnthropicChatStream_ThinkingBudgetLeavesRoomForTheAnswer(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
const sse = "event: message_start\ndata: {\"type\":\"message_start\"}\n\n" +
"event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n" +
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n\n" +
"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
srv := newAnthropicServer(t, http.StatusOK, "text/event-stream", sse, ch)
ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"})
if _, err := ad.ChatStream(context.Background(), Request{
Model: "claude-test",
Messages: []Message{TextMessage(RoleUser, "hi")},
MaxTokens: 2048,
Thinking: &Thinking{BudgetTokens: 2048},
}, func(StreamEvent) {}); err != nil {
t.Fatalf("ChatStream error: %v", err)
}
body := (<-ch).body
think, ok := body["thinking"].(map[string]any)
if !ok {
t.Fatalf("stream body.thinking = %v, want an enabled directive", body["thinking"])
}
gotBudget, _ := think["budget_tokens"].(float64)
maxTokens, _ := body["max_tokens"].(float64)
if gotBudget >= maxTokens {
t.Fatalf("stream max_tokens = %v, want > budget_tokens %v", maxTokens, gotBudget)
}
}
// TestReasoningVendorFor_Classification 锁住白名单所依赖的厂商
// 检测,包括回退到 base URL 的那条路径。
func TestReasoningVendorFor_Classification(t *testing.T) {
t.Parallel()
cases := []struct {
model string
baseURL string
want reasoningVendor
}{
{model: "deepseek-v4-pro", want: vendorDeepSeek},
{model: "Qwen3-Max", want: vendorQwen},
{model: "gpt-4o", want: vendorOpenAI},
{model: "o3-mini", want: vendorOpenAI},
{model: "chatgpt-4o-latest", want: vendorOpenAI},
{model: "claude-sonnet-4", baseURL: "https://api.openai.com/v1", want: vendorOpenAI},
{model: "qwen-plus", baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", want: vendorQwen},
{model: "some-model", baseURL: "https://api.deepseek.com/v1", want: vendorDeepSeek},
{model: "some-model", baseURL: "http://localhost:8000/v1", want: vendorUnknown},
}
for _, tt := range cases {
if got := reasoningVendorFor(tt.model, tt.baseURL); got != tt.want {
t.Errorf("reasoningVendorFor(%q, %q) = %q, want %q", tt.model, tt.baseURL, got, tt.want)
}
}
}