Skip to content

Commit 3fca535

Browse files
committed
fix(anthropic): preserve thinking signatures for Claude Code compatibility
Claude's API requires valid signatures on thinking blocks. This change: - Extract thinking blocks and signatures from Anthropic format in translator - Pass thought_signature through streaming pipeline with signature_delta - Use tool_call_id as primary cache key (more stable than text hash) - Only inject thinking when valid signature is available - Add final safety check to ensure clean state when signatures missing Fixes 400 errors when using Claude models through Claude Code.
1 parent 16c889f commit 3fca535

File tree

3 files changed

+186
-63
lines changed

3 files changed

+186
-63
lines changed

src/rotator_library/anthropic_compat/streaming.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ async def anthropic_streaming_wrapper(
4949
message_started = False
5050
content_block_started = False
5151
thinking_block_started = False
52+
thinking_signature = "" # Store signature for thinking block
5253
current_block_index = 0
5354
tool_calls_by_index = {} # Track tool calls by their index
5455
tool_block_indices = {} # Track which block index each tool call uses
@@ -87,9 +88,18 @@ async def anthropic_streaming_wrapper(
8788

8889
# Close any open thinking block
8990
if thinking_block_started:
91+
# Send signature_delta if we have a signature
92+
if thinking_signature:
93+
sig_delta = {
94+
"type": "content_block_delta",
95+
"index": current_block_index,
96+
"delta": {"type": "signature_delta", "signature": thinking_signature},
97+
}
98+
yield f"event: content_block_delta\ndata: {json.dumps(sig_delta)}\n\n"
9099
yield f'event: content_block_stop\ndata: {{"type": "content_block_stop", "index": {current_block_index}}}\n\n'
91100
current_block_index += 1
92101
thinking_block_started = False
102+
thinking_signature = ""
93103

94104
# Close any open text block
95105
if content_block_started:
@@ -148,8 +158,25 @@ async def anthropic_streaming_wrapper(
148158

149159
# Handle reasoning/thinking content (from OpenAI-style reasoning_content)
150160
reasoning_content = delta.get("reasoning_content")
161+
thought_sig_from_delta = delta.get("thought_signature", "")
162+
163+
# Always capture signature if available (may come in later deltas)
164+
if thought_sig_from_delta and not thinking_signature:
165+
thinking_signature = thought_sig_from_delta
166+
151167
if reasoning_content:
168+
import logging
169+
logging.getLogger("rotator_library").debug(
170+
f"[Anthropic Stream] Sending thinking ({len(reasoning_content)} chars), sig={bool(thinking_signature)}"
171+
)
152172
if not thinking_block_started:
173+
# Close any open text block before starting a new thinking block
174+
# This enables interleaved thinking (thinking after tool results)
175+
if content_block_started:
176+
yield f'event: content_block_stop\ndata: {{"type": "content_block_stop", "index": {current_block_index}}}\n\n'
177+
current_block_index += 1
178+
content_block_started = False
179+
153180
# Start a thinking content block
154181
block_start = {
155182
"type": "content_block_start",
@@ -172,9 +199,18 @@ async def anthropic_streaming_wrapper(
172199
if content:
173200
# If we were in a thinking block, close it first
174201
if thinking_block_started and not content_block_started:
202+
# Send signature_delta if we have a signature
203+
if thinking_signature:
204+
sig_delta = {
205+
"type": "content_block_delta",
206+
"index": current_block_index,
207+
"delta": {"type": "signature_delta", "signature": thinking_signature},
208+
}
209+
yield f"event: content_block_delta\ndata: {json.dumps(sig_delta)}\n\n"
175210
yield f'event: content_block_stop\ndata: {{"type": "content_block_stop", "index": {current_block_index}}}\n\n'
176211
current_block_index += 1
177212
thinking_block_started = False
213+
thinking_signature = ""
178214

179215
if not content_block_started:
180216
# Start a text content block
@@ -202,9 +238,18 @@ async def anthropic_streaming_wrapper(
202238
if tc_index not in tool_calls_by_index:
203239
# Close previous thinking block if open
204240
if thinking_block_started:
241+
# Send signature_delta if we have a signature
242+
if thinking_signature:
243+
sig_delta = {
244+
"type": "content_block_delta",
245+
"index": current_block_index,
246+
"delta": {"type": "signature_delta", "signature": thinking_signature},
247+
}
248+
yield f"event: content_block_delta\ndata: {json.dumps(sig_delta)}\n\n"
205249
yield f'event: content_block_stop\ndata: {{"type": "content_block_stop", "index": {current_block_index}}}\n\n'
206250
current_block_index += 1
207251
thinking_block_started = False
252+
thinking_signature = ""
208253

209254
# Close previous text block if open
210255
if content_block_started:

src/rotator_library/anthropic_compat/translator.py

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@
77
"""
88

99
import json
10+
import logging
1011
import uuid
1112
from typing import Any, Dict, List, Optional, Union
1213

1314
from .models import AnthropicMessagesRequest
1415

16+
lib_logger = logging.getLogger("rotator_library")
17+
1518

1619
def anthropic_to_openai_messages(
1720
anthropic_messages: List[dict], system: Optional[Union[str, List[dict]]] = None
@@ -56,12 +59,35 @@ def anthropic_to_openai_messages(
5659
# Handle content blocks
5760
openai_content = []
5861
tool_calls = []
62+
reasoning_content = ""
63+
thought_signature = None
5964

6065
for block in content:
6166
if isinstance(block, dict):
6267
block_type = block.get("type", "text")
6368

64-
if block_type == "text":
69+
if block_type in ("thinking", "redacted_thinking"):
70+
if role != "assistant":
71+
continue
72+
if reasoning_content:
73+
reasoning_content += "\n"
74+
thinking_text = ""
75+
if block_type == "redacted_thinking":
76+
reasoning_content += "[redacted]"
77+
thinking_text = "[redacted]"
78+
else:
79+
thinking_text = block.get("thinking", "")
80+
if thinking_text:
81+
reasoning_content += thinking_text
82+
signature = block.get("signature")
83+
if signature:
84+
thought_signature = signature
85+
lib_logger.debug(
86+
f"[Translator] Found {block_type} block: "
87+
f"has_thinking={bool(thinking_text)}, "
88+
f"has_signature={bool(signature)}"
89+
)
90+
elif block_type == "text":
6591
openai_content.append(
6692
{"type": "text", "text": block.get("text", "")}
6793
)
@@ -197,15 +223,42 @@ def anthropic_to_openai_messages(
197223
else:
198224
msg_dict["content"] = None
199225
msg_dict["tool_calls"] = tool_calls
226+
if reasoning_content:
227+
msg_dict["reasoning_content"] = reasoning_content
228+
if thought_signature:
229+
msg_dict["thought_signature"] = thought_signature
230+
lib_logger.debug(
231+
f"[Translator] Assistant msg with tool_calls: "
232+
f"reasoning={len(reasoning_content)} chars, has_sig={bool(thought_signature)}"
233+
)
200234
openai_messages.append(msg_dict)
201235
elif openai_content:
202236
# Check if it's just text or mixed content
203237
if len(openai_content) == 1 and openai_content[0].get("type") == "text":
204-
openai_messages.append(
205-
{"role": role, "content": openai_content[0].get("text", "")}
206-
)
238+
msg_dict = {
239+
"role": role,
240+
"content": openai_content[0].get("text", ""),
241+
}
207242
else:
208-
openai_messages.append({"role": role, "content": openai_content})
243+
msg_dict = {"role": role, "content": openai_content}
244+
if reasoning_content:
245+
msg_dict["reasoning_content"] = reasoning_content
246+
if thought_signature:
247+
msg_dict["thought_signature"] = thought_signature
248+
lib_logger.debug(
249+
f"[Translator] Assistant msg with text: "
250+
f"reasoning={len(reasoning_content)} chars, has_sig={bool(thought_signature)}"
251+
)
252+
openai_messages.append(msg_dict)
253+
elif reasoning_content:
254+
msg_dict = {"role": role, "content": "", "reasoning_content": reasoning_content}
255+
if thought_signature:
256+
msg_dict["thought_signature"] = thought_signature
257+
lib_logger.debug(
258+
f"[Translator] Assistant msg (reasoning only): "
259+
f"reasoning={len(reasoning_content)} chars, has_sig={bool(thought_signature)}"
260+
)
261+
openai_messages.append(msg_dict)
209262

210263
return openai_messages
211264

0 commit comments

Comments
 (0)