TroubleshootingGuide
400 after a tool call: send reasoning_content back
Why the second request of a tool-call loop returns 400 on a thinking model, a Python fix that appends the full message, and when you can skip it.

On this page
Your agent loop works on the first request and fails on the second with a 400. On a thinking model, one documented cause is a missing field: the assistant message you send back has lost its reasoning_. If that is the cause, appending the whole message exactly as the API returned it fixes it.
DeepSeek's thinking-mode guide (docs, checked 8 November 2026) puts the rule in one sentence: when the request has the tools parameter, reasoning_ has to be passed back in full in all following requests, including turns without a tool call. Otherwise the API returns 400. The same page says thinking is on by default, with effort high. So a loop you wrote for a non-thinking model can break without a single line changing.
What happens between request one and request two#
In thinking mode the chain of thought comes back in reasoning_, a field at the same level as content. Picture the loop:
- You send the question and the
toolslist. - The model answers with
tool_calls(andreasoning_next to them).content - You run the tool and send everything back: the history, the assistant message, and a
toolmessage with the result.
The 400 comes on the request in step 3 if the assistant message in it has no reasoning_.

The DeepSeek page documents the 400 only for this tool-call case and does not quote an error text, so look at the HTTP status, not at a specific message, when you check whether you have this problem.
The fix: append the message as returned#
The safest move is not to rebuild the assistant message at all. Take it from the response and append it whole. Here is a complete loop with the OpenAI Python SDK pointed at CheapTokens. The weather tool is a stub, so you can run it as is.
import json, os
from openai import OpenAI
client = OpenAI(
base_url="https://api.cheaptokens.dev/v1",
api_key=os.environ["CHEAPTOKENS_API_KEY"],
)
MODEL = "deepseek-ai/DeepSeek-V4-Flash-0731"
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Current weather for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}]
def get_weather(city):
return {"city": city, "temp_c": 7} # stub
messages = [{"role": "user", "content": "Do I need a jacket in Oslo today?"}]
for _ in range(5): # hard step limit
resp = client.chat.completions.create(model=MODEL, messages=messages, tools=tools)
msg = resp.choices[0].message
messages.append(msg.model_dump(exclude_none=True)) # the whole message
if not msg.tool_calls:
print(msg.content)
break
for call in msg.tool_calls:
args = json.loads(call.function.arguments)
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(get_weather(**args)),
})The one line that matters is messages.. The SDK keeps fields it does not know about, so whatever the API put on the message, reasoning_ included, travels back with it. The final answer is appended too, which is what the rule asks for: after the first tool call, every later request carries the reasoning of earlier assistant turns.
The tool-call mechanics themselves (schema, tool_call_id, parsing arguments) are in Function calling in Python with an OpenAI-compatible API. The base URL, key and model id are on the CheapTokens docs page; the model used here is described on the DeepSeek page: 380K context and up to 32,768 output tokens per answer, which leaves room for long thinking plus an answer.
A common culprit: a message rebuilt by hand#
A common way to lose the field is code that does not append the response object but builds a new dict. It looks harmless: role, content, tool calls. Frameworks and wrappers do the same when they store history in their own format. The one field they never heard of is the one that goes missing.

Compare
Rebuilt by hand vs appended whole
Rebuilt by hand (breaks)
messages.append({
"role": "assistant",
"content": msg.content,
"tool_calls": [c.model_dump() for c in msg.tool_calls],
})Appended whole (works)
messages.append(msg.model_dump(exclude_none=True))If you use a framework and cannot touch that line, check how it stores assistant turns. A history that goes through a serializer which keeps only content and tool_calls has the same bug, just one layer lower.
When you do not need this#
Not every thinking-model call needs the extra care. For multi-turn chat without tools, DeepSeek's guide says reasoning_ does not have to be passed back; if you send it anyway, it is ignored and earlier reasoning is not added to the context. So a plain chat can keep dropping it and nothing breaks.
Pick your case
Which case is yours?
Choose an option to see the answer
The request has tools, the model is thinking
Append the whole assistant message every turn, tool turns and final answers alike.
Plain chat, no tools parameter
No fix needed. You may drop reasoning_ from the history, or send it; it is ignored.
A framework builds the history for you
Find where it serializes assistant messages and make sure reasoning_ survives. If it cannot, call the API with your own loop for the tool part.
A side effect to keep in mind: the history you resend is input on every request, so long reasoning in a long loop adds up. How thinking tokens are counted is covered in Reasoning effort and thinking tokens, and what to do when a request grows past the model's limits is in Request too long: 400 vs 413.
If it still returns 400#
Go through this in order:
- Print
messagesright before the failing call. Does every assistant message that came from the model havereasoning_, if the response had it?content - Do you rebuild a message anywhere: a helper, a history class, a
json.dumpsand back with a fixed list of keys? - Is the
toolmessage for everytool_call_idpresent, with the same id? - Is it really the length? A request over the context limit is a different 400, described in the article above.
Quick check
What do you append after the model asks for a tool?
Show answer
Answer: The whole assistant message as returned, reasoning_content included
The tool_calls are what your tool messages answer, and with tools in the request the reasoning has to ride along with them.
That is the whole fix: one append, no rebuilding. Add the step limit from the example to any loop you ship, so a bad turn costs five requests, not five hundred.



