Skip to content

bug: DeepSeek Anthropic-compatible API — HTTP 400: thinking blocks stripped from message history #22313

@guocongcongcong

Description

@guocongcongcong

Bug Description

When using DeepSeek's Anthropic-compatible Messages API (https://api.deepseek.com/anthropic) with reasoning_effort enabled, multi-turn conversations fail with HTTP 400:

HTTP 400: The `content[].thinking` in the thinking mode must be passed back to the API.

Root Cause

In agent/anthropic_adapter.py, convert_messages_to_anthropic() classifies any non-anthropic.com endpoint as third-party and strips ALL thinking / redacted_thinking content blocks from every assistant message (lines ~1484–1492).

DeepSeek requires these thinking blocks in the message history when reasoning mode is active. Stripping them causes the API to reject the request on the second+ turn.

Kimi's /coding endpoint has the same requirement and already has a dedicated handler (_is_kimi branch at line ~1466). DeepSeek needs similar treatment.

Steps to Reproduce

  1. Configure DeepSeek Anthropic-compatible endpoint:
    model:
      default: deepseek-v4-pro
      provider: deepseek
      base_url: https://api.deepseek.com/anthropic
    agent:
      reasoning_effort: medium
  2. Start any multi-turn conversation that triggers 2+ assistant turns with tool calls
  3. Second API call fails with HTTP 400

Proposed Fix

Add _is_deepseek_anthropic_endpoint detection and a dedicated branch that preserves unsigned thinking blocks (DeepSeek-native) while stripping signed Anthropic blocks (which DeepSeek can't validate). Same pattern as the existing Kimi handler.

diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py
index af358a2d..a8936a47 100644
--- a/agent/anthropic_adapter.py
+++ b/agent/anthropic_adapter.py
@@ -336,6 +336,22 @@ def _is_kimi_coding_endpoint(base_url: str | None) -> bool:
     return normalized.rstrip("/").lower().startswith("https://api.kimi.com/coding")
 
 
+def _is_deepseek_anthropic_endpoint(base_url: str | None) -> bool:
+    """Return True for DeepSeek's Anthropic-compatible Messages API.
+
+    DeepSeek requires thinking blocks to be preserved in the message
+    history when reasoning_effort is enabled — if they are stripped the
+    API returns HTTP 400.  Unlike Anthropic, DeepSeek does not sign its
+    thinking blocks, so signed (Anthropic) blocks are stripped while
+    unsigned (DeepSeek-native) blocks are kept.
+    """
+    normalized = _normalize_base_url_text(base_url)
+    if not normalized:
+        return False
+    normalized = normalized.rstrip("/").lower()
+    return "api.deepseek.com" in normalized and "anthropic" in normalized
+
+
 def _requires_bearer_auth(base_url: str | None) -> bool:
     """Return True for Anthropic-compatible providers that require Bearer auth.
 
@@ -1435,6 +1451,7 @@ def convert_messages_to_anthropic(
     _THINKING_TYPES = frozenset(("thinking", "redacted_thinking"))
     _is_third_party = _is_third_party_anthropic_endpoint(base_url)
     _is_kimi = _is_kimi_coding_endpoint(base_url)
+    _is_deepseek = _is_deepseek_anthropic_endpoint(base_url)
 
     last_assistant_idx = None
     for i in range(len(result) - 1, -1, -1):
@@ -1464,6 +1481,21 @@ def convert_messages_to_anthropic(
                 # keep it: Kimi needs it for message-history validation.
                 new_content.append(b)
             m["content"] = new_content or [{"type": "text", "text": "(empty)"}]
+        elif _is_deepseek:
+            # DeepSeek Anthropic-compatible API requires thinking blocks
+            # on replayed assistant messages for multi-turn reasoning
+            # continuity.  Strip signed Anthropic blocks (DeepSeek can't
+            # validate external signatures) but preserve unsigned blocks
+            # that DeepSeek itself generated on prior turns.
+            new_content = []
+            for b in m["content"]:
+                if not isinstance(b, dict) or b.get("type") not in _THINKING_TYPES:
+                    new_content.append(b)
+                    continue
+                if b.get("signature") or b.get("data"):
+                    continue
+                new_content.append(b)
+            m["content"] = new_content or [{"type": "text", "text": "(empty)"}]
         elif _is_third_party or idx != last_assistant_idx:
             # Third-party endpoint: strip ALL thinking blocks from every
             # assistant message — signatures are Anthropic-proprietary.

Workaround

Set reasoning_effort: '' in config to disable thinking mode when using DeepSeek's Anthropic endpoint.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Medium — degraded but workaround existscomp/agentCore agent loop, run_agent.py, prompt builderprovider/deepseekDeepSeek APItype/bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions