From fd5479a4fcedc768dd7924d2deece337db7562f0 Mon Sep 17 00:00:00 2001 From: vominh1919 Date: Tue, 28 Apr 2026 09:38:37 +0700 Subject: [PATCH] fix: preserve DeepSeek thinking blocks on Anthropic replay (#16748) DeepSeek's /anthropic endpoint requires thinking blocks to be replayed in multi-turn conversations for reasoning continuity. The existing code classified api.deepseek.com as a generic third-party endpoint and stripped ALL thinking blocks, causing HTTP 400 from DeepSeek. Fix: add _is_deepseek_anthropic_endpoint() detector (following the Kimi precedent) and a dedicated branch that strips only signed Anthropic blocks while preserving unsigned ones synthesised from reasoning_content. This follows the exact same pattern as the Kimi exemption (issue #13848) and does not change behavior for any other third-party endpoint (Azure, Bedrock, MiniMax, etc.). Fixes NousResearch/hermes-agent#16748 --- agent/anthropic_adapter.py | 54 +++++++++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 1b8e1397..04232f26 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -420,6 +420,33 @@ def _is_kimi_family_endpoint(base_url: str | None, model: str | None = None) -> return False +def _is_deepseek_anthropic_endpoint(base_url: str | None) -> bool: + """Return True for DeepSeek's Anthropic-compatible endpoint. + + DeepSeek's ``/anthropic`` route speaks the Anthropic Messages protocol + but, when thinking mode is enabled, requires the ``thinking`` blocks + from prior assistant turns to round-trip on subsequent requests — the + generic third-party path strips them and triggers HTTP 400:: + + The content[].thinking in the thinking mode must be passed back + to the API. + + Per DeepSeek's published compatibility matrix the blocks are unsigned + (no Anthropic-proprietary signature, no ``redacted_thinking`` support), + so this endpoint is handled with the same strip-signed / keep-unsigned + policy used for Kimi's ``/coding`` endpoint. The match is pinned to + the ``/anthropic`` path so the OpenAI-compatible ``api.deepseek.com`` + base URL (which never reaches this adapter) is not misclassified. + See hermes-agent#16748. + """ + if not base_url_host_matches(base_url or "", "api.deepseek.com"): + return False + normalized = _normalize_base_url_text(base_url) + if not normalized: + return False + return "/anthropic" in normalized.rstrip("/").lower() + + def _requires_bearer_auth(base_url: str | None) -> bool: """Return True for Anthropic-compatible providers that require Bearer auth. @@ -1569,7 +1596,16 @@ def convert_messages_to_anthropic( # cache markers can interfere with signature validation. _THINKING_TYPES = frozenset(("thinking", "redacted_thinking")) _is_third_party = _is_third_party_anthropic_endpoint(base_url) - _is_kimi = _is_kimi_family_endpoint(base_url, model) + # Kimi /coding and DeepSeek /anthropic share a contract: both speak the + # Anthropic Messages protocol upstream but require that thinking blocks + # synthesised from reasoning_content round-trip on subsequent turns when + # thinking is enabled. Signed Anthropic blocks still have to be stripped + # (neither endpoint can validate Anthropic's signatures); unsigned blocks + # are preserved. See hermes-agent#13848 (Kimi) and #16748 (DeepSeek). + _preserve_unsigned_thinking = ( + _is_kimi_family_endpoint(base_url, model) + or _is_deepseek_anthropic_endpoint(base_url) + ) last_assistant_idx = None for i in range(len(result) - 1, -1, -1): @@ -1581,22 +1617,22 @@ def convert_messages_to_anthropic( if m.get("role") != "assistant" or not isinstance(m.get("content"), list): continue - if _is_kimi: - # Kimi's /coding endpoint enables thinking server-side and - # requires unsigned thinking blocks on replayed assistant - # tool-call messages. Strip signed Anthropic blocks (Kimi - # can't validate signatures) but preserve the unsigned ones - # we synthesised from reasoning_content above. + if _preserve_unsigned_thinking: + # Kimi's /coding and DeepSeek's /anthropic endpoints both enable + # thinking server-side and require unsigned thinking blocks on + # replayed assistant tool-call messages. Strip signed Anthropic + # blocks (neither upstream can validate Anthropic signatures) but + # preserve the unsigned ones we synthesised from reasoning_content. 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"): - # Anthropic-signed block — Kimi can't validate, strip + # Anthropic-signed block — upstream can't validate, strip continue # Unsigned thinking (synthesised from reasoning_content) — - # keep it: Kimi needs it for message-history validation. + # keep it: the upstream needs it for message-history validation. new_content.append(b) m["content"] = new_content or [{"type": "text", "text": "(empty)"}] elif _is_third_party or idx != last_assistant_idx: