fix(anthropic): preserve third-party thinking continuity

Downgrade third-party thinking blocks to text so reasoning context survives across turns while removing redacted payloads and stale signatures. Add regression tests for third-party thinking conversion and keep z.ai preserved-thinking behavior server-driven by removing explicit clear_thinking injection.
This commit is contained in:
Teknium 2026-04-15 20:54:44 +08:00
parent 722331a57d
commit d2f043f9cf
4 changed files with 4007 additions and 1591 deletions

View file

@ -120,13 +120,17 @@ class TestReadClaudeCodeCredentials:
def test_reads_valid_credentials(self, tmp_path, monkeypatch):
cred_file = tmp_path / ".claude" / ".credentials.json"
cred_file.parent.mkdir(parents=True)
cred_file.write_text(json.dumps({
"claudeAiOauth": {
"accessToken": "sk-ant-oat01-token",
"refreshToken": "sk-ant-oat01-refresh",
"expiresAt": int(time.time() * 1000) + 3600_000,
}
}))
cred_file.write_text(
json.dumps(
{
"claudeAiOauth": {
"accessToken": "sk-ant-oat01-token",
"refreshToken": "sk-ant-oat01-refresh",
"expiresAt": int(time.time() * 1000) + 3600_000,
}
}
)
)
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
creds = read_claude_code_credentials()
assert creds is not None
@ -134,7 +138,9 @@ class TestReadClaudeCodeCredentials:
assert creds["refreshToken"] == "sk-ant-oat01-refresh"
assert creds["source"] == "claude_code_credentials_file"
def test_ignores_primary_api_key_for_native_anthropic_resolution(self, tmp_path, monkeypatch):
def test_ignores_primary_api_key_for_native_anthropic_resolution(
self, tmp_path, monkeypatch
):
claude_json = tmp_path / ".claude.json"
claude_json.write_text(json.dumps({"primaryApiKey": "sk-ant-api03-primary"}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
@ -156,9 +162,9 @@ class TestReadClaudeCodeCredentials:
def test_returns_none_for_empty_access_token(self, tmp_path, monkeypatch):
cred_file = tmp_path / ".claude" / ".credentials.json"
cred_file.parent.mkdir(parents=True)
cred_file.write_text(json.dumps({
"claudeAiOauth": {"accessToken": "", "refreshToken": "x"}
}))
cred_file.write_text(
json.dumps({"claudeAiOauth": {"accessToken": "", "refreshToken": "x"}})
)
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
assert read_claude_code_credentials() is None
@ -185,16 +191,22 @@ class TestResolveAnthropicToken:
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() == "sk-ant-oat01-mytoken"
def test_does_not_resolve_primary_api_key_as_native_anthropic_token(self, monkeypatch, tmp_path):
def test_does_not_resolve_primary_api_key_as_native_anthropic_token(
self, monkeypatch, tmp_path
):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
(tmp_path / ".claude.json").write_text(json.dumps({"primaryApiKey": "sk-ant-api03-primary"}))
(tmp_path / ".claude.json").write_text(
json.dumps({"primaryApiKey": "sk-ant-api03-primary"})
)
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() is None
def test_falls_back_to_api_key_when_no_oauth_sources_exist(self, monkeypatch, tmp_path):
def test_falls_back_to_api_key_when_no_oauth_sources_exist(
self, monkeypatch, tmp_path
):
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-mykey")
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
@ -228,39 +240,53 @@ class TestResolveAnthropicToken:
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
cred_file = tmp_path / ".claude" / ".credentials.json"
cred_file.parent.mkdir(parents=True)
cred_file.write_text(json.dumps({
"claudeAiOauth": {
"accessToken": "cc-auto-token",
"refreshToken": "refresh",
"expiresAt": int(time.time() * 1000) + 3600_000,
}
}))
cred_file.write_text(
json.dumps(
{
"claudeAiOauth": {
"accessToken": "cc-auto-token",
"refreshToken": "refresh",
"expiresAt": int(time.time() * 1000) + 3600_000,
}
}
)
)
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() == "cc-auto-token"
def test_prefers_refreshable_claude_code_credentials_over_static_anthropic_token(self, monkeypatch, tmp_path):
def test_prefers_refreshable_claude_code_credentials_over_static_anthropic_token(
self, monkeypatch, tmp_path
):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-static-token")
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
cred_file = tmp_path / ".claude" / ".credentials.json"
cred_file.parent.mkdir(parents=True)
cred_file.write_text(json.dumps({
"claudeAiOauth": {
"accessToken": "cc-auto-token",
"refreshToken": "refresh-token",
"expiresAt": int(time.time() * 1000) + 3600_000,
}
}))
cred_file.write_text(
json.dumps(
{
"claudeAiOauth": {
"accessToken": "cc-auto-token",
"refreshToken": "refresh-token",
"expiresAt": int(time.time() * 1000) + 3600_000,
}
}
)
)
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() == "cc-auto-token"
def test_keeps_static_anthropic_token_when_only_non_refreshable_claude_key_exists(self, monkeypatch, tmp_path):
def test_keeps_static_anthropic_token_when_only_non_refreshable_claude_key_exists(
self, monkeypatch, tmp_path
):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-static-token")
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
claude_json = tmp_path / ".claude.json"
claude_json.write_text(json.dumps({"primaryApiKey": "sk-ant-api03-managed-key"}))
claude_json.write_text(
json.dumps({"primaryApiKey": "sk-ant-api03-managed-key"})
)
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() == "sk-ant-oat01-static-token"
@ -280,17 +306,19 @@ class TestRefreshOauthToken:
"expiresAt": int(time.time() * 1000) - 3600_000,
}
mock_response = json.dumps({
"access_token": "new-token-abc",
"refresh_token": "new-refresh-456",
"expires_in": 7200,
}).encode()
mock_response = json.dumps(
{
"access_token": "new-token-abc",
"refresh_token": "new-refresh-456",
"expires_in": 7200,
}
).encode()
with patch("urllib.request.urlopen") as mock_urlopen:
mock_ctx = MagicMock()
mock_ctx.__enter__ = MagicMock(return_value=MagicMock(
read=MagicMock(return_value=mock_response)
))
mock_ctx.__enter__ = MagicMock(
return_value=MagicMock(read=MagicMock(return_value=mock_response))
)
mock_ctx.__exit__ = MagicMock(return_value=False)
mock_urlopen.return_value = mock_ctx
@ -348,38 +376,54 @@ class TestResolveWithRefresh:
# Set up expired creds with a refresh token
cred_file = tmp_path / ".claude" / ".credentials.json"
cred_file.parent.mkdir(parents=True)
cred_file.write_text(json.dumps({
"claudeAiOauth": {
"accessToken": "expired-tok",
"refreshToken": "valid-refresh",
"expiresAt": int(time.time() * 1000) - 3600_000,
}
}))
cred_file.write_text(
json.dumps(
{
"claudeAiOauth": {
"accessToken": "expired-tok",
"refreshToken": "valid-refresh",
"expiresAt": int(time.time() * 1000) - 3600_000,
}
}
)
)
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
# Mock refresh to succeed
with patch("agent.anthropic_adapter._refresh_oauth_token", return_value="refreshed-token"):
with patch(
"agent.anthropic_adapter._refresh_oauth_token",
return_value="refreshed-token",
):
result = resolve_anthropic_token()
assert result == "refreshed-token"
def test_static_env_oauth_token_does_not_block_refreshable_claude_creds(self, monkeypatch, tmp_path):
def test_static_env_oauth_token_does_not_block_refreshable_claude_creds(
self, monkeypatch, tmp_path
):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-expired-env-token")
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
cred_file = tmp_path / ".claude" / ".credentials.json"
cred_file.parent.mkdir(parents=True)
cred_file.write_text(json.dumps({
"claudeAiOauth": {
"accessToken": "expired-claude-creds-token",
"refreshToken": "valid-refresh",
"expiresAt": int(time.time() * 1000) - 3600_000,
}
}))
cred_file.write_text(
json.dumps(
{
"claudeAiOauth": {
"accessToken": "expired-claude-creds-token",
"refreshToken": "valid-refresh",
"expiresAt": int(time.time() * 1000) - 3600_000,
}
}
)
)
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
with patch("agent.anthropic_adapter._refresh_oauth_token", return_value="refreshed-token"):
with patch(
"agent.anthropic_adapter._refresh_oauth_token",
return_value="refreshed-token",
):
result = resolve_anthropic_token()
assert result == "refreshed-token"
@ -400,13 +444,17 @@ class TestRunOauthSetupToken:
# Pre-create credential files that will be found after subprocess
cred_file = tmp_path / ".claude" / ".credentials.json"
cred_file.parent.mkdir(parents=True)
cred_file.write_text(json.dumps({
"claudeAiOauth": {
"accessToken": "from-cred-file",
"refreshToken": "refresh",
"expiresAt": int(time.time() * 1000) + 3600_000,
}
}))
cred_file.write_text(
json.dumps(
{
"claudeAiOauth": {
"accessToken": "from-cred-file",
"refreshToken": "refresh",
"expiresAt": int(time.time() * 1000) + 3600_000,
}
}
)
)
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
with patch("subprocess.run") as mock_run:
@ -459,27 +507,45 @@ class TestRunOauthSetupToken:
class TestNormalizeModelName:
def test_strips_anthropic_prefix(self):
assert normalize_model_name("anthropic/claude-sonnet-4-20250514") == "claude-sonnet-4-20250514"
assert (
normalize_model_name("anthropic/claude-sonnet-4-20250514")
== "claude-sonnet-4-20250514"
)
def test_leaves_bare_name(self):
assert normalize_model_name("claude-sonnet-4-20250514") == "claude-sonnet-4-20250514"
assert (
normalize_model_name("claude-sonnet-4-20250514")
== "claude-sonnet-4-20250514"
)
def test_converts_dots_to_hyphens(self):
"""OpenRouter uses dots (4.6), Anthropic uses hyphens (4-6)."""
assert normalize_model_name("anthropic/claude-opus-4.6") == "claude-opus-4-6"
assert normalize_model_name("anthropic/claude-sonnet-4.5") == "claude-sonnet-4-5"
assert (
normalize_model_name("anthropic/claude-sonnet-4.5") == "claude-sonnet-4-5"
)
assert normalize_model_name("claude-opus-4.6") == "claude-opus-4-6"
def test_already_hyphenated_unchanged(self):
"""Names already in Anthropic format should pass through."""
assert normalize_model_name("claude-opus-4-6") == "claude-opus-4-6"
assert normalize_model_name("claude-opus-4-5-20251101") == "claude-opus-4-5-20251101"
assert (
normalize_model_name("claude-opus-4-5-20251101")
== "claude-opus-4-5-20251101"
)
def test_preserve_dots_for_alibaba_dashscope(self):
"""Alibaba/DashScope use dots in model names (e.g. qwen3.5-plus). Fixes #1739."""
assert normalize_model_name("qwen3.5-plus", preserve_dots=True) == "qwen3.5-plus"
assert normalize_model_name("anthropic/qwen3.5-plus", preserve_dots=True) == "qwen3.5-plus"
assert normalize_model_name("qwen3.5-flash", preserve_dots=True) == "qwen3.5-flash"
assert (
normalize_model_name("qwen3.5-plus", preserve_dots=True) == "qwen3.5-plus"
)
assert (
normalize_model_name("anthropic/qwen3.5-plus", preserve_dots=True)
== "qwen3.5-plus"
)
assert (
normalize_model_name("qwen3.5-flash", preserve_dots=True) == "qwen3.5-flash"
)
# ---------------------------------------------------------------------------
@ -536,7 +602,10 @@ class TestConvertMessages:
"role": "user",
"content": [
{"type": "text", "text": "Can you see this?"},
{"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}},
{
"type": "image_url",
"image_url": {"url": "https://example.com/cat.png"},
},
],
}
]
@ -548,7 +617,10 @@ class TestConvertMessages:
"role": "user",
"content": [
{"type": "text", "text": "Can you see this?"},
{"type": "image", "source": {"type": "url", "url": "https://example.com/cat.png"}},
{
"type": "image",
"source": {"type": "url", "url": "https://example.com/cat.png"},
},
],
}
]
@ -613,7 +685,10 @@ class TestConvertMessages:
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "tc_1", "function": {"name": "test_tool", "arguments": "{}"}},
{
"id": "tc_1",
"function": {"name": "test_tool", "arguments": "{}"},
},
],
},
{"role": "tool", "tool_call_id": "tc_1", "content": "result data"},
@ -678,10 +753,9 @@ class TestConvertMessages:
# tc_gone has no matching tool_use — its tool_result should be stripped
for m in result:
if m["role"] == "user" and isinstance(m["content"], list):
assert all(
b.get("type") != "tool_result"
for b in m["content"]
), "Orphaned tool_result should have been stripped"
assert all(b.get("type") != "tool_result" for b in m["content"]), (
"Orphaned tool_result should have been stripped"
)
def test_strips_orphaned_tool_result_preserves_valid(self):
"""Orphaned tool_results are stripped while valid ones survive."""
@ -690,7 +764,10 @@ class TestConvertMessages:
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "tc_valid", "function": {"name": "search", "arguments": "{}"}},
{
"id": "tc_valid",
"function": {"name": "search", "arguments": "{}"},
},
],
},
{"role": "tool", "tool_call_id": "tc_valid", "content": "good result"},
@ -709,7 +786,11 @@ class TestConvertMessages:
{
"role": "system",
"content": [
{"type": "text", "text": "System prompt", "cache_control": {"type": "ephemeral"}},
{
"type": "text",
"text": "System prompt",
"cache_control": {"type": "ephemeral"},
},
],
},
{"role": "user", "content": "Hi"},
@ -720,10 +801,12 @@ class TestConvertMessages:
assert system[0]["cache_control"] == {"type": "ephemeral"}
def test_assistant_cache_control_blocks_are_preserved(self):
messages = apply_anthropic_cache_control([
{"role": "system", "content": "System prompt"},
{"role": "assistant", "content": "Hello from assistant"},
])
messages = apply_anthropic_cache_control(
[
{"role": "system", "content": "System prompt"},
{"role": "assistant", "content": "Hello from assistant"},
]
)
_, result = convert_messages_to_anthropic(messages)
assistant_blocks = result[0]["content"]
@ -733,17 +816,23 @@ class TestConvertMessages:
assert assistant_blocks[0]["cache_control"] == {"type": "ephemeral"}
def test_tool_cache_control_is_preserved_on_tool_result_block(self):
messages = apply_anthropic_cache_control([
{"role": "system", "content": "System prompt"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "tc_1", "function": {"name": "test_tool", "arguments": "{}"}},
],
},
{"role": "tool", "tool_call_id": "tc_1", "content": "result"},
], native_anthropic=True)
messages = apply_anthropic_cache_control(
[
{"role": "system", "content": "System prompt"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "tc_1",
"function": {"name": "test_tool", "arguments": "{}"},
},
],
},
{"role": "tool", "tool_call_id": "tc_1", "content": "result"},
],
native_anthropic=True,
)
_, result = convert_messages_to_anthropic(messages)
user_msg = [m for m in result if m["role"] == "user"][0]
@ -760,7 +849,10 @@ class TestConvertMessages:
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "tc_1", "function": {"name": "test_tool", "arguments": "{}"}},
{
"id": "tc_1",
"function": {"name": "test_tool", "arguments": "{}"},
},
],
"reasoning_details": [
{
@ -774,10 +866,14 @@ class TestConvertMessages:
]
_, result = convert_messages_to_anthropic(messages)
assistant_blocks = next(msg for msg in result if msg["role"] == "assistant")["content"]
assistant_blocks = next(msg for msg in result if msg["role"] == "assistant")[
"content"
]
assert assistant_blocks[0]["type"] == "thinking"
assert assistant_blocks[0]["thinking"] == "Need to inspect the tool result first."
assert (
assistant_blocks[0]["thinking"] == "Need to inspect the tool result first."
)
assert assistant_blocks[0]["signature"] == "sig_123"
assert assistant_blocks[1]["type"] == "tool_use"
@ -832,25 +928,33 @@ class TestConvertMessages:
}
def test_empty_cached_assistant_tool_turn_converts_without_empty_text_block(self):
messages = apply_anthropic_cache_control([
{"role": "system", "content": "System prompt"},
{"role": "user", "content": "Find the skill"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "tc_1", "function": {"name": "skill_view", "arguments": "{}"}},
],
},
{"role": "tool", "tool_call_id": "tc_1", "content": "result"},
])
messages = apply_anthropic_cache_control(
[
{"role": "system", "content": "System prompt"},
{"role": "user", "content": "Find the skill"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "tc_1",
"function": {"name": "skill_view", "arguments": "{}"},
},
],
},
{"role": "tool", "tool_call_id": "tc_1", "content": "result"},
]
)
_, result = convert_messages_to_anthropic(messages)
assistant_turn = next(msg for msg in result if msg["role"] == "assistant")
assistant_blocks = assistant_turn["content"]
assert all(not (b.get("type") == "text" and b.get("text") == "") for b in assistant_blocks)
assert all(
not (b.get("type") == "text" and b.get("text") == "")
for b in assistant_blocks
)
assert any(b.get("type") == "tool_use" for b in assistant_blocks)
def test_empty_user_message_string_gets_placeholder(self):
@ -888,7 +992,13 @@ class TestConvertMessages:
def test_user_message_with_empty_text_blocks_gets_placeholder(self):
"""User message with only empty text blocks should get placeholder."""
messages = [
{"role": "user", "content": [{"type": "text", "text": ""}, {"type": "text", "text": " "}]},
{
"role": "user",
"content": [
{"type": "text", "text": ""},
{"type": "text", "text": " "},
],
},
]
_, result = convert_messages_to_anthropic(messages)
assert result[0]["role"] == "user"
@ -1085,35 +1195,43 @@ class TestBuildAnthropicKwargs:
class TestGetAnthropicMaxOutput:
def test_opus_4_6(self):
from agent.anthropic_adapter import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-opus-4-6") == 128_000
def test_opus_4_6_variant(self):
from agent.anthropic_adapter import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-opus-4-6:1m:fast") == 128_000
def test_sonnet_4_6(self):
from agent.anthropic_adapter import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-sonnet-4-6") == 64_000
def test_sonnet_4_date_stamped(self):
from agent.anthropic_adapter import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-sonnet-4-20250514") == 64_000
def test_claude_3_5_sonnet(self):
from agent.anthropic_adapter import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-3-5-sonnet-20241022") == 8_192
def test_claude_3_opus(self):
from agent.anthropic_adapter import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-3-opus-20240229") == 4_096
def test_unknown_future_model(self):
from agent.anthropic_adapter import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-ultra-5-20260101") == 128_000
def test_longest_prefix_wins(self):
"""'claude-3-5-sonnet' should match before 'claude-3-5'."""
from agent.anthropic_adapter import _get_anthropic_max_output
# claude-3-5-sonnet (8192) should win over a hypothetical shorter match
assert _get_anthropic_max_output("claude-3-5-sonnet-20241022") == 8_192
@ -1218,7 +1336,9 @@ class TestNormalizeResponse:
msg, reason = normalize_anthropic_response(self._make_response(blocks))
assert msg.content == "The answer is 42."
assert msg.reasoning == "Let me reason about this..."
assert msg.reasoning_details == [{"type": "thinking", "thinking": "Let me reason about this..."}]
assert msg.reasoning_details == [
{"type": "thinking", "thinking": "Let me reason about this..."}
]
def test_thinking_response_preserves_signature(self):
blocks = [
@ -1235,15 +1355,9 @@ class TestNormalizeResponse:
def test_stop_reason_mapping(self):
block = SimpleNamespace(type="text", text="x")
_, r1 = normalize_anthropic_response(
self._make_response([block], "end_turn")
)
_, r2 = normalize_anthropic_response(
self._make_response([block], "tool_use")
)
_, r3 = normalize_anthropic_response(
self._make_response([block], "max_tokens")
)
_, r1 = normalize_anthropic_response(self._make_response([block], "end_turn"))
_, r2 = normalize_anthropic_response(self._make_response([block], "tool_use"))
_, r3 = normalize_anthropic_response(self._make_response([block], "max_tokens"))
assert r1 == "stop"
assert r2 == "tool_calls"
assert r3 == "length"
@ -1306,7 +1420,11 @@ class TestThinkingBlockSignatureManagement:
{"id": "tc_1", "function": {"name": "tool1", "arguments": "{}"}},
],
"reasoning_details": [
{"type": "thinking", "thinking": "Old reasoning.", "signature": "sig_old"},
{
"type": "thinking",
"thinking": "Old reasoning.",
"signature": "sig_old",
},
],
},
{"role": "tool", "tool_call_id": "tc_1", "content": "result 1"},
@ -1317,7 +1435,11 @@ class TestThinkingBlockSignatureManagement:
{"id": "tc_2", "function": {"name": "tool2", "arguments": "{}"}},
],
"reasoning_details": [
{"type": "thinking", "thinking": "Latest reasoning.", "signature": "sig_new"},
{
"type": "thinking",
"thinking": "Latest reasoning.",
"signature": "sig_new",
},
],
},
{"role": "tool", "tool_call_id": "tc_2", "content": "result 2"},
@ -1348,7 +1470,11 @@ class TestThinkingBlockSignatureManagement:
"role": "assistant",
"content": "The answer is 42.",
"reasoning_details": [
{"type": "thinking", "thinking": "Deep thought.", "signature": "sig_valid"},
{
"type": "thinking",
"thinking": "Deep thought.",
"signature": "sig_valid",
},
],
},
]
@ -1445,14 +1571,22 @@ class TestThinkingBlockSignatureManagement:
"role": "assistant",
"content": "First response.",
"reasoning_details": [
{"type": "thinking", "thinking": "First thought.", "signature": "sig_1"},
{
"type": "thinking",
"thinking": "First thought.",
"signature": "sig_1",
},
],
},
{
"role": "assistant",
"content": "Second response.",
"reasoning_details": [
{"type": "thinking", "thinking": "Second thought.", "signature": "sig_2"},
{
"type": "thinking",
"thinking": "Second thought.",
"signature": "sig_2",
},
],
},
]
@ -1532,12 +1666,57 @@ class TestThinkingBlockSignatureManagement:
# Last one: thinking preserved
last_thinking = [
b for b in assistants[2]["content"]
b
for b in assistants[2]["content"]
if isinstance(b, dict) and b.get("type") == "thinking"
]
assert len(last_thinking) == 1
assert last_thinking[0]["signature"] == "sig_3"
def test_third_party_downgrades_thinking_to_text(self):
"""Third-party Anthropic-compatible endpoints get plain text thinking."""
messages = [
{
"role": "assistant",
"content": "Visible answer.",
"reasoning_details": [
{
"type": "thinking",
"thinking": "Third-party-safe reasoning.",
"signature": "sig",
},
{"type": "redacted_thinking", "data": "opaque"},
],
}
]
_, result = convert_messages_to_anthropic(
messages,
base_url="https://api.z.ai/api/paas/v4",
)
blocks = result[0]["content"]
assert not any(b.get("type") == "thinking" for b in blocks)
assert not any(b.get("type") == "redacted_thinking" for b in blocks)
text_blocks = [b.get("text", "") for b in blocks if b.get("type") == "text"]
assert "Third-party-safe reasoning." in text_blocks
assert "Visible answer." in text_blocks
def test_third_party_thinking_only_content_gets_placeholder(self):
"""If third-party turn only has redacted_thinking, use placeholder text."""
messages = [
{
"role": "assistant",
"content": "",
"reasoning_details": [
{"type": "redacted_thinking", "data": "opaque"},
],
}
]
_, result = convert_messages_to_anthropic(
messages,
base_url="https://api.minimax.io/anthropic",
)
assert result[0]["content"] == [{"type": "text", "text": "(thinking elided)"}]
# ---------------------------------------------------------------------------
# Tool choice

File diff suppressed because it is too large Load diff