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
This commit is contained in:
@@ -420,6 +420,33 @@ def _is_kimi_family_endpoint(base_url: str | None, model: str | None = None) ->
|
|||||||
return False
|
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:
|
def _requires_bearer_auth(base_url: str | None) -> bool:
|
||||||
"""Return True for Anthropic-compatible providers that require Bearer auth.
|
"""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.
|
# cache markers can interfere with signature validation.
|
||||||
_THINKING_TYPES = frozenset(("thinking", "redacted_thinking"))
|
_THINKING_TYPES = frozenset(("thinking", "redacted_thinking"))
|
||||||
_is_third_party = _is_third_party_anthropic_endpoint(base_url)
|
_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
|
last_assistant_idx = None
|
||||||
for i in range(len(result) - 1, -1, -1):
|
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):
|
if m.get("role") != "assistant" or not isinstance(m.get("content"), list):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if _is_kimi:
|
if _preserve_unsigned_thinking:
|
||||||
# Kimi's /coding endpoint enables thinking server-side and
|
# Kimi's /coding and DeepSeek's /anthropic endpoints both enable
|
||||||
# requires unsigned thinking blocks on replayed assistant
|
# thinking server-side and require unsigned thinking blocks on
|
||||||
# tool-call messages. Strip signed Anthropic blocks (Kimi
|
# replayed assistant tool-call messages. Strip signed Anthropic
|
||||||
# can't validate signatures) but preserve the unsigned ones
|
# blocks (neither upstream can validate Anthropic signatures) but
|
||||||
# we synthesised from reasoning_content above.
|
# preserve the unsigned ones we synthesised from reasoning_content.
|
||||||
new_content = []
|
new_content = []
|
||||||
for b in m["content"]:
|
for b in m["content"]:
|
||||||
if not isinstance(b, dict) or b.get("type") not in _THINKING_TYPES:
|
if not isinstance(b, dict) or b.get("type") not in _THINKING_TYPES:
|
||||||
new_content.append(b)
|
new_content.append(b)
|
||||||
continue
|
continue
|
||||||
if b.get("signature") or b.get("data"):
|
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
|
continue
|
||||||
# Unsigned thinking (synthesised from reasoning_content) —
|
# 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)
|
new_content.append(b)
|
||||||
m["content"] = new_content or [{"type": "text", "text": "(empty)"}]
|
m["content"] = new_content or [{"type": "text", "text": "(empty)"}]
|
||||||
elif _is_third_party or idx != last_assistant_idx:
|
elif _is_third_party or idx != last_assistant_idx:
|
||||||
|
|||||||
Reference in New Issue
Block a user