Files
Cyrene_v2/internal/runtime/model/anthropic_deepseek_reasoning_test.go
T
AskaEth 0de68bc960
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(model): DeepSeek 推理参数语义反转 + thinking 开关真发(按官方文档核对结果)
依据 DevDocs/design/04 §3.2「官方核对后的修正」9 条 + 本地 DevDocs/reference/deepseek/facts-for-cyrene.md(官方原句/URL)。
- 🔴 reasoning_effort 白名单改为官方集合 [none, low, high, max] + 别名映射(minimal→low, medium→high, xhigh→high, ultra→max);**删除就近降级**,非法值一律丢弃+WARN。此前 none(关思考) 被降级成 low(开思考) 属语义反转
- 🔴 DeepSeek thinking 开关真发顶层 {"thinking":{"type":"enabled|disabled"}}(官方默认 enabled,不传≠关);此前只打 WARN 导致 mode:off 静默无效
- DeepSeek 的 /anthropic 端点忽略 budget_tokens → 不再对其抬 max_tokens(会误导预算/日志),改为按官方用 output_config.effort
- 注释纠正:penalty 属接口级 deprecated(非仅思考模式);top_p 仅思考模式生效且下限 0.95(不加该字段)
- tool_choice 雷登记:思考模式下 required/指定 tool 会 400(思考默认开)
- 旧模型名清理(deepseek-chat/reasoner 已于 2026-07-24 停用)
- 请求体级测试 + 变异验证(把 none 改回 low / 把 thinking 改回只 WARN → 对应断言 FAIL)
2026-10-03 20:22:31 +08:00

343 lines
13 KiB
Go

package model
import (
"context"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// TestDeepSeekAnthropicEndpoint_Classification pins the endpoint detection: a
// DeepSeek /anthropic URL must be recognized even when the configured model name
// is not a DeepSeek one (the service silently maps unknown names onto
// deepseek-flash, so the name is not a reliable signal there).
func TestDeepSeekAnthropicEndpoint_Classification(t *testing.T) {
t.Parallel()
cases := []struct {
model string
baseURL string
want bool
}{
{model: deepSeekModel, baseURL: "http://127.0.0.1:9", want: true},
{model: "claude-sonnet-4", baseURL: "https://api.deepseek.com/anthropic", want: true},
{model: "gpt-4o", baseURL: "https://api.deepseek.com/anthropic", want: true},
{model: "claude-sonnet-4", baseURL: "https://api.anthropic.com", want: false},
{model: "deepseek-v4-pro", baseURL: "https://gateway.internal/anthropic", want: true},
}
for _, tc := range cases {
if got := deepSeekAnthropicEndpoint(tc.model, tc.baseURL); got != tc.want {
t.Errorf("deepSeekAnthropicEndpoint(%q, %q) = %v, want %v", tc.model, tc.baseURL, got, tc.want)
}
}
}
// newDeepSeekAnthropicAdapter builds an Anthropic-protocol adapter whose model
// name identifies DeepSeek, so the DeepSeek /anthropic branch is exercised while
// the request still goes to a local stub.
func newDeepSeekAnthropicAdapter(t *testing.T, ch chan<- anthropicCaptured, opts ...AnthropicOption) *AnthropicChatAdapter {
t.Helper()
srv := newAnthropicServer(t, http.StatusOK, "application/json", anthropicTextResponse, ch)
return NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: deepSeekModel}, opts...)
}
// TestAnthropicDeepSeek_BudgetDoesNotRaiseMaxTokens asserts the max_tokens raise
// stays on the Anthropic endpoint: DeepSeek's /anthropic ignores budget_tokens, so
// raising the limit there would misreport the budget the provider applies.
func TestAnthropicDeepSeek_BudgetDoesNotRaiseMaxTokens(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
log, buf := captureLog()
ad := newDeepSeekAnthropicAdapter(t, ch, WithAnthropicLogger(log))
const budget = 8192
if _, err := ad.Chat(context.Background(), Request{
Model: deepSeekModel,
Messages: []Message{TextMessage(RoleUser, "hi")},
MaxTokens: 1024,
Thinking: &Thinking{BudgetTokens: budget},
}, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
body := (<-ch).body
if got := body["max_tokens"]; got != float64(1024) {
t.Fatalf("body.max_tokens = %v, want the requested 1024 (DeepSeek ignores budget_tokens)", got)
}
think, ok := body["thinking"].(map[string]any)
if !ok || think["type"] != thinkingTypeEnabled {
t.Fatalf("body.thinking = %v, want an enabled directive", body["thinking"])
}
rec := buf.String()
for _, want := range []string{"level=WARN", "budget_tokens", "ignored", "deepseek"} {
if !strings.Contains(rec, want) {
t.Errorf("log %q missing %q", rec, want)
}
}
if strings.Contains(rec, "max_tokens raised") {
t.Errorf("log %q reports a max_tokens raise that must not happen on the DeepSeek endpoint", rec)
}
}
// TestAnthropicOfficial_BudgetStillRaisesMaxTokens is the counterpart guard: on
// the Anthropic endpoint the design red line ("budget must not eat the answer")
// still holds.
func TestAnthropicOfficial_BudgetStillRaisesMaxTokens(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: 1024,
Thinking: &Thinking{BudgetTokens: 4096},
}, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
body := (<-ch).body
maxTokens, _ := body["max_tokens"].(float64)
think, _ := body["thinking"].(map[string]any)
budget, _ := think["budget_tokens"].(float64)
if maxTokens <= budget {
t.Fatalf("body.max_tokens = %v, want > budget_tokens %v on the Anthropic endpoint", maxTokens, budget)
}
if !strings.Contains(buf.String(), "max_tokens raised") {
t.Errorf("log %q, want the raise WARN", buf.String())
}
}
// TestAnthropicDeepSeek_OutputConfigEffort asserts the DeepSeek /anthropic
// strength path exists: the effort becomes output_config.effort, and the aliases
// are normalized to the tiers the service applies.
func TestAnthropicDeepSeek_OutputConfigEffort(t *testing.T) {
t.Parallel()
cases := []struct{ sent, want string }{
{"low", "low"},
{"high", "high"},
{"max", "max"},
{"minimal", "low"},
{"medium", "high"},
{"xhigh", "high"},
{"ultra", "max"},
}
for _, tc := range cases {
t.Run(tc.sent, func(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
ad := newDeepSeekAnthropicAdapter(t, ch)
if _, err := ad.Chat(context.Background(), Request{
Model: deepSeekModel,
Messages: []Message{TextMessage(RoleUser, "hi")},
ReasoningEffort: tc.sent,
}, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
body := (<-ch).body
output, ok := body["output_config"].(map[string]any)
if !ok {
t.Fatalf("body.output_config = %v, want an effort block", body["output_config"])
}
if got := output["effort"]; got != tc.want {
t.Fatalf("body.output_config.effort = %v, want %q (sent %q)", got, tc.want, tc.sent)
}
})
}
}
// TestAnthropicDeepSeek_OutputConfigEffortInvalidDropped asserts an unrecognized
// effort is dropped and reported rather than approximated.
func TestAnthropicDeepSeek_OutputConfigEffortInvalidDropped(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
log, buf := captureLog()
ad := newDeepSeekAnthropicAdapter(t, ch, WithAnthropicLogger(log))
if _, err := ad.Chat(context.Background(), Request{
Model: deepSeekModel,
Messages: []Message{TextMessage(RoleUser, "hi")},
ReasoningEffort: "foo",
}, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
body := (<-ch).body
if _, present := body["output_config"]; present {
t.Fatalf("body.output_config = %v, want the block dropped", body["output_config"])
}
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", "foo", "deepseek"} {
if !strings.Contains(rec, want) {
t.Errorf("log %q missing %q", rec, want)
}
}
}
// TestAnthropicDeepSeek_EffortNoneDisablesThinking pins the translation of the
// one effort value output_config cannot carry: DeepSeek's Anthropic format has no
// effort "none", so the off intent goes through the thinking switch — mapping it
// onto a tier would turn thinking off into thinking on.
func TestAnthropicDeepSeek_EffortNoneDisablesThinking(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
log, buf := captureLog()
ad := newDeepSeekAnthropicAdapter(t, ch, WithAnthropicLogger(log))
if _, err := ad.Chat(context.Background(), Request{
Model: deepSeekModel,
Messages: []Message{TextMessage(RoleUser, "hi")},
ReasoningEffort: effortNone,
}, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
body := (<-ch).body
think, ok := body["thinking"].(map[string]any)
if !ok || think["type"] != thinkingTypeDisabled {
t.Fatalf("body.thinking = %v, want %q", body["thinking"], thinkingTypeDisabled)
}
if _, present := body["output_config"]; present {
t.Fatalf("body.output_config = %v, want no effort block", body["output_config"])
}
if rec := buf.String(); !strings.Contains(rec, "none") || !strings.Contains(rec, "level=WARN") {
t.Errorf("log %q, want a WARN explaining the translation", rec)
}
}
// TestAnthropicDeepSeek_ThinkingSwitch asserts an explicit switch is carried as
// the Anthropic-format thinking block, and that a disabled one omits the budget
// (DeepSeek ignores it anyway).
func TestAnthropicDeepSeek_ThinkingSwitch(t *testing.T) {
t.Parallel()
cases := []struct {
name string
enable *bool
wantType string
absent bool
}{
{name: "explicit_off", enable: boolPtr(false), wantType: thinkingTypeDisabled},
{name: "explicit_on", enable: boolPtr(true), wantType: thinkingTypeEnabled},
{name: "nil_leaves_the_default", enable: nil, absent: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
ad := newDeepSeekAnthropicAdapter(t, ch)
req := Request{Model: deepSeekModel, Messages: []Message{TextMessage(RoleUser, "hi")}}
if tc.enable != nil {
req.Thinking = &Thinking{Enable: tc.enable}
}
if _, err := ad.Chat(context.Background(), req, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
body := (<-ch).body
think, present := body["thinking"].(map[string]any)
if tc.absent {
if present {
t.Fatalf("body.thinking = %v, want the field omitted", body["thinking"])
}
return
}
if !present || think["type"] != tc.wantType {
t.Fatalf("body.thinking = %v, want type %q", body["thinking"], tc.wantType)
}
if _, hasBudget := think["budget_tokens"]; tc.wantType == thinkingTypeDisabled && hasBudget {
t.Errorf("body.thinking = %v, want no budget_tokens for a disabled switch", think)
}
})
}
}
// TestAnthropicDeepSeek_EffortDroppedWhenThinkingDisabled keeps the "off wins"
// rule on the Anthropic path too: output_config.effort must not re-open thinking.
func TestAnthropicDeepSeek_EffortDroppedWhenThinkingDisabled(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
log, buf := captureLog()
ad := newDeepSeekAnthropicAdapter(t, ch, WithAnthropicLogger(log))
if _, err := ad.Chat(context.Background(), Request{
Model: deepSeekModel,
Messages: []Message{TextMessage(RoleUser, "hi")},
Thinking: &Thinking{Enable: boolPtr(false)},
ReasoningEffort: "high",
}, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
body := (<-ch).body
if _, present := body["output_config"]; present {
t.Fatalf("body.output_config = %v, want it dropped while thinking is off", body["output_config"])
}
if think, _ := body["thinking"].(map[string]any); think["type"] != thinkingTypeDisabled {
t.Fatalf("body.thinking = %v, want %q", body["thinking"], thinkingTypeDisabled)
}
if rec := buf.String(); !strings.Contains(rec, "explicitly disabled") {
t.Errorf("log %q, want a WARN explaining the dropped effort", rec)
}
}
// TestAnthropicOfficial_ReasoningEffortReportedIgnored asserts a reasoning_effort
// aimed at the Anthropic endpoint is reported instead of silently disappearing —
// the Messages protocol has no such field.
func TestAnthropicOfficial_ReasoningEffortReportedIgnored(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")},
ReasoningEffort: "high",
}, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
body := (<-ch).body
for _, key := range []string{"reasoning_effort", "output_config"} {
if _, present := body[key]; present {
t.Errorf("body.%s = %v, want the Anthropic endpoint untouched", key, body[key])
}
}
rec := buf.String()
for _, want := range []string{"level=WARN", "reasoning_effort", "high", "anthropic"} {
if !strings.Contains(rec, want) {
t.Errorf("log %q missing %q", rec, want)
}
}
}
// TestAnthropicDeepSeek_ForcedToolChoiceWithThinkingWarns covers the DeepSeek 400
// trap on the Anthropic path: the adapter warns and still sends the directive —
// it never closes thinking on its own.
func TestAnthropicDeepSeek_ForcedToolChoiceWithThinkingWarns(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
log, buf := captureLog()
ad := newDeepSeekAnthropicAdapter(t, ch, WithAnthropicLogger(log))
choice := map[string]any{"type": "tool", "name": "get_time"}
if _, err := ad.Chat(context.Background(), Request{
Model: deepSeekModel,
Messages: []Message{TextMessage(RoleUser, "hi")},
ToolChoice: choice,
}, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
if got := (<-ch).body["tool_choice"]; got == nil {
t.Fatal("body.tool_choice = nil, want the directive passed through verbatim")
}
rec := buf.String()
for _, want := range []string{"level=WARN", "tool_choice", "deepseek", deepSeekModel} {
if !strings.Contains(rec, want) {
t.Errorf("log %q missing %q", rec, want)
}
}
}