Back to blog

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.

By CheapTokens TeamPublished 5 min read

Cover: a tool-call loop where reasoning_content travels from the assistant message into the next request
On this page
  1. What happens between request one and request two
  2. The fix: append the message as returned
  3. A common culprit: a message rebuilt by hand
  4. When you do not need this
  5. If it still returns 400

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_content. 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_content 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_content, a field at the same level as content. Picture the loop:

  1. You send the question and the tools list.
  2. The model answers with tool_calls (and reasoning_content next to them).
  3. You run the tool and send everything back: the history, the assistant message, and a tool message with the result.

The 400 comes on the request in step 3 if the assistant message in it has no reasoning_content.

Three steps of a tool loop: request, assistant message with reasoning_content, second request
Where the field has to travel: from the assistant message into the next request

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.

python
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.append(msg.model_dump(exclude_none=True)). The SDK keeps fields it does not know about, so whatever the API put on the message, reasoning_content 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.

Left: assistant message rebuilt from role, content, tool_calls. Right: the message as returned, with reasoning_content
The difference between the two assistant messages is one field

Compare

Rebuilt by hand vs appended whole

Rebuilt by hand (breaks)

python
messages.append({
    "role": "assistant",
    "content": msg.content,
    "tool_calls": [c.model_dump() for c in msg.tool_calls],
})

Appended whole (works)

python
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_content 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?

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_content 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_content 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 messages right before the failing call. Does every assistant message that came from the model have reasoning_content, if the response had it?
  • Do you rebuild a message anywhere: a helper, a history class, a json.dumps and back with a fixed list of keys?
  • Is the tool message for every tool_call_id present, 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.