Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 93 additions & 9 deletions camel/models/deepseek_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]


Expand Down Expand Up @@ -124,32 +123,97 @@ 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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's also add a TODO to improve the reasoning content storage
also nit:

Suggested change
self._last_reasoning_content: Optional[str] = None
self._last_reasoning_content: str | None = None


def _inject_reasoning_content(
self,
messages: List[OpenAIMessage],
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
messages: List[OpenAIMessage],
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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
messages: The original messages list.
messages (OpenAIMessage): 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 (
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we can separate these checkings to make it clearer?
is_reasoning = (not reasoning_injected) and ("reasoning_content" not in msg)
is_tool_call = msg.get("tool_calls") is not None
is_assistant = msg.get("role") == "assistant"
if is_reasoning and is_tool_call and is_assistant:
...

not reasoning_injected
and isinstance(msg, dict)
and msg.get("role") == "assistant"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any enum for the assistant we can use here?

and msg.get("tool_calls")
and "reasoning_content" not in msg
):
new_msg = dict(msg)
new_msg["reasoning_content"] = self._last_reasoning_content
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be not None?

processed.append(new_msg) # type: ignore[arg-type]
reasoning_injected = True
else:
processed.append(msg)

if reasoning_injected:
self._last_reasoning_content = None
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move it below the reasoning_injected = True?


return list(reversed(processed))

def _extract_reasoning_content(
self, response: ChatCompletion
) -> Optional[str]:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
) -> Optional[str]:
) -> str | None:

r"""Extract reasoning_content from the model response.

Args:
response: The model response.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
response: The model response.
response (ChatCompletion): 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,
messages: List[OpenAIMessage],
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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move to the top


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:
Expand Down Expand Up @@ -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
)
Expand All @@ -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()
Expand All @@ -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
)
Expand All @@ -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
96 changes: 96 additions & 0 deletions test/models/test_deepseek_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading