hermes - 💡(How to fix) Fix bug: DeepSeek Anthropic-compatible API — HTTP 400: thinking blocks stripped from message history

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

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.

Fix Action

Workaround

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

Code Example

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

---

model:
     default: deepseek-v4-pro
     provider: deepseek
     base_url: https://api.deepseek.com/anthropic
   agent:
     reasoning_effort: medium

---

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.
RAW_BUFFERClick to expand / collapse

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.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING