mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
fix(anthropic-adapter): preserve reasoning_content across tool-call replay
Request path: thinking blocks now populate reasoning_content on the OpenAI assistant message so providers like Fireworks/DeepSeek can round-trip them. Response path: changed truthiness check to is not None so empty reasoning_content survives when tool calls are present. Fixes #37270.
This commit is contained in:
parent
ff02d5cfc0
commit
6106cc5bca
2 changed files with 117 additions and 1 deletions
|
|
@ -583,11 +583,19 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
else:
|
||||
assistant_content = assistant_message_str
|
||||
|
||||
reasoning_content = None
|
||||
for tb in thinking_blocks:
|
||||
if tb.get("type") == "thinking":
|
||||
reasoning_content = tb.get("thinking", "")
|
||||
break
|
||||
|
||||
assistant_message = ChatCompletionAssistantMessage(
|
||||
role="assistant",
|
||||
content=assistant_content,
|
||||
thinking_blocks=(thinking_blocks if len(thinking_blocks) > 0 else None),
|
||||
)
|
||||
if reasoning_content is not None:
|
||||
assistant_message["reasoning_content"] = reasoning_content
|
||||
if len(tool_calls) > 0:
|
||||
assistant_message["tool_calls"] = tool_calls
|
||||
if len(thinking_blocks) > 0:
|
||||
|
|
@ -1242,7 +1250,7 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
).model_dump()
|
||||
)
|
||||
# Handle reasoning_content when thinking_blocks is not present
|
||||
elif hasattr(choice.message, "reasoning_content") and choice.message.reasoning_content:
|
||||
elif hasattr(choice.message, "reasoning_content") and choice.message.reasoning_content is not None:
|
||||
new_content.append(
|
||||
AnthropicResponseContentBlockThinking(
|
||||
type="thinking",
|
||||
|
|
|
|||
|
|
@ -3887,3 +3887,111 @@ def test_translate_anthropic_messages_to_openai_carries_midturn_system_prompt_ca
|
|||
assert result == [
|
||||
{"role": "system", "content": [{"type": "text", "text": "fix", "prompt_cache_breakpoint": explicit}]}
|
||||
]
|
||||
|
||||
|
||||
def test_translate_anthropic_to_openai_thinking_sets_reasoning_content():
|
||||
"""Regression for #37270: Anthropic thinking blocks must populate reasoning_content
|
||||
on the OpenAI assistant message so providers like Fireworks/DeepSeek can round-trip them."""
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
request = {
|
||||
"model": "fireworks_ai/accounts/fireworks/models/deepseek-v4-pro",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": " preserve me ", "signature": ""},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "call_1",
|
||||
"name": "lookup",
|
||||
"input": {"q": "x"},
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
translated, _ = adapter.translate_anthropic_to_openai(request)
|
||||
assistant = translated["messages"][0]
|
||||
assert assistant["reasoning_content"] == " preserve me "
|
||||
assert assistant.get("thinking_blocks") is not None
|
||||
assert len(assistant["thinking_blocks"]) == 1
|
||||
|
||||
|
||||
def test_translate_anthropic_to_openai_redacted_thinking_no_reasoning_content():
|
||||
"""Redacted thinking blocks alone should not set reasoning_content."""
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
request = {
|
||||
"model": "test",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "redacted_thinking", "data": "encrypted"},
|
||||
{"type": "text", "text": "hello"},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
translated, _ = adapter.translate_anthropic_to_openai(request)
|
||||
assistant = translated["messages"][0]
|
||||
assert assistant.get("reasoning_content") is None
|
||||
|
||||
|
||||
def test_translate_openai_to_anthropic_empty_reasoning_content_with_tool_calls():
|
||||
"""Regression for #37270: an explicitly present reasoning_content="" on an assistant
|
||||
tool-call response must produce a thinking block, not be dropped by truthiness check."""
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
response = ModelResponse(
|
||||
id="chatcmpl-test",
|
||||
model="test",
|
||||
choices=[
|
||||
{
|
||||
"index": 0,
|
||||
"finish_reason": "tool_calls",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"reasoning_content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "lookup",
|
||||
"arguments": '{"q":"x"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
],
|
||||
usage=Usage(prompt_tokens=1, completion_tokens=1, total_tokens=2),
|
||||
)
|
||||
blocks = adapter._translate_openai_content_to_anthropic(response.choices)
|
||||
thinking = [b for b in blocks if b.get("type") == "thinking"]
|
||||
assert len(thinking) == 1
|
||||
assert thinking[0]["thinking"] == ""
|
||||
assert thinking[0]["signature"] is None
|
||||
|
||||
|
||||
def test_translate_openai_to_anthropic_none_reasoning_content_no_thinking():
|
||||
"""When reasoning_content is absent (None), no thinking block should appear."""
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
response = ModelResponse(
|
||||
id="chatcmpl-test",
|
||||
model="test",
|
||||
choices=[
|
||||
{
|
||||
"index": 0,
|
||||
"finish_reason": "stop",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "hello",
|
||||
},
|
||||
}
|
||||
],
|
||||
usage=Usage(prompt_tokens=1, completion_tokens=1, total_tokens=2),
|
||||
)
|
||||
blocks = adapter._translate_openai_content_to_anthropic(response.choices)
|
||||
thinking = [b for b in blocks if b.get("type") == "thinking"]
|
||||
assert len(thinking) == 0
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue