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) } }) } }