依据 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)
308 lines
11 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|