diff --git a/camel/models/deepseek_model.py b/camel/models/deepseek_model.py index b937e0b0b6..3b2c7b7d52 100644 --- a/camel/models/deepseek_model.py +++ b/camel/models/deepseek_model.py @@ -49,14 +49,13 @@ logger = get_logger(__name__) -REASONSER_UNSUPPORTED_PARAMS = [ +REASONER_UNSUPPORTED_PARAMS = [ "temperature", "top_p", "presence_penalty", "frequency_penalty", "logprobs", "top_logprobs", - "tools", ] @@ -124,6 +123,72 @@ def __init__( max_retries=max_retries, **kwargs, ) + # Store the last reasoning_content from model response. + # For DeepSeek reasoner models with tool calls, the + # reasoning_content must be passed back in subsequent requests. + self._last_reasoning_content: Optional[str] = None + + def _inject_reasoning_content( + self, + messages: List[OpenAIMessage], + ) -> List[OpenAIMessage]: + r"""Inject the last reasoning_content into assistant messages. + + For DeepSeek reasoner models with tool call support, the + reasoning_content from the model response needs to be passed back + in subsequent requests for proper context management. + + Args: + messages: The original messages list. + + Returns: + Messages with reasoning_content added to the last assistant + message that has tool_calls. + """ + if not self._last_reasoning_content: + return messages + + # Find the last assistant message with tool_calls and inject + # reasoning_content + processed: List[OpenAIMessage] = [] + reasoning_injected = False + + for msg in reversed(messages): + if ( + not reasoning_injected + and isinstance(msg, dict) + and msg.get("role") == "assistant" + and msg.get("tool_calls") + and "reasoning_content" not in msg + ): + new_msg = dict(msg) + new_msg["reasoning_content"] = self._last_reasoning_content + processed.append(new_msg) # type: ignore[arg-type] + reasoning_injected = True + else: + processed.append(msg) + + if reasoning_injected: + self._last_reasoning_content = None + + return list(reversed(processed)) + + def _extract_reasoning_content( + self, response: ChatCompletion + ) -> Optional[str]: + r"""Extract reasoning_content from the model response. + + Args: + response: The model response. + + Returns: + The reasoning_content if available, None otherwise. + """ + if response.choices: + return getattr( + response.choices[0].message, "reasoning_content", None + ) + return None def _prepare_request( self, @@ -131,25 +196,24 @@ def _prepare_request( response_format: Optional[Type[BaseModel]] = None, tools: Optional[List[Dict[str, Any]]] = None, ) -> Dict[str, Any]: - request_config = self.model_config_dict.copy() + import copy + + request_config = copy.deepcopy(self.model_config_dict) if self.model_type in [ ModelType.DEEPSEEK_REASONER, ]: logger.warning( - "Warning: You are using an DeepSeek Reasoner model, " + "Warning: You are using a DeepSeek Reasoner model, " "which has certain limitations, reference: " - "`https://api-docs.deepseek.com/guides/reasoning_model" + "`https://api-docs.deepseek.com/guides/thinking_mode" "#api-parameters`.", ) request_config = { key: value for key, value in request_config.items() - if key not in REASONSER_UNSUPPORTED_PARAMS + if key not in REASONER_UNSUPPORTED_PARAMS } - import copy - - request_config = copy.deepcopy(self.model_config_dict) # Remove strict from each tool's function parameters since DeepSeek # does not support them if tools: @@ -183,6 +247,10 @@ def _run( """ self._log_and_trace() + # Inject reasoning_content for reasoner tool call continuations + if self.model_type in [ModelType.DEEPSEEK_REASONER]: + messages = self._inject_reasoning_content(messages) + request_config = self._prepare_request( messages, response_format, tools ) @@ -193,6 +261,12 @@ def _run( **request_config, ) + # Extract and store reasoning_content for next request + if isinstance(response, ChatCompletion): + self._last_reasoning_content = self._extract_reasoning_content( + response + ) + return response @observe() @@ -215,6 +289,10 @@ async def _arun( """ self._log_and_trace() + # Inject reasoning_content for reasoner tool call continuations + if self.model_type in [ModelType.DEEPSEEK_REASONER]: + messages = self._inject_reasoning_content(messages) + request_config = self._prepare_request( messages, response_format, tools ) @@ -224,4 +302,10 @@ async def _arun( **request_config, ) + # Extract and store reasoning_content for next request + if isinstance(response, ChatCompletion): + self._last_reasoning_content = self._extract_reasoning_content( + response + ) + return response diff --git a/test/models/test_deepseek_model.py b/test/models/test_deepseek_model.py index 5369d35645..f8abc22332 100644 --- a/test/models/test_deepseek_model.py +++ b/test/models/test_deepseek_model.py @@ -54,3 +54,99 @@ def test_deepseek_model_create(model_type: ModelType): model_config_dict=DeepSeekConfig(temperature=1.3).as_dict(), ) assert model.model_type == model_type + + +@pytest.mark.model_backend +def test_deepseek_reasoner_prepare_request_filters_params(): + r"""Test that reasoner model filters unsupported parameters.""" + model_config_dict = DeepSeekConfig(temperature=0.5, top_p=0.9).as_dict() + model = DeepSeekModel(ModelType.DEEPSEEK_REASONER, model_config_dict) + messages = [{"role": "user", "content": "Hello"}] + request_config = model._prepare_request(messages) + assert "temperature" not in request_config + assert "top_p" not in request_config + + +@pytest.mark.model_backend +def test_deepseek_reasoner_allows_tools(): + r"""Test that reasoner model supports tool calls (V3.2+).""" + model_config_dict = DeepSeekConfig().as_dict() + model = DeepSeekModel(ModelType.DEEPSEEK_REASONER, model_config_dict) + messages = [{"role": "user", "content": "Hello"}] + tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the weather", + "parameters": {"type": "object", "properties": {}}, + "strict": True, + }, + } + ] + request_config = model._prepare_request(messages, tools=tools) + assert "tools" in request_config + # Verify strict is removed from function parameters + assert "strict" not in request_config["tools"][0]["function"] + + +@pytest.mark.model_backend +def test_deepseek_chat_prepare_request_keeps_params(): + r"""Test that chat model keeps all parameters.""" + model_config_dict = DeepSeekConfig(temperature=0.5, top_p=0.9).as_dict() + model = DeepSeekModel(ModelType.DEEPSEEK_CHAT, model_config_dict) + messages = [{"role": "user", "content": "Hello"}] + request_config = model._prepare_request(messages) + assert request_config.get("temperature") == 0.5 + assert request_config.get("top_p") == 0.9 + + +@pytest.mark.model_backend +def test_deepseek_reasoning_content_injection(): + r"""Test reasoning_content injection for tool call continuations.""" + model_config_dict = DeepSeekConfig().as_dict() + model = DeepSeekModel(ModelType.DEEPSEEK_REASONER, model_config_dict) + + # Simulate stored reasoning_content from a previous response + model._last_reasoning_content = "Let me think about this..." + + messages = [ + {"role": "user", "content": "What's the weather?"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"city": "Beijing"}', + }, + } + ], + }, + { + "role": "tool", + "content": "Sunny, 25°C", + "tool_call_id": "call_1", + }, + ] + + processed = model._inject_reasoning_content(messages) + + # reasoning_content should be injected into the assistant message + assert processed[1]["reasoning_content"] == "Let me think about this..." + # Should be cleared after injection + assert model._last_reasoning_content is None + + +@pytest.mark.model_backend +def test_deepseek_no_injection_without_reasoning_content(): + r"""Test that no injection happens when there's no reasoning_content.""" + model_config_dict = DeepSeekConfig().as_dict() + model = DeepSeekModel(ModelType.DEEPSEEK_REASONER, model_config_dict) + + messages = [{"role": "user", "content": "Hello"}] + processed = model._inject_reasoning_content(messages) + assert processed == messages