diff --git a/camel/configs/deepseek_config.py b/camel/configs/deepseek_config.py index 09ba19a42d..09aa46ef13 100644 --- a/camel/configs/deepseek_config.py +++ b/camel/configs/deepseek_config.py @@ -80,6 +80,15 @@ class DeepSeekConfig(BaseConfig): position, each with an associated log probability. logprobs must be set to true if this parameter is used. (default: :obj:`None`) + thinking (dict, optional): Controls whether to enable thinking + (reasoning) mode. Set to `{"type": "enabled"}` to enable + thinking mode on `deepseek-chat` model, which makes the model + output reasoning content before the final answer. When using + `deepseek-reasoner` model, thinking mode is enabled by default. + Note: when thinking mode is enabled, `temperature`, `top_p`, + `presence_penalty`, and `frequency_penalty` will not take effect. + Reference: https://api-docs.deepseek.com/guides/thinking_mode + (default: :obj:`None`) include_usage (bool, optional): When streaming, specifies whether to include usage information in `stream_options`. (default: :obj:`None`) @@ -98,6 +107,7 @@ class DeepSeekConfig(BaseConfig): ] = None logprobs: Optional[bool] = None top_logprobs: Optional[int] = None + thinking: Optional[Dict[str, str]] = None stream_options: Optional[dict[str, bool]] = None def __init__(self, include_usage: bool = True, **kwargs): diff --git a/camel/models/deepseek_model.py b/camel/models/deepseek_model.py index b937e0b0b6..7d840b0c7c 100644 --- a/camel/models/deepseek_model.py +++ b/camel/models/deepseek_model.py @@ -12,8 +12,9 @@ # limitations under the License. # ========= Copyright 2023-2026 @ CAMEL-AI.org. All Rights Reserved. ========= +import copy import os -from typing import Any, Dict, List, Optional, Type, Union +from typing import Any, Dict, List, Optional, Type, Union, cast from openai import AsyncStream, Stream from pydantic import BaseModel @@ -56,7 +57,6 @@ "frequency_penalty", "logprobs", "top_logprobs", - "tools", ] @@ -124,6 +124,89 @@ def __init__( max_retries=max_retries, **kwargs, ) + # Map tool_call_id -> reasoning_content for multi-turn tool calls. + # Since ChatAgent memory doesn't store reasoning_content, we need to + # keep all of them and re-inject on every request within the same turn. + # Cleared at the start of a new turn (new user message). + self._reasoning_content_map: Dict[str, str] = {} + + def _is_thinking_enabled(self) -> bool: + r"""Check if thinking (reasoning) mode is enabled. + + Returns: + bool: Whether thinking mode is enabled. + """ + if self.model_type in [ModelType.DEEPSEEK_REASONER]: + return True + thinking = self.model_config_dict.get("thinking") + return isinstance(thinking, dict) and thinking.get("type") == "enabled" + + def _inject_reasoning_content( + self, + messages: List[OpenAIMessage], + ) -> List[OpenAIMessage]: + r"""Inject reasoning_content into all assistant messages that need it. + + Args: + messages: The original messages list. + + Returns: + Messages with reasoning_content injected where needed. + """ + if not messages: + return messages + + # New turn (last message is user) -> clear map and return as-is + last_msg = messages[-1] + if isinstance(last_msg, dict) and last_msg.get("role") == "user": + self._reasoning_content_map.clear() + return messages + + if not self._reasoning_content_map: + return messages + + # Inject reasoning_content into ALL assistant messages that need it + processed: List[OpenAIMessage] = [] + for msg in messages: + if ( + isinstance(msg, dict) + and msg.get("role") == "assistant" + and msg.get("tool_calls") + and "reasoning_content" not in msg + ): + tool_calls = cast( + List[Dict[str, Any]], msg.get("tool_calls", []) + ) + if tool_calls: + first_id = tool_calls[0].get("id", "") + reasoning = self._reasoning_content_map.get(first_id) + if reasoning: + new_msg = cast(Dict[str, Any], copy.deepcopy(msg)) + new_msg["reasoning_content"] = reasoning + processed.append(cast(OpenAIMessage, new_msg)) + continue + processed.append(msg) + + return processed + + def _store_reasoning_content(self, response: ChatCompletion) -> None: + r"""Store reasoning_content from the model response. + + Args: + response: The model response. + """ + if not response.choices: + return + + message = response.choices[0].message + reasoning = getattr(message, "reasoning_content", None) + tool_calls = getattr(message, "tool_calls", None) + + if reasoning and tool_calls: + for tc in tool_calls: + tc_id = getattr(tc, "id", None) + if tc_id: + self._reasoning_content_map[tc_id] = reasoning def _prepare_request( self, @@ -131,13 +214,13 @@ 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() + 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" "#api-parameters`.", @@ -147,9 +230,14 @@ def _prepare_request( for key, value in request_config.items() if key not in REASONSER_UNSUPPORTED_PARAMS } - import copy - request_config = copy.deepcopy(self.model_config_dict) + thinking = request_config.pop("thinking", None) + if thinking: + request_config["extra_body"] = { + **request_config.get("extra_body", {}), + "thinking": thinking, + } + # Remove strict from each tool's function parameters since DeepSeek # does not support them if tools: @@ -175,12 +263,19 @@ def _run( Args: messages (List[OpenAIMessage]): Message list with the chat history in OpenAI API format. + response_format (Optional[Type[BaseModel]]): The format of the + response. + tools (Optional[List[Dict[str, Any]]]): The schema of the tools + to use for the request. Returns: Union[ChatCompletion, Stream[ChatCompletionChunk]]: `ChatCompletion` in the non-stream mode, or `Stream[ChatCompletionChunk]` in the stream mode. """ + if self._is_thinking_enabled(): + messages = self._inject_reasoning_content(messages) + self._log_and_trace() request_config = self._prepare_request( @@ -193,6 +288,12 @@ def _run( **request_config, ) + # Store reasoning_content for future requests + if self._is_thinking_enabled() and isinstance( + response, ChatCompletion + ): + self._store_reasoning_content(response) + return response @observe() @@ -207,21 +308,35 @@ async def _arun( Args: messages (List[OpenAIMessage]): Message list with the chat history in OpenAI API format. + response_format (Optional[Type[BaseModel]]): The format of the + response. + tools (Optional[List[Dict[str, Any]]]): The schema of the tools + to use for the request. Returns: Union[ChatCompletion, AsyncStream[ChatCompletionChunk]]: `ChatCompletion` in the non-stream mode, or `AsyncStream[ChatCompletionChunk]` in the stream mode. """ + if self._is_thinking_enabled(): + messages = self._inject_reasoning_content(messages) + self._log_and_trace() request_config = self._prepare_request( messages, response_format, tools ) + response = await self._async_client.chat.completions.create( messages=messages, model=self.model_type, **request_config, ) + # Store reasoning_content for future requests + if self._is_thinking_enabled() and isinstance( + response, ChatCompletion + ): + self._store_reasoning_content(response) + return response diff --git a/test/models/test_deepseek_model.py b/test/models/test_deepseek_model.py index 5369d35645..95c99230ee 100644 --- a/test/models/test_deepseek_model.py +++ b/test/models/test_deepseek_model.py @@ -13,6 +13,8 @@ # ========= Copyright 2023-2026 @ CAMEL-AI.org. All Rights Reserved. ========= +from unittest.mock import MagicMock + import pytest from camel.configs import DeepSeekConfig @@ -54,3 +56,69 @@ 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_config_thinking_parameter(): + """Test DeepSeekConfig accepts thinking parameter.""" + config = DeepSeekConfig(thinking={"type": "enabled"}) + assert config.as_dict()["thinking"] == {"type": "enabled"} + + +@pytest.mark.model_backend +def test_is_thinking_enabled(): + """Test _is_thinking_enabled for different configurations.""" + model_reasoner = DeepSeekModel(ModelType.DEEPSEEK_REASONER) + assert model_reasoner._is_thinking_enabled() is True + + model_chat = DeepSeekModel(ModelType.DEEPSEEK_CHAT) + assert model_chat._is_thinking_enabled() is False + + config = DeepSeekConfig(thinking={"type": "enabled"}).as_dict() + model_chat_thinking = DeepSeekModel(ModelType.DEEPSEEK_CHAT, config) + assert model_chat_thinking._is_thinking_enabled() is True + + +@pytest.mark.model_backend +def test_inject_and_store_reasoning_content(): + """Test reasoning_content injection and storage.""" + model = DeepSeekModel(ModelType.DEEPSEEK_REASONER) + + mock_tool_call = MagicMock() + mock_tool_call.id = "tc_123" + mock_message = MagicMock() + mock_message.reasoning_content = "My reasoning..." + mock_message.tool_calls = [mock_tool_call] + mock_response = MagicMock() + mock_response.choices = [MagicMock(message=mock_message)] + + model._store_reasoning_content(mock_response) + assert model._reasoning_content_map == {"tc_123": "My reasoning..."} + + messages = [ + { + "role": "assistant", + "content": None, + "tool_calls": [{"id": "tc_123"}], + }, + {"role": "tool", "tool_call_id": "tc_123", "content": "result"}, + ] + result = model._inject_reasoning_content(messages) + assert result[0]["reasoning_content"] == "My reasoning..." + + model._inject_reasoning_content([{"role": "user", "content": "new"}]) + assert model._reasoning_content_map == {} + + +@pytest.mark.model_backend +def test_prepare_request_thinking_in_extra_body(): + """Test thinking parameter is moved to extra_body.""" + config = DeepSeekConfig(thinking={"type": "enabled"}).as_dict() + model = DeepSeekModel(ModelType.DEEPSEEK_CHAT, model_config_dict=config) + + request_config = model._prepare_request( + [{"role": "user", "content": "Hi"}] + ) + + assert "thinking" not in request_config + assert request_config["extra_body"]["thinking"] == {"type": "enabled"}