Skip to content

Commit 41facbc

Browse files
committed
feat(deepseek): update reasoner model for V3.2 tool call support
- Remove 'tools' from reasoner unsupported params since DeepSeek V3.2 reasoner now supports tool calls with thinking mode - Fix bug where copy.deepcopy overwrote the filtered config for reasoner models, making param filtering ineffective - Add reasoning_content injection/extraction for tool call continuations (required by DeepSeek API for multi-turn tool calls in thinking mode) - Fix typo in constant name (REASONSER -> REASONER) - Update documentation URL to point to current thinking_mode guide Closes #3811
1 parent 44b10d0 commit 41facbc

File tree

2 files changed

+189
-9
lines changed

2 files changed

+189
-9
lines changed

camel/models/deepseek_model.py

Lines changed: 93 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,13 @@
4949

5050
logger = get_logger(__name__)
5151

52-
REASONSER_UNSUPPORTED_PARAMS = [
52+
REASONER_UNSUPPORTED_PARAMS = [
5353
"temperature",
5454
"top_p",
5555
"presence_penalty",
5656
"frequency_penalty",
5757
"logprobs",
5858
"top_logprobs",
59-
"tools",
6059
]
6160

6261

@@ -124,32 +123,97 @@ def __init__(
124123
max_retries=max_retries,
125124
**kwargs,
126125
)
126+
# Store the last reasoning_content from model response.
127+
# For DeepSeek reasoner models with tool calls, the
128+
# reasoning_content must be passed back in subsequent requests.
129+
self._last_reasoning_content: Optional[str] = None
130+
131+
def _inject_reasoning_content(
132+
self,
133+
messages: List[OpenAIMessage],
134+
) -> List[OpenAIMessage]:
135+
r"""Inject the last reasoning_content into assistant messages.
136+
137+
For DeepSeek reasoner models with tool call support, the
138+
reasoning_content from the model response needs to be passed back
139+
in subsequent requests for proper context management.
140+
141+
Args:
142+
messages: The original messages list.
143+
144+
Returns:
145+
Messages with reasoning_content added to the last assistant
146+
message that has tool_calls.
147+
"""
148+
if not self._last_reasoning_content:
149+
return messages
150+
151+
# Find the last assistant message with tool_calls and inject
152+
# reasoning_content
153+
processed: List[OpenAIMessage] = []
154+
reasoning_injected = False
155+
156+
for msg in reversed(messages):
157+
if (
158+
not reasoning_injected
159+
and isinstance(msg, dict)
160+
and msg.get("role") == "assistant"
161+
and msg.get("tool_calls")
162+
and "reasoning_content" not in msg
163+
):
164+
new_msg = dict(msg)
165+
new_msg["reasoning_content"] = self._last_reasoning_content
166+
processed.append(new_msg) # type: ignore[arg-type]
167+
reasoning_injected = True
168+
else:
169+
processed.append(msg)
170+
171+
if reasoning_injected:
172+
self._last_reasoning_content = None
173+
174+
return list(reversed(processed))
175+
176+
def _extract_reasoning_content(
177+
self, response: ChatCompletion
178+
) -> Optional[str]:
179+
r"""Extract reasoning_content from the model response.
180+
181+
Args:
182+
response: The model response.
183+
184+
Returns:
185+
The reasoning_content if available, None otherwise.
186+
"""
187+
if response.choices:
188+
return getattr(
189+
response.choices[0].message, "reasoning_content", None
190+
)
191+
return None
127192

128193
def _prepare_request(
129194
self,
130195
messages: List[OpenAIMessage],
131196
response_format: Optional[Type[BaseModel]] = None,
132197
tools: Optional[List[Dict[str, Any]]] = None,
133198
) -> Dict[str, Any]:
134-
request_config = self.model_config_dict.copy()
199+
import copy
200+
201+
request_config = copy.deepcopy(self.model_config_dict)
135202

136203
if self.model_type in [
137204
ModelType.DEEPSEEK_REASONER,
138205
]:
139206
logger.warning(
140-
"Warning: You are using an DeepSeek Reasoner model, "
207+
"Warning: You are using a DeepSeek Reasoner model, "
141208
"which has certain limitations, reference: "
142-
"`https://api-docs.deepseek.com/guides/reasoning_model"
209+
"`https://api-docs.deepseek.com/guides/thinking_mode"
143210
"#api-parameters`.",
144211
)
145212
request_config = {
146213
key: value
147214
for key, value in request_config.items()
148-
if key not in REASONSER_UNSUPPORTED_PARAMS
215+
if key not in REASONER_UNSUPPORTED_PARAMS
149216
}
150-
import copy
151-
152-
request_config = copy.deepcopy(self.model_config_dict)
153217
# Remove strict from each tool's function parameters since DeepSeek
154218
# does not support them
155219
if tools:
@@ -183,6 +247,10 @@ def _run(
183247
"""
184248
self._log_and_trace()
185249

250+
# Inject reasoning_content for reasoner tool call continuations
251+
if self.model_type in [ModelType.DEEPSEEK_REASONER]:
252+
messages = self._inject_reasoning_content(messages)
253+
186254
request_config = self._prepare_request(
187255
messages, response_format, tools
188256
)
@@ -193,6 +261,12 @@ def _run(
193261
**request_config,
194262
)
195263

264+
# Extract and store reasoning_content for next request
265+
if isinstance(response, ChatCompletion):
266+
self._last_reasoning_content = self._extract_reasoning_content(
267+
response
268+
)
269+
196270
return response
197271

198272
@observe()
@@ -215,6 +289,10 @@ async def _arun(
215289
"""
216290
self._log_and_trace()
217291

292+
# Inject reasoning_content for reasoner tool call continuations
293+
if self.model_type in [ModelType.DEEPSEEK_REASONER]:
294+
messages = self._inject_reasoning_content(messages)
295+
218296
request_config = self._prepare_request(
219297
messages, response_format, tools
220298
)
@@ -224,4 +302,10 @@ async def _arun(
224302
**request_config,
225303
)
226304

305+
# Extract and store reasoning_content for next request
306+
if isinstance(response, ChatCompletion):
307+
self._last_reasoning_content = self._extract_reasoning_content(
308+
response
309+
)
310+
227311
return response

test/models/test_deepseek_model.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,99 @@ def test_deepseek_model_create(model_type: ModelType):
5454
model_config_dict=DeepSeekConfig(temperature=1.3).as_dict(),
5555
)
5656
assert model.model_type == model_type
57+
58+
59+
@pytest.mark.model_backend
60+
def test_deepseek_reasoner_prepare_request_filters_params():
61+
r"""Test that reasoner model filters unsupported parameters."""
62+
model_config_dict = DeepSeekConfig(temperature=0.5, top_p=0.9).as_dict()
63+
model = DeepSeekModel(ModelType.DEEPSEEK_REASONER, model_config_dict)
64+
messages = [{"role": "user", "content": "Hello"}]
65+
request_config = model._prepare_request(messages)
66+
assert "temperature" not in request_config
67+
assert "top_p" not in request_config
68+
69+
70+
@pytest.mark.model_backend
71+
def test_deepseek_reasoner_allows_tools():
72+
r"""Test that reasoner model supports tool calls (V3.2+)."""
73+
model_config_dict = DeepSeekConfig().as_dict()
74+
model = DeepSeekModel(ModelType.DEEPSEEK_REASONER, model_config_dict)
75+
messages = [{"role": "user", "content": "Hello"}]
76+
tools = [
77+
{
78+
"type": "function",
79+
"function": {
80+
"name": "get_weather",
81+
"description": "Get the weather",
82+
"parameters": {"type": "object", "properties": {}},
83+
"strict": True,
84+
},
85+
}
86+
]
87+
request_config = model._prepare_request(messages, tools=tools)
88+
assert "tools" in request_config
89+
# Verify strict is removed from function parameters
90+
assert "strict" not in request_config["tools"][0]["function"]
91+
92+
93+
@pytest.mark.model_backend
94+
def test_deepseek_chat_prepare_request_keeps_params():
95+
r"""Test that chat model keeps all parameters."""
96+
model_config_dict = DeepSeekConfig(temperature=0.5, top_p=0.9).as_dict()
97+
model = DeepSeekModel(ModelType.DEEPSEEK_CHAT, model_config_dict)
98+
messages = [{"role": "user", "content": "Hello"}]
99+
request_config = model._prepare_request(messages)
100+
assert request_config.get("temperature") == 0.5
101+
assert request_config.get("top_p") == 0.9
102+
103+
104+
@pytest.mark.model_backend
105+
def test_deepseek_reasoning_content_injection():
106+
r"""Test reasoning_content injection for tool call continuations."""
107+
model_config_dict = DeepSeekConfig().as_dict()
108+
model = DeepSeekModel(ModelType.DEEPSEEK_REASONER, model_config_dict)
109+
110+
# Simulate stored reasoning_content from a previous response
111+
model._last_reasoning_content = "Let me think about this..."
112+
113+
messages = [
114+
{"role": "user", "content": "What's the weather?"},
115+
{
116+
"role": "assistant",
117+
"content": "",
118+
"tool_calls": [
119+
{
120+
"id": "call_1",
121+
"type": "function",
122+
"function": {
123+
"name": "get_weather",
124+
"arguments": '{"city": "Beijing"}',
125+
},
126+
}
127+
],
128+
},
129+
{
130+
"role": "tool",
131+
"content": "Sunny, 25°C",
132+
"tool_call_id": "call_1",
133+
},
134+
]
135+
136+
processed = model._inject_reasoning_content(messages)
137+
138+
# reasoning_content should be injected into the assistant message
139+
assert processed[1]["reasoning_content"] == "Let me think about this..."
140+
# Should be cleared after injection
141+
assert model._last_reasoning_content is None
142+
143+
144+
@pytest.mark.model_backend
145+
def test_deepseek_no_injection_without_reasoning_content():
146+
r"""Test that no injection happens when there's no reasoning_content."""
147+
model_config_dict = DeepSeekConfig().as_dict()
148+
model = DeepSeekModel(ModelType.DEEPSEEK_REASONER, model_config_dict)
149+
150+
messages = [{"role": "user", "content": "Hello"}]
151+
processed = model._inject_reasoning_content(messages)
152+
assert processed == messages

0 commit comments

Comments
 (0)