From 0543fa2f1b31783d294bf6c8b8d9ec1ebe30593b Mon Sep 17 00:00:00 2001 From: Xipong <217837358+Xipong@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:54:49 +0300 Subject: [PATCH] fix(anthropic): DeepSeek thinking replay contract for third-party proxies Third-party Anthropic-compatible relays serving a DeepSeek thinking model (deepseek-r/v4/pro/flash) follow the same replay contract as DeepSeek's native /anthropic endpoint: strip signed thinking blocks, preserve unsigned ones. Previously these sessions hit the generic third-party path and lost ALL thinking blocks, breaking cross-turn reasoning coherence. Detection is model-name based (vendor prefixes stripped), gated on the existing _is_third_party check so direct Anthropic traffic is untouched. --- agent/anthropic_endpoints.py | 19 ++++++++ agent/anthropic_message_convert.py | 6 ++- tests/agent/test_anthropic_adapter.py | 25 +++++++++++ .../agent/test_deepseek_anthropic_thinking.py | 45 +++++++++++++++++++ 4 files changed, 93 insertions(+), 2 deletions(-) diff --git a/agent/anthropic_endpoints.py b/agent/anthropic_endpoints.py index 82b96c5af1..237a562176 100644 --- a/agent/anthropic_endpoints.py +++ b/agent/anthropic_endpoints.py @@ -72,6 +72,25 @@ def _is_kimi_family_endpoint(base_url: str | None, model: str | None = None) -> ) + +_DEEPSEEK_THINKING_MODEL_PREFIXES = ( + "deepseek-r", "deepseek-v4", "deepseek_v4", "deepseek-pro", + "deepseek_pro", "deepseek-flash", "deepseek_flash", +) + + +def _model_name_is_deepseek_thinking(model: str | None) -> bool: + """Known DeepSeek thinking families behind an Anthropic-compatible relay. + + Strip vendor namespaces, but do not treat arbitrary DeepSeek chat/distill + names as evidence of the thinking replay contract. + """ + if not isinstance(model, str): + return False + name = model.strip().lower().rsplit("/", 1)[-1] + return bool(name) and name.startswith(_DEEPSEEK_THINKING_MODEL_PREFIXES) + + def _is_deepseek_anthropic_endpoint(base_url: str | None) -> bool: """DeepSeek's ``/anthropic`` route. In thinking mode DeepSeek requires prior-turn ``thinking`` blocks to round-trip while the generic third-party path strips them; its blocks are unsigned, diff --git a/agent/anthropic_message_convert.py b/agent/anthropic_message_convert.py index 2335345ea7..5309ba7fc9 100644 --- a/agent/anthropic_message_convert.py +++ b/agent/anthropic_message_convert.py @@ -12,7 +12,7 @@ from typing import Any, Dict, List, Optional, Tuple from agent.anthropic_endpoints import ( _is_deepseek_anthropic_endpoint, _is_kimi_family_endpoint, _is_nous_portal_endpoint, - _is_third_party_anthropic_endpoint, + _is_third_party_anthropic_endpoint, _model_name_is_deepseek_thinking, ) logger = logging.getLogger(__name__) @@ -565,7 +565,9 @@ def _manage_thinking_signatures(result: List[Dict[str, Any]], base_url: str | No """ is_third_party = _is_third_party_anthropic_endpoint(base_url) and not _is_nous_portal_endpoint(base_url) is_kimi = _is_kimi_family_endpoint(base_url, model) - is_deepseek = _is_deepseek_anthropic_endpoint(base_url) + is_deepseek = _is_deepseek_anthropic_endpoint(base_url) or ( + is_third_party and _model_name_is_deepseek_thinking(model) + ) last_assistant_idx = next((i for i in range(len(result) - 1, -1, -1) if result[i].get("role") == "assistant"), None) for idx, m in _assistant_block_lists(result): if is_kimi: diff --git a/tests/agent/test_anthropic_adapter.py b/tests/agent/test_anthropic_adapter.py index 3ded294a6c..bbc9a822e1 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -1225,6 +1225,31 @@ class TestThinkingBlockSignatureManagement: + def test_third_party_deepseek_preserves_unsigned_thinking(self): + messages = [ + { + "role": "assistant", + "content": "Response text.", + "reasoning_details": [ + {"type": "thinking", "thinking": "Proxy reasoning."}, + ], + }, + ] + + _, result = convert_messages_to_anthropic( + messages, + base_url="https://proxy.example.com/anthropic", + model="deepseek-ai/deepseek-v4", + ) + + assistant = next(m for m in result if m["role"] == "assistant") + thinking = [ + block + for block in assistant["content"] + if block.get("type") == "thinking" + ] + assert thinking == [{"type": "thinking", "thinking": "Proxy reasoning."}] + def test_redacted_thinking_with_data_preserved(self): """Redacted thinking with 'data' field is kept on last turn.""" messages = [ diff --git a/tests/agent/test_deepseek_anthropic_thinking.py b/tests/agent/test_deepseek_anthropic_thinking.py index 9f0dd66f00..543ad1952c 100644 --- a/tests/agent/test_deepseek_anthropic_thinking.py +++ b/tests/agent/test_deepseek_anthropic_thinking.py @@ -105,3 +105,48 @@ class TestDeepSeekAnthropicPreservesThinking: assert "cache_control" not in b +@pytest.mark.parametrize("model", [ + "deepseek-r1", "deepseek-v4", "vendor/deepseek-v4-pro", + "gateway/deepseek-ai/deepseek_flash", " DeepSeek-Pro ", "deepseek_v4_flash", +]) +def test_thinking_family_name_detection(model): + from agent.anthropic_endpoints import _model_name_is_deepseek_thinking + assert _model_name_is_deepseek_thinking(model) + + +@pytest.mark.parametrize("model", [None, "", " ", 42, "deepseek-chat", "deepseek-v3", "vendor/", "not-deepseek-v4", "qwen-thinking"]) +def test_thinking_family_name_detection_rejects_unknown_models(model): + from agent.anthropic_endpoints import _model_name_is_deepseek_thinking + assert not _model_name_is_deepseek_thinking(model) + + +@pytest.mark.parametrize("url", [None, "https://api.anthropic.com", "https://inference-api.nousresearch.com/anthropic"]) +def test_deepseek_model_name_does_not_override_native_signature_contract(url): + from agent.anthropic_message_convert import _manage_thinking_signatures + block = {"type": "thinking", "thinking": "signed native reasoning", "signature": "sig"} + messages = [{"role": "assistant", "content": [dict(block), {"type": "text", "text": "answer"}]}] + _manage_thinking_signatures(messages, url, "deepseek-v4") + assert messages[0]["content"][0] == block + + +def test_deepseek_proxy_keeps_unsigned_thinking_in_older_tool_turns_only(): + import copy + from agent.anthropic_message_convert import convert_messages_to_anthropic + history = [ + {"role": "user", "content": "inspect"}, + {"role": "assistant", "content": "checking", "reasoning_details": [ + {"type": "thinking", "thinking": "unsigned", "cache_control": {"type": "ephemeral"}}, + {"type": "thinking", "thinking": "foreign signed", "signature": "sig"}, + {"type": "redacted_thinking", "data": "redacted-signature"}, + ], "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "inspect", "arguments": "{}"}}]}, + {"role": "tool", "tool_call_id": "call_1", "content": "ok"}, + {"role": "assistant", "content": "done"}, + ] + snapshot = copy.deepcopy(history) + _, result = convert_messages_to_anthropic(history, base_url="https://proxy.example/anthropic", model="vendor/deepseek-v4") + assistant = next(m for m in result if m["role"] == "assistant") + assert [b for b in assistant["content"] if b.get("type") in {"thinking", "redacted_thinking"}] == [ + {"type": "thinking", "thinking": "unsigned"}, + ] + assert history == snapshot +