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.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user