diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 6da9d73e6..84b082a31 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -242,10 +242,18 @@ class ChatCompletionsTransport(ProviderTransport): # DeepSeek: thinking mode toggle and effort mapping is_deepseek = params.get("is_deepseek", False) if is_deepseek: - _ds_thinking_enabled = True + # Legacy ``deepseek-chat`` is the non-thinking alias; the V4 + # family and ``deepseek-reasoner`` default to thinking mode. + _ds_default_thinking = model_lower != "deepseek-chat" + _ds_thinking_enabled = _ds_default_thinking + _ds_has_explicit_toggle = False if reasoning_config and isinstance(reasoning_config, dict): if reasoning_config.get("enabled") is False: _ds_thinking_enabled = False + _ds_has_explicit_toggle = True + elif reasoning_config.get("enabled") is True or reasoning_config.get("effort"): + _ds_thinking_enabled = True + _ds_has_explicit_toggle = True if _ds_thinking_enabled: # DeepSeek only supports "high" and "max" effort values. # Map low/medium/high → "high", xhigh/max → "max". @@ -260,7 +268,7 @@ class ChatCompletionsTransport(ProviderTransport): # frequency_penalty when thinking is enabled. for _k in ("temperature", "top_p", "presence_penalty", "frequency_penalty"): api_kwargs.pop(_k, None) - else: + elif _ds_default_thinking or _ds_has_explicit_toggle: extra_body["thinking"] = {"type": "disabled"} # Reasoning diff --git a/run_agent.py b/run_agent.py index 6a22bf577..a10bdb29e 100644 --- a/run_agent.py +++ b/run_agent.py @@ -7731,20 +7731,28 @@ class AIAgent: return # DeepSeek thinking mode requires reasoning_content on ALL assistant - # messages — not just tool_calls turns. Empty string is valid. - # Scope to native DeepSeek API and OpenRouter-routed DeepSeek. - deepseek_requires_reasoning = ( - base_url_host_matches(self.base_url, "api.deepseek.com") - or ( - self._is_openrouter_url() - and (self.model or "").lower().startswith("deepseek/") - ) - ) - if deepseek_requires_reasoning: - rc = self.reasoning_config - if isinstance(rc, dict) and rc.get("enabled") is False: + # messages — not just tool_calls turns. Empty string is valid. + # + # Native DeepSeek keeps ``deepseek-chat`` as the legacy non-thinking + # alias, while V4 models and ``deepseek-reasoner`` default to + # thinking. Preserve that distinction so enabling native DeepSeek + # support does not silently change ``deepseek-chat`` semantics. + _model_lower = (self.model or "").lower() + _deepseek_native = base_url_host_matches(self.base_url, "api.deepseek.com") + _deepseek_openrouter = self._is_openrouter_url() and _model_lower.startswith("deepseek/") + if _deepseek_native or _deepseek_openrouter: + rc = self.reasoning_config if isinstance(self.reasoning_config, dict) else {} + if rc.get("enabled") is False: return - api_msg["reasoning_content"] = "" + _deepseek_requires_reasoning = _deepseek_openrouter + if _deepseek_native: + _deepseek_requires_reasoning = ( + _model_lower != "deepseek-chat" + or rc.get("enabled") is True + or bool(rc.get("effort")) + ) + if _deepseek_requires_reasoning: + api_msg["reasoning_content"] = "" @staticmethod def _sanitize_tool_calls_for_strict_api(api_msg: dict) -> dict: diff --git a/tests/agent/test_deepseek_v4.py b/tests/agent/test_deepseek_v4.py index 892fd7c54..e20496f91 100644 --- a/tests/agent/test_deepseek_v4.py +++ b/tests/agent/test_deepseek_v4.py @@ -59,16 +59,22 @@ class TestDeepSeekV4ContextWindows(unittest.TestCase): class TestDeepSeekThinkingMode(unittest.TestCase): """Verify build_kwargs handles DeepSeek thinking mode correctly.""" - def _build(self, reasoning_config=None, is_deepseek=True, temperature=0.7): + def _build( + self, + reasoning_config=None, + is_deepseek=True, + model="deepseek-v4-pro", + fixed_temperature=0.7, + ): transport = ChatCompletionsTransport.__new__(ChatCompletionsTransport) kwargs = transport.build_kwargs( - model="deepseek-v4-pro", + model=model, messages=[{"role": "user", "content": "Hello"}], tools=None, is_deepseek=is_deepseek, reasoning_config=reasoning_config, - model_lower="deepseek-v4-pro", - temperature=temperature, + model_lower=model.lower(), + fixed_temperature=fixed_temperature, ) return kwargs @@ -106,7 +112,7 @@ class TestDeepSeekThinkingMode(unittest.TestCase): def test_temperature_stripped_when_thinking_enabled(self): """DeepSeek rejects temperature when thinking is enabled.""" - kwargs = self._build(temperature=0.7) + kwargs = self._build(fixed_temperature=0.7) self.assertNotIn("temperature", kwargs) def test_non_deepseek_not_affected(self): @@ -119,11 +125,29 @@ class TestDeepSeekThinkingMode(unittest.TestCase): """When thinking is disabled, temperature should be preserved.""" kwargs = self._build( reasoning_config={"enabled": False}, - temperature=0.7, + fixed_temperature=0.7, ) - # Temperature should not be stripped when thinking is disabled - # (The transport may or may not set temperature — the key point - # is that the DeepSeek block does not strip it) + self.assertEqual(kwargs.get("temperature"), 0.7) + + def test_deepseek_chat_does_not_force_thinking(self): + """Legacy deepseek-chat should stay on its non-thinking default.""" + kwargs = self._build(model="deepseek-chat") + extra = kwargs.get("extra_body", {}) + self.assertNotIn("thinking", extra) + self.assertNotIn("reasoning_effort", kwargs) + self.assertEqual(kwargs.get("temperature"), 0.7) + + def test_deepseek_chat_can_opt_in_to_thinking(self): + """Explicit reasoning config should enable thinking for deepseek-chat.""" + kwargs = self._build( + model="deepseek-chat", + reasoning_config={"enabled": True, "effort": "xhigh"}, + fixed_temperature=0.7, + ) + extra = kwargs.get("extra_body", {}) + self.assertEqual(extra.get("thinking", {}).get("type"), "enabled") + self.assertEqual(kwargs.get("reasoning_effort"), "max") + self.assertNotIn("temperature", kwargs) class TestDeepSeekReasoningContentReplay(unittest.TestCase): @@ -197,6 +221,29 @@ class TestDeepSeekReasoningContentReplay(unittest.TestCase): ) self.assertNotIn("reasoning_content", api_msg) + def test_native_deepseek_chat_does_not_inject_by_default(self): + """Legacy non-thinking deepseek-chat should not replay reasoning_content.""" + agent = self._make_agent(model="deepseek-chat") + api_msg = {} + agent._copy_reasoning_content_for_api( + {"role": "assistant", "content": "Hi"}, + api_msg, + ) + self.assertNotIn("reasoning_content", api_msg) + + def test_native_deepseek_chat_injects_when_enabled(self): + """deepseek-chat should replay reasoning_content once thinking is enabled.""" + agent = self._make_agent( + model="deepseek-chat", + reasoning_config={"enabled": True, "effort": "high"}, + ) + api_msg = {} + agent._copy_reasoning_content_for_api( + {"role": "assistant", "content": "Hi"}, + api_msg, + ) + self.assertEqual(api_msg.get("reasoning_content"), "") + def test_non_assistant_skipped(self): """Non-assistant messages should be skipped entirely.""" agent = self._make_agent()