Files
Cyrene_v2/internal/runtime/model/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

308 lines
11 KiB
Go

package model
import (
"context"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// newDeepSeekAdapter builds an OpenAI-compatible adapter pointed at a stub server
// but classified as DeepSeek by its model name, so the DeepSeek mapping is
// exercised against the real request body.
func newDeepSeekAdapter(t *testing.T, ch chan<- capturedRequest, opts ...OpenAIOption) *OpenAIChatAdapter {
t.Helper()
srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, ch)
return NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: deepSeekModel}, opts...)
}
// deepSeekModel is the current DeepSeek model name (the old deepseek-chat /
// deepseek-reasoner names were discontinued on 2026-07-24).
const deepSeekModel = "deepseek-flash"
// deepSeekThinkingType reads body.thinking.type, reporting whether the switch was
// sent at all.
func deepSeekThinkingType(body map[string]any) (string, bool) {
block, ok := body["thinking"].(map[string]any)
if !ok {
return "", false
}
value, _ := block["type"].(string)
return value, true
}
// TestOpenAIChat_DeepSeekReasoningEffortMatrix pins the official value set end to
// end at the request body: none/low/high/max pass through untouched and the four
// compatibility aliases are normalized to the tier DeepSeek applies
// (minimal→low, medium/xhigh→high, ultra→max). The old behavior — grading "none"
// down to "low" — turned thinking off into thinking on, so "none" must arrive as
// "none".
func TestOpenAIChat_DeepSeekReasoningEffortMatrix(t *testing.T) {
t.Parallel()
cases := []struct{ sent, want string }{
{"none", "none"},
{"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 capturedRequest, 1)
ad := newDeepSeekAdapter(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
if got := body["reasoning_effort"]; got != tc.want {
t.Fatalf("body.reasoning_effort = %v, want %q (sent %q)", got, tc.want, tc.sent)
}
if _, present := body["thinking"]; present {
t.Errorf("body.thinking = %v, want no switch invented from an effort", body["thinking"])
}
})
}
}
// TestOpenAIChat_DeepSeekReasoningEffortInvalidDropped asserts an unrecognized
// value is dropped and reported — never replaced by a nearby tier, which is how
// the original "round down" logic inverted the caller's intent.
func TestOpenAIChat_DeepSeekReasoningEffortInvalidDropped(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
log, buf := captureLog()
ad := newDeepSeekAdapter(t, ch, WithOpenAILogger(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["reasoning_effort"]; present {
t.Fatalf("body.reasoning_effort = %v, want the field dropped", body["reasoning_effort"])
}
if _, present := body["thinking"]; present {
t.Fatalf("body.thinking = %v, want no switch invented", body["thinking"])
}
rec := buf.String()
for _, want := range []string{"level=WARN", "reasoning_effort", "foo", deepSeekModel, "deepseek"} {
if !strings.Contains(rec, want) {
t.Errorf("log %q missing %q", rec, want)
}
}
}
// TestOpenAIChat_DeepSeekThinkingSwitch asserts the DeepSeek thinking switch is
// really sent: an explicit off must reach the wire (DeepSeek defaults to enabled,
// so staying silent would leave thinking ON), and nil must not be turned into
// either value.
func TestOpenAIChat_DeepSeekThinkingSwitch(t *testing.T) {
t.Parallel()
cases := []struct {
name string
enable *bool
wantType string
wantAbsent bool
}{
{name: "explicit_off", enable: boolPtr(false), wantType: thinkingTypeDisabled},
{name: "explicit_on", enable: boolPtr(true), wantType: thinkingTypeEnabled},
{name: "nil_leaves_the_default", enable: nil, wantAbsent: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
ad := newDeepSeekAdapter(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)
}
got, present := deepSeekThinkingType((<-ch).body)
if tc.wantAbsent {
if present {
t.Fatalf("body.thinking.type = %q, want the field omitted (not equal to off)", got)
}
return
}
if !present || got != tc.wantType {
t.Fatalf("body.thinking.type = %q (present=%v), want %q", got, present, tc.wantType)
}
})
}
}
// TestOpenAIChat_DeepSeekEffortNoneAloneKeepsTheOfficialOffSwitch asserts the
// official reasoning_effort "none" path is used verbatim when no thinking switch
// was requested — that is the documented way to close thinking.
func TestOpenAIChat_DeepSeekEffortNoneAloneKeepsTheOfficialOffSwitch(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
ad := newDeepSeekAdapter(t, ch)
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
if got := body["reasoning_effort"]; got != effortNone {
t.Fatalf("body.reasoning_effort = %v, want %q", got, effortNone)
}
if _, present := body["thinking"]; present {
t.Fatalf("body.thinking = %v, want no switch block", body["thinking"])
}
}
// TestOpenAIChat_DeepSeekThinkingOffBeatsEffort is the conflict rule documented in
// resolveOpenAIReasoning: the provider states no precedence, so the off side wins
// and the dropped tier is reported — never silently applied.
func TestOpenAIChat_DeepSeekThinkingOffBeatsEffort(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
log, buf := captureLog()
ad := newDeepSeekAdapter(t, ch, WithOpenAILogger(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 got, _ := deepSeekThinkingType(body); got != thinkingTypeDisabled {
t.Fatalf("body.thinking.type = %q, want %q", got, thinkingTypeDisabled)
}
if _, present := body["reasoning_effort"]; present {
t.Fatalf("body.reasoning_effort = %v, want it dropped while thinking is explicitly off", body["reasoning_effort"])
}
if rec := buf.String(); !strings.Contains(rec, "explicitly disabled") {
t.Errorf("log %q, want a WARN explaining the dropped reasoning_effort", rec)
}
}
// TestOpenAIChat_DeepSeekEffortNoneBeatsThinkingEnabled covers the opposite
// conflict: reasoning_effort "none" and thinking enabled cannot both be honoured,
// so the off side wins and the request carries exactly one switch.
func TestOpenAIChat_DeepSeekEffortNoneBeatsThinkingEnabled(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
log, buf := captureLog()
ad := newDeepSeekAdapter(t, ch, WithOpenAILogger(log))
if _, err := ad.Chat(context.Background(), Request{
Model: deepSeekModel,
Messages: []Message{TextMessage(RoleUser, "hi")},
Thinking: &Thinking{Enable: boolPtr(true)},
ReasoningEffort: effortNone,
}, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
body := (<-ch).body
if got, _ := deepSeekThinkingType(body); got != thinkingTypeDisabled {
t.Fatalf("body.thinking.type = %q, want %q", got, thinkingTypeDisabled)
}
if _, present := body["reasoning_effort"]; present {
t.Fatalf("body.reasoning_effort = %v, want it dropped in favour of the switch", body["reasoning_effort"])
}
if rec := buf.String(); !strings.Contains(rec, "contradicts") {
t.Errorf("log %q, want a WARN about the contradiction", rec)
}
}
// TestOpenAIChat_DeepSeekThinkingBudgetReportedIgnored asserts a token budget
// (an Anthropic-style directive) is reported as unmappable on the DeepSeek
// chat-completions endpoint instead of opening or closing the switch by guess.
func TestOpenAIChat_DeepSeekThinkingBudgetReportedIgnored(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
log, buf := captureLog()
ad := newDeepSeekAdapter(t, ch, WithOpenAILogger(log))
if _, err := ad.Chat(context.Background(), Request{
Model: deepSeekModel,
Messages: []Message{TextMessage(RoleUser, "hi")},
Thinking: &Thinking{BudgetTokens: 8192},
}, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
body := (<-ch).body
if _, present := body["thinking"]; present {
t.Fatalf("body.thinking = %v, want no switch block", body["thinking"])
}
rec := buf.String()
for _, want := range []string{"level=WARN", "thinking.budget_tokens", "deepseek", deepSeekModel} {
if !strings.Contains(rec, want) {
t.Errorf("log %q missing %q", rec, want)
}
}
}
// TestOpenAIChat_DeepSeekForcedToolChoiceWithThinkingWarns covers the DeepSeek
// 400 trap: a forcing tool_choice while thinking is on is rejected, and thinking
// is on by default. The adapter only warns — it must never close thinking by
// itself.
func TestOpenAIChat_DeepSeekForcedToolChoiceWithThinkingWarns(t *testing.T) {
t.Parallel()
cases := []struct {
name string
thinking *Thinking
toolChoice any
wantWarn bool
}{
{name: "required_with_default_thinking", toolChoice: map[string]any{"type": "required"}, wantWarn: true},
{name: "named_tool_with_thinking_on", thinking: &Thinking{Enable: boolPtr(true)},
toolChoice: map[string]any{"type": "function", "function": map[string]any{"name": "x"}}, wantWarn: true},
{name: "auto_is_not_forcing", toolChoice: map[string]any{"type": "auto"}},
{name: "forced_with_thinking_off", thinking: &Thinking{Enable: boolPtr(false)},
toolChoice: map[string]any{"type": "required"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
log, buf := captureLog()
ad := newDeepSeekAdapter(t, ch, WithOpenAILogger(log))
if _, err := ad.Chat(context.Background(), Request{
Model: deepSeekModel,
Messages: []Message{TextMessage(RoleUser, "hi")},
Thinking: tc.thinking,
ToolChoice: tc.toolChoice,
}, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
rec := buf.String()
if tc.wantWarn {
for _, want := range []string{"level=WARN", "tool_choice", "deepseek", deepSeekModel} {
if !strings.Contains(rec, want) {
t.Errorf("log %q missing %q", rec, want)
}
}
return
}
if strings.Contains(rec, "tool_choice") {
t.Errorf("unexpected tool_choice WARN: %q", rec)
}
})
}
}