This commit is contained in:
小七 2026-10-18 10:46:44 +08:00 • committed by GitHub
commit 6ff641e03a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 231 additions and 302 deletions

View file

@ -2017,11 +2017,11 @@ def strip_prefill_prefix_field(message: AllMessageValues) -> AllMessageValues:
return _message_without_fields(message, frozenset({"prefix"}))
def filter_value_from_dict(dictionary: dict, key: str, depth: int = 0) -> Any:
def filter_value_from_dict(dictionary: dict, key: str, *extra_keys: str, depth: int = 0) -> Any:
"""
Filters a value from a dictionary
Goes through the nested dict and removes the key if it exists
Goes through the nested dict and removes the key (plus any extra keys) if it exists
"""
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH
@ -2032,14 +2032,14 @@ def filter_value_from_dict(dictionary: dict, key: str, depth: int = 0) -> Any:
keys: Final = list(dictionary.keys())
for k in keys:
v = dictionary[k]
if k == key:
if k == key or k in extra_keys:
del dictionary[k]
elif isinstance(v, dict):
filter_value_from_dict(v, key, depth + 1)
filter_value_from_dict(v, key, *extra_keys, depth=depth + 1)
elif isinstance(v, list):
for item in v:
if isinstance(item, dict):
filter_value_from_dict(item, key, depth + 1)
filter_value_from_dict(item, key, *extra_keys, depth=depth + 1)
return dictionary

View file

@ -534,6 +534,7 @@ class LiteLLMAnthropicMessagesAdapter:
role="tool",
tool_call_id=content.get("tool_use_id", ""),
content=self._tool_result_content(content.get("content")),
**({"is_error": True} if "is_error" in content and content["is_error"] is True else {}),
)
self._add_cache_control_if_applicable(content, tool_result, model)
tool_message_list.append(tool_result)

View file

@ -422,9 +422,13 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig):
from litellm.types.llms.openai import ChatCompletionToolParam
for i, message in enumerate(messages):
# is_error: litellm's anthropic adapter carries tool_result.is_error
# on the intermediate tool message so error-aware translations can
# restore it; real OpenAI has no such field (#44979). Stripped in
# the same in-place pass as cache_control.
messages[i] = cast(
AllMessageValues,
filter_value_from_dict(message, "cache_control"),
filter_value_from_dict(message, "cache_control", "is_error"),
)
if tools is not None:
for i, tool in enumerate(tools):

View file

@ -47066,6 +47066,10 @@
],
"title": "Content"
},
"is_error": {
"title": "Is Error",
"type": "boolean"
},
"role": {
"const": "tool",
"title": "Role",

View file

@ -918,6 +918,10 @@ class ChatCompletionToolMessage(TypedDict):
role: Literal["tool"]
content: str | Iterable[ToolMessageContentPart] # writable-ok: Pydantic warns on ReadOnly TypedDict fields
tool_call_id: str
# litellm-internal carrier for Anthropic tool_result.is_error, so the flag
# survives translation to the OpenAI shape and downstream provider
# translations can restore it. Not an OpenAI wire field.
is_error: NotRequired[bool] # writable-ok: Pydantic warns on ReadOnly TypedDict fields
class ChatCompletionFunctionMessage(TypedDict):

View file

@ -9,7 +9,6 @@ import pytest
import litellm
from litellm.litellm_core_utils.prompt_templates.common_utils import (
TOOL_RESULT_IMAGE_PLACEHOLDER,
encrypted_reasoning_signature,
@ -63,10 +62,17 @@ def test_translate_openai_response_to_anthropic_empty_choices() -> None:
assert result["usage"]["input_tokens"] == 10
@pytest.mark.parametrize("text,count,expected_stop", [
("", 1, "compaction"), (None, 1, "compaction"), ("Answer", 1, "max_tokens"),
(" ", 1, "max_tokens"), ("", 2, "max_tokens"), ("", 0, "max_tokens"),
])
@pytest.mark.parametrize(
"text,count,expected_stop",
[
("", 1, "compaction"),
(None, 1, "compaction"),
("Answer", 1, "max_tokens"),
(" ", 1, "max_tokens"),
("", 2, "max_tokens"),
("", 0, "max_tokens"),
],
)
def test_native_compaction_response_roundtrip(text: str | None, count: int, expected_stop: str) -> None:
block: Final = {"type": "compaction", "content": "Exact summary", "signature": "opaque-signature"}
message: Final = Message(content=text, provider_specific_fields={"compaction_blocks": [block] * count})
@ -175,9 +181,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_content_block():
tool_calls=[
ChatCompletionDeltaToolCall(
id="call_d581d130-e234-4315-94e8-27e7ff7c4e55",
function=Function(
arguments='{"location": "Boston"}', name="get_weather"
),
function=Function(arguments='{"location": "Boston"}', name="get_weather"),
type="function",
index=0,
)
@ -221,9 +225,7 @@ def test_translate_streaming_openai_chunk_strips_gemini_thought_from_tool_call_i
tool_calls=[
ChatCompletionDeltaToolCall(
id=combined,
function=Function(
arguments='{"a": 17, "b": 25}', name="add_numbers"
),
function=Function(arguments='{"a": 17, "b": 25}', name="add_numbers"),
type="function",
index=0,
)
@ -460,10 +462,7 @@ def test_translate_anthropic_messages_to_openai_thinking_blocks():
assert "thinking_blocks" in result[1]
assert len(result[1]["thinking_blocks"]) == 2
assert result[1]["thinking_blocks"][0]["type"] == "thinking"
assert (
result[1]["thinking_blocks"][0]["thinking"]
== "I will call the get_weather tool."
)
assert result[1]["thinking_blocks"][0]["thinking"] == "I will call the get_weather tool."
assert result[1]["thinking_blocks"][0]["signature"] == "sigsig"
assert result[1]["thinking_blocks"][1]["type"] == "redacted_thinking"
assert result[1]["thinking_blocks"][1]["data"] == "REDACTED"
@ -603,9 +602,7 @@ def test_translate_anthropic_messages_to_openai_tool_message_placement():
assert tool_message_idx is not None, "Tool message not found"
assert user_message_idx is not None, "User message not found"
assert (
tool_message_idx < user_message_idx
), "Tool message should be placed before user message"
assert tool_message_idx < user_message_idx, "Tool message should be placed before user message"
@pytest.mark.parametrize(
@ -1071,9 +1068,7 @@ def test_translate_anthropic_to_openai_skips_prompt_cache_key_when_provider_lack
def test_translate_anthropic_to_openai_skips_prompt_cache_key_for_chained_litellm_proxy():
assert "prompt_cache_key" in litellm.get_supported_openai_params(
model="xai", custom_llm_provider="litellm_proxy"
)
assert "prompt_cache_key" in litellm.get_supported_openai_params(model="xai", custom_llm_provider="litellm_proxy")
openai_request = _translate_with_metadata("litellm_proxy/xai", {"user_id": CLAUDE_CODE_USER_ID}, "litellm_proxy")
assert openai_request["user"] == CLAUDE_CODE_USER_ID
assert "prompt_cache_key" not in openai_request
@ -1118,7 +1113,8 @@ def test_translate_openai_content_to_anthropic_empty_function_arguments():
id="call_empty_args",
type="function",
function=Function(
name="test_function", arguments="" # empty arguments string
name="test_function",
arguments="", # empty arguments string
),
)
],
@ -1133,9 +1129,7 @@ def test_translate_openai_content_to_anthropic_empty_function_arguments():
assert result[0]["type"] == "tool_use"
assert result[0]["id"] == "call_empty_args"
assert result[0]["name"] == "test_function"
assert (
result[0]["input"] == {}
), "Empty function arguments should result in empty dict"
assert result[0]["input"] == {}, "Empty function arguments should result in empty dict"
assert "provider_specific_fields" not in result[0]
@ -1259,9 +1253,7 @@ def test_translate_openai_response_to_anthropic_text_and_tool_calls():
ChatCompletionAssistantToolCall(
id="call_tool_combo",
type="function",
function=Function(
name="get_weather", arguments='{"location": "Paris"}'
),
function=Function(name="get_weather", arguments='{"location": "Paris"}'),
)
],
),
@ -1271,9 +1263,7 @@ def test_translate_openai_response_to_anthropic_text_and_tool_calls():
)
adapter = LiteLLMAnthropicMessagesAdapter()
anthropic_response = adapter.translate_openai_response_to_anthropic(
response=openai_response
)
anthropic_response = adapter.translate_openai_response_to_anthropic(response=openai_response)
anthropic_content = anthropic_response.get("content")
assert anthropic_content is not None
@ -1314,9 +1304,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_with_partial_json():
(
type_of_content,
content_block_delta,
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(
choices=choices
)
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(choices=choices)
print("Type of content:", type_of_content)
print("Content block delta:", content_block_delta)
@ -1425,9 +1413,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_thinking_delta():
(
type_of_content,
content_block_delta,
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(
choices=choices
)
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(choices=choices)
assert type_of_content == "thinking_delta"
assert content_block_delta["type"] == "thinking_delta"
@ -1470,9 +1456,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_with_thinking():
(
type_of_content,
content_block_delta,
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(
choices=choices
)
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(choices=choices)
assert type_of_content == "signature_delta"
assert content_block_delta["type"] == "signature_delta"
@ -1576,9 +1560,7 @@ def test_translate_anthropic_messages_to_openai_user_message_with_base64_image()
# Check image content
assert result[0]["content"][1]["type"] == "image_url"
assert "image_url" in result[0]["content"][1]
assert result[0]["content"][1]["image_url"]["url"].startswith(
"data:image/png;base64,"
)
assert result[0]["content"][1]["image_url"]["url"].startswith("data:image/png;base64,")
assert (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
in result[0]["content"][1]["image_url"]["url"]
@ -1616,18 +1598,14 @@ def test_translate_anthropic_messages_to_openai_user_message_with_url_image():
# Check image content
assert result[0]["content"][1]["type"] == "image_url"
assert "image_url" in result[0]["content"][1]
assert (
result[0]["content"][1]["image_url"]["url"] == "https://example.com/forest.jpg"
)
assert result[0]["content"][1]["image_url"]["url"] == "https://example.com/forest.jpg"
def test_translate_anthropic_messages_to_openai_tool_result_with_base64_image():
"""Test that base64 images in tool results are correctly translated to OpenAI format."""
anthropic_messages = [
AnthropicMessagesUserMessageParam(
role="user", content=[{"type": "text", "text": "Take a screenshot"}]
),
AnthropicMessagesUserMessageParam(role="user", content=[{"type": "text", "text": "Take a screenshot"}]),
AnthopicMessagesAssistantMessageParam(
role="assistant",
content=[
@ -1779,9 +1757,7 @@ def test_translate_anthropic_messages_to_openai_mixed_content_with_image():
# Check first image (base64)
assert result[0]["content"][1]["type"] == "image_url"
assert result[0]["content"][1]["image_url"]["url"].startswith(
"data:image/png;base64,"
)
assert result[0]["content"][1]["image_url"]["url"].startswith("data:image/png;base64,")
# Check middle text
assert result[0]["content"][2]["type"] == "text"
@ -1789,9 +1765,7 @@ def test_translate_anthropic_messages_to_openai_mixed_content_with_image():
# Check second image (URL)
assert result[0]["content"][3]["type"] == "image_url"
assert (
result[0]["content"][3]["image_url"]["url"] == "https://example.com/image2.jpg"
)
assert result[0]["content"][3]["image_url"]["url"] == "https://example.com/image2.jpg"
# Check final text
assert result[0]["content"][4]["type"] == "text"
@ -1837,10 +1811,7 @@ def test_translate_anthropic_messages_to_openai_tool_use_with_signature():
assert tool_call["id"] == "call_386f67af31f9415781bc35071405"
assert "function" in tool_call
assert "provider_specific_fields" in tool_call["function"]
assert (
tool_call["function"]["provider_specific_fields"]["thought_signature"]
== test_signature
)
assert tool_call["function"]["provider_specific_fields"]["thought_signature"] == test_signature
def test_translate_anthropic_messages_to_openai_tool_result_with_multiple_content_items():
@ -1898,9 +1869,7 @@ def test_translate_anthropic_messages_to_openai_tool_result_with_multiple_conten
result = adapter.translate_anthropic_messages_to_openai(messages=anthropic_messages)
# Count how many tool messages have the same tool_call_id
tool_messages = [
msg for msg in result if isinstance(msg, dict) and msg.get("role") == "tool"
]
tool_messages = [msg for msg in result if isinstance(msg, dict) and msg.get("role") == "tool"]
tool_call_ids = [msg.get("tool_call_id") for msg in tool_messages]
# The critical assertion: each tool_call_id should appear only ONCE
@ -1916,12 +1885,8 @@ def test_translate_anthropic_messages_to_openai_tool_result_with_multiple_conten
# The content should be a list with all items combined
tool_message = tool_messages[0]
assert tool_message["tool_call_id"] == "toolu_016hYHBkTf4JDF3p22UoYk5C"
assert isinstance(
tool_message["content"], list
), "Multiple content items should be combined into a list"
assert (
len(tool_message["content"]) == 3
), f"Expected 3 content items, got {len(tool_message['content'])}"
assert isinstance(tool_message["content"], list), "Multiple content items should be combined into a list"
assert len(tool_message["content"]) == 3, f"Expected 3 content items, got {len(tool_message['content'])}"
# Verify content types
assert tool_message["content"][0]["type"] == "text"
@ -1970,17 +1935,14 @@ def test_translate_anthropic_messages_to_openai_tool_result_single_item_backward
adapter = LiteLLMAnthropicMessagesAdapter()
result = adapter.translate_anthropic_messages_to_openai(messages=anthropic_messages)
tool_messages = [
msg for msg in result if isinstance(msg, dict) and msg.get("role") == "tool"
]
tool_messages = [msg for msg in result if isinstance(msg, dict) and msg.get("role") == "tool"]
assert len(tool_messages) == 1
tool_message = tool_messages[0]
# Single item should be a string for backward compatibility
assert isinstance(tool_message["content"], str), (
f"Single content item should be a string for backward compatibility, "
f"got {type(tool_message['content'])}"
f"Single content item should be a string for backward compatibility, got {type(tool_message['content'])}"
)
assert tool_message["content"] == "72°F and sunny"
@ -2084,15 +2046,12 @@ def test_streaming_chunk_with_text_and_empty_tool_calls_returns_text_delta():
# ============================================================================
# Model constant for cache control tests
CACHE_CONTROL_BEDROCK_CONVERSE_MODEL = (
"bedrock/converse/global.anthropic.claude-opus-4-5-20251101-v1:0"
)
CACHE_CONTROL_BEDROCK_CONVERSE_MODEL = "bedrock/converse/global.anthropic.claude-opus-4-5-20251101-v1:0"
CACHE_CONTROL_NON_ANTHROPIC_MODEL = "gpt-4"
# Bedrock Application Inference Profile ARN: the string contains neither
# "anthropic" nor "claude", so the model can only be recognized via its ARN shape
CACHE_CONTROL_BEDROCK_ARN_MODEL = (
"bedrock/converse/arn:aws:bedrock:us-east-1:123456789012:"
"application-inference-profile/abcdef123456"
"bedrock/converse/arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abcdef123456"
)
@ -2108,9 +2067,7 @@ def test_should_add_cache_control_for_anthropic_model():
"vertex_ai/claude-3-sonnet@20240229",
]:
target = {}
adapter._add_cache_control_if_applicable(
{"cache_control": cache_control}, target, model
)
adapter._add_cache_control_if_applicable({"cache_control": cache_control}, target, model)
assert "cache_control" in target
assert target["cache_control"] == cache_control
@ -2125,9 +2082,7 @@ def test_should_not_add_cache_control_for_non_anthropic_model():
"openai/gpt-4-turbo",
]:
target = {}
adapter._add_cache_control_if_applicable(
{"cache_control": cache_control}, target, model
)
adapter._add_cache_control_if_applicable({"cache_control": cache_control}, target, model)
assert "cache_control" not in target
@ -2142,9 +2097,7 @@ def test_should_add_cache_control_for_gemini_model():
"vertex_ai/gemini-2.5-pro",
]:
target = {}
adapter._add_cache_control_if_applicable(
{"cache_control": cache_control}, target, model
)
adapter._add_cache_control_if_applicable({"cache_control": cache_control}, target, model)
assert target.get("cache_control") == cache_control
@ -2182,9 +2135,7 @@ def test_should_not_add_cache_control_when_none():
{},
]:
target = {}
adapter._add_cache_control_if_applicable(
source, target, CACHE_CONTROL_BEDROCK_CONVERSE_MODEL
)
adapter._add_cache_control_if_applicable(source, target, CACHE_CONTROL_BEDROCK_CONVERSE_MODEL)
assert "cache_control" not in target
@ -2195,9 +2146,7 @@ def test_should_not_add_cache_control_when_model_none():
for model in [None, ""]:
target = {}
adapter._add_cache_control_if_applicable(
{"cache_control": cache_control}, target, model
)
adapter._add_cache_control_if_applicable({"cache_control": cache_control}, target, model)
assert "cache_control" not in target
@ -2303,12 +2252,7 @@ def test_cache_control_fix_does_not_broaden_claude_detection():
make is_anthropic_claude_model treat ARN profiles as Claude, which would route
thinking params through unmodified and break non-Claude Bedrock profiles.
"""
assert (
LiteLLMAnthropicMessagesAdapter.is_anthropic_claude_model(
CACHE_CONTROL_BEDROCK_ARN_MODEL
)
is False
)
assert LiteLLMAnthropicMessagesAdapter.is_anthropic_claude_model(CACHE_CONTROL_BEDROCK_ARN_MODEL) is False
def test_thinking_preserved_for_bedrock_arn_inference_profile():
@ -2906,9 +2850,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_reasoning_content_without
(
type_of_content,
content_block_delta,
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(
choices=choices
)
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(choices=choices)
assert type_of_content == "thinking_delta"
assert content_block_delta["type"] == "thinking_delta"
@ -2940,9 +2882,7 @@ def test_translate_openai_response_to_anthropic_with_reasoning_content_only():
)
adapter = LiteLLMAnthropicMessagesAdapter()
anthropic_response = adapter.translate_openai_response_to_anthropic(
response=openai_response
)
anthropic_response = adapter.translate_openai_response_to_anthropic(response=openai_response)
anthropic_content = anthropic_response.get("content")
assert anthropic_content is not None
@ -2955,9 +2895,7 @@ def test_translate_openai_response_to_anthropic_with_reasoning_content_only():
# Second block should be text
assert anthropic_content[1]["type"] == "text"
assert (
anthropic_content[1]["text"] == 'There are **3** "r"s in the word strawberry.'
)
assert anthropic_content[1]["text"] == 'There are **3** "r"s in the word strawberry.'
assert anthropic_response.get("stop_reason") == "end_turn"
@ -3009,9 +2947,7 @@ def test_truncate_tool_name_deterministic():
def test_truncate_tool_name_avoids_collisions():
"""Similar long names should produce different truncated names."""
name1 = "process_user_data_with_validation_and_error_handling_for_production_environment"
name2 = (
"process_user_data_with_validation_and_error_handling_for_staging_environment"
)
name2 = "process_user_data_with_validation_and_error_handling_for_staging_environment"
result1 = truncate_tool_name(name1)
result2 = truncate_tool_name(name2)
@ -3031,9 +2967,7 @@ def test_create_tool_name_mapping_no_long_names():
def test_create_tool_name_mapping_with_long_names():
"""Mapping should contain entries for truncated names."""
long_name = (
"a_very_long_tool_name_that_exceeds_the_64_character_limit_imposed_by_openai"
)
long_name = "a_very_long_tool_name_that_exceeds_the_64_character_limit_imposed_by_openai"
tools = [
{"name": "short_name"},
{"name": long_name},
@ -3058,9 +2992,7 @@ def test_translate_anthropic_tools_with_long_names():
]
adapter = LiteLLMAnthropicMessagesAdapter()
result, tool_name_mapping = adapter.translate_anthropic_tools_to_openai(
tools=tools, model="gpt-4"
)
result, tool_name_mapping = adapter.translate_anthropic_tools_to_openai(tools=tools, model="gpt-4")
assert len(result) == 1
# The tool name should be truncated
@ -3082,9 +3014,7 @@ def test_translate_anthropic_tools_mixed_names():
]
adapter = LiteLLMAnthropicMessagesAdapter()
result, tool_name_mapping = adapter.translate_anthropic_tools_to_openai(
tools=tools, model="gpt-4"
)
result, tool_name_mapping = adapter.translate_anthropic_tools_to_openai(tools=tools, model="gpt-4")
assert len(result) == 2
# Short name unchanged
@ -3098,9 +3028,7 @@ def test_translate_anthropic_tools_mixed_names():
def test_translate_openai_response_restores_tool_names():
"""Tool names in responses should be restored to original."""
original_name = (
"a_very_long_tool_name_that_needs_truncation_for_openai_api_compatibility"
)
original_name = "a_very_long_tool_name_that_needs_truncation_for_openai_api_compatibility"
truncated_name = truncate_tool_name(original_name)
tool_name_mapping = {truncated_name: original_name}
@ -3132,9 +3060,7 @@ def test_translate_openai_response_restores_tool_names():
)
adapter = LiteLLMAnthropicMessagesAdapter()
result = adapter.translate_openai_response_to_anthropic(
response=response, tool_name_mapping=tool_name_mapping
)
result = adapter.translate_openai_response_to_anthropic(response=response, tool_name_mapping=tool_name_mapping)
# Find the tool_use block in the response
tool_use_blocks = [c for c in result["content"] if c.get("type") == "tool_use"]
@ -3555,9 +3481,7 @@ def test_translate_streaming_openai_response_to_anthropic_cache_tokens_with_appl
assert message_delta["usage"]["output_tokens"] == 50
assert message_delta["usage"]["cache_read_input_tokens"] == 30
assert message_delta["usage"]["cache_creation_input_tokens"] == 20
assert message_delta["context_management"]["applied_edits"][0]["type"] == (
"compact_20260112"
)
assert message_delta["context_management"]["applied_edits"][0]["type"] == ("compact_20260112")
# =====================================================================
@ -3606,9 +3530,7 @@ def test_translate_anthropic_client_web_search_preserves_schema_and_choice(schem
translated, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai(request)
assert "web_search_options" not in translated
assert translated["tools"] == [
{"type": "function", "function": {"name": "web_search", "parameters": schema}}
]
assert translated["tools"] == [{"type": "function", "function": {"name": "web_search", "parameters": schema}}]
assert translated["tool_choice"] == {"type": "function", "function": {"name": "web_search"}}
@ -3753,15 +3675,8 @@ class TestTranslateAnthropicOutputFormatToOpenAI:
assert schema["required"] == ["user"]
assert schema["properties"]["user"]["additionalProperties"] is False
assert schema["properties"]["user"]["required"] == ["name", "address"]
assert (
schema["properties"]["user"]["properties"]["address"][
"additionalProperties"
]
is False
)
assert schema["properties"]["user"]["properties"]["address"]["required"] == [
"city"
]
assert schema["properties"]["user"]["properties"]["address"]["additionalProperties"] is False
assert schema["properties"]["user"]["properties"]["address"]["required"] == ["city"]
def test_array_items_object_adds_additional_properties_false(self):
output_format = {
@ -3836,19 +3751,9 @@ class TestTranslateAnthropicOutputFormatToOpenAI:
assert sorted(schema["required"]) == ["age", "email", "name"]
def test_invalid_output_format_returns_none(self):
assert (
self.adapter.translate_anthropic_output_format_to_openai("invalid") is None
)
assert (
self.adapter.translate_anthropic_output_format_to_openai({"type": "text"})
is None
)
assert (
self.adapter.translate_anthropic_output_format_to_openai(
{"type": "json_schema"}
)
is None
)
assert self.adapter.translate_anthropic_output_format_to_openai("invalid") is None
assert self.adapter.translate_anthropic_output_format_to_openai({"type": "text"}) is None
assert self.adapter.translate_anthropic_output_format_to_openai({"type": "json_schema"}) is None
class TestAnthropicStreamWrapperToolArgs:
@ -4052,9 +3957,7 @@ def test_translate_openai_response_to_anthropic_with_polyfill_compaction_block()
)
response = _make_simple_openai_response(text="Hello after compaction.")
adapter = LiteLLMAnthropicMessagesAdapter()
result = adapter.translate_openai_response_to_anthropic(
response=response, polyfill_result=polyfill
)
result = adapter.translate_openai_response_to_anthropic(response=response, polyfill_result=polyfill)
content = result.get("content")
assert content is not None
@ -4086,9 +3989,7 @@ def test_translate_openai_response_to_anthropic_with_polyfill_iterations_usage()
)
response = _make_simple_openai_response(prompt_tokens=100, completion_tokens=30)
adapter = LiteLLMAnthropicMessagesAdapter()
result = adapter.translate_openai_response_to_anthropic(
response=response, polyfill_result=polyfill
)
result = adapter.translate_openai_response_to_anthropic(response=response, polyfill_result=polyfill)
usage = result.get("usage")
assert usage is not None
@ -4143,13 +4044,9 @@ def test_translate_openai_response_to_anthropic_with_polyfill_both_compaction_an
{"type": "compaction", "input_tokens": 300, "output_tokens": 75},
],
)
response = _make_simple_openai_response(
text="After compaction.", prompt_tokens=120, completion_tokens=40
)
response = _make_simple_openai_response(text="After compaction.", prompt_tokens=120, completion_tokens=40)
adapter = LiteLLMAnthropicMessagesAdapter()
result = adapter.translate_openai_response_to_anthropic(
response=response, polyfill_result=polyfill
)
result = adapter.translate_openai_response_to_anthropic(response=response, polyfill_result=polyfill)
# compaction block must come first
content = result.get("content")
@ -4251,7 +4148,9 @@ def test_translate_anthropic_tools_to_openai_omits_unset_strict():
assert function["parameters"]["required"] == ["query"]
TOOL_RESULT_IMAGE_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
TOOL_RESULT_IMAGE_B64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
)
TOOL_RESULT_IMAGE_URL = "https://example.com/screenshot.png"
@ -4259,8 +4158,7 @@ def _anthropic_tool_use_turn(*tool_use_ids):
return AnthopicMessagesAssistantMessageParam(
role="assistant",
content=[
{"type": "tool_use", "id": tid, "name": "read_file", "input": {"path": "img.png"}}
for tid in tool_use_ids
{"type": "tool_use", "id": tid, "name": "read_file", "input": {"path": "img.png"}} for tid in tool_use_ids
],
)
@ -4380,9 +4278,7 @@ def test_tool_result_parallel_tool_calls_keep_tool_message_adjacency():
result = _run_chat_completions_pipeline(
[
_anthropic_tool_use_turn("toolu_01", "toolu_02"),
_anthropic_tool_result_turn(
{"toolu_01": [_base64_image_block()], "toolu_02": [_url_image_block()]}
),
_anthropic_tool_result_turn({"toolu_01": [_base64_image_block()], "toolu_02": [_url_image_block()]}),
]
)
@ -4573,10 +4469,7 @@ def test_tool_result_text_and_document_reach_bedrock_converse_tool_result():
)
tool_results = [
block["toolResult"]
for message in converse_messages
for block in message["content"]
if "toolResult" in block
block["toolResult"] for message in converse_messages for block in message["content"] if "toolResult" in block
]
assert len(tool_results) == 1
documents = [part["document"] for part in tool_results[0]["content"] if "document" in part]
@ -4635,7 +4528,9 @@ def test_translate_anthropic_to_openai_without_prompt_cache_breakpoint_adds_noth
def test_translate_anthropic_messages_to_openai_carries_midturn_system_prompt_cache_breakpoint():
explicit = {"mode": "explicit"}
result = LiteLLMAnthropicMessagesAdapter().translate_anthropic_messages_to_openai(
messages=[{"role": "system", "content": [{"type": "text", "text": "fix", "prompt_cache_breakpoint": explicit}]}],
messages=[
{"role": "system", "content": [{"type": "text", "text": "fix", "prompt_cache_breakpoint": explicit}]}
],
model="gpt-5.6",
)
assert result == [
@ -5235,3 +5130,58 @@ def test_eager_input_streaming_tool_reaches_bedrock_converse_as_beta():
assert data["additionalModelRequestFields"]["anthropic_beta"] == ["fine-grained-tool-streaming-2025-05-14"]
assert data["toolConfig"]["tools"][0]["toolSpec"]["inputSchema"]["json"] == EAGER_INPUT_SCHEMA
def test_translate_anthropic_messages_preserves_tool_result_is_error():
messages: Final = [
AnthropicMessagesUserMessageParam(
role="user",
content=[
{
"type": "tool_result",
"tool_use_id": "toolu_error",
"is_error": True,
"content": "ToolNotFound: get_weather is not available",
},
],
),
]
result: Final = LiteLLMAnthropicMessagesAdapter().translate_anthropic_messages_to_openai(messages=messages)
tool_messages: Final = [m for m in result if isinstance(m, dict) and m.get("role") == "tool"]
assert len(tool_messages) == 1
# the error bit must survive the first hop so downstream provider
# translations (e.g. Gemini's functionResponse error) can restore it
assert tool_messages[0] == {
"role": "tool",
"tool_call_id": "toolu_error",
"content": "ToolNotFound: get_weather is not available",
"is_error": True,
}
def test_translate_anthropic_messages_omits_is_error_for_plain_tool_results():
messages: Final = [
AnthropicMessagesUserMessageParam(
role="user",
content=[
{
"type": "tool_result",
"tool_use_id": "toolu_ok",
"content": "Sunny, 75°F",
},
],
),
]
result: Final = LiteLLMAnthropicMessagesAdapter().translate_anthropic_messages_to_openai(messages=messages)
tool_messages: Final = [m for m in result if isinstance(m, dict) and m.get("role") == "tool"]
assert len(tool_messages) == 1
# non-error tool results keep their existing shape byte-for-byte
assert tool_messages[0] == {
"role": "tool",
"tool_call_id": "toolu_ok",
"content": "Sunny, 75°F",
}

View file

@ -41,9 +41,7 @@ class TestOpenAIGPTConfig:
be included in supported params so it reaches OpenAI and SpendLogs.
"""
# responses/gpt-4.1-mini should support 'user' just like gpt-4.1-mini
supported_params = self.config.get_supported_openai_params(
"responses/gpt-4.1-mini"
)
supported_params = self.config.get_supported_openai_params("responses/gpt-4.1-mini")
assert "user" in supported_params
supported_params = self.config.get_supported_openai_params("responses/gpt-4o")
@ -60,9 +58,7 @@ class TestOpenAIGPTConfig:
"""
# Both should have the same supported params
regular_params = self.config.get_supported_openai_params("gpt-4.1-mini")
responses_params = self.config.get_supported_openai_params(
"responses/gpt-4.1-mini"
)
responses_params = self.config.get_supported_openai_params("responses/gpt-4.1-mini")
# 'user' should be in both
assert "user" in regular_params
@ -80,9 +76,7 @@ class TestOpenAIGPTConfig:
"tool_choice",
]
supported_params = self.config.get_supported_openai_params(
"responses/gpt-4.1-mini"
)
supported_params = self.config.get_supported_openai_params("responses/gpt-4.1-mini")
for param in base_expected_params:
assert param in supported_params, f"Expected '{param}' in supported params"
@ -192,9 +186,7 @@ class TestGetOptionalParamsIntegration:
reasoning_effort must be forwarded so the server decides support."""
from litellm.llms.openai.openai import OpenAIConfig
supported_params = OpenAIConfig().get_supported_openai_params(
"my-claude-alias"
)
supported_params = OpenAIConfig().get_supported_openai_params("my-claude-alias")
assert "reasoning_effort" in supported_params
def test_reasoning_effort_not_supported_for_known_non_reasoning_models(self):
@ -203,9 +195,7 @@ class TestGetOptionalParamsIntegration:
config = OpenAIConfig()
assert "reasoning_effort" not in config.get_supported_openai_params("gpt-4o")
assert "reasoning_effort" not in config.get_supported_openai_params(
"responses/gpt-4.1-mini"
)
assert "reasoning_effort" not in config.get_supported_openai_params("responses/gpt-4.1-mini")
def test_reasoning_effort_not_inherited_by_openai_compatible_subclasses(self):
"""Providers subclassing either openai config keep their own reasoning_effort gating
@ -218,12 +208,8 @@ class TestGetOptionalParamsIntegration:
class InheritingGPTConfig(OpenAIGPTConfig):
pass
assert "reasoning_effort" not in InheritingDispatcherConfig().get_supported_openai_params(
"some-unknown-model"
)
assert "reasoning_effort" not in InheritingGPTConfig().get_supported_openai_params(
"some-unknown-model"
)
assert "reasoning_effort" not in InheritingDispatcherConfig().get_supported_openai_params("some-unknown-model")
assert "reasoning_effort" not in InheritingGPTConfig().get_supported_openai_params("some-unknown-model")
def test_reasoning_effort_forwarded_in_optional_params_for_unknown_model_alias(
self,
@ -259,9 +245,7 @@ class TestOpenAIChatCompletionStreamingHandler:
Test that chunk_parser preserves the usage field from streaming chunks.
"""
handler = OpenAIChatCompletionStreamingHandler(
streaming_response=None, sync_stream=True
)
handler = OpenAIChatCompletionStreamingHandler(streaming_response=None, sync_stream=True)
usage_chunk = {
"id": "gen-123",
@ -293,9 +277,7 @@ class TestOpenAIChatCompletionStreamingHandler:
"""OpenAI-compatible upstreams serve a service_tier on every streamed
chunk; chunk_parser must keep it on the emitted ModelResponseStream so
disconnect billing and the reassembled response see the served tier."""
handler = OpenAIChatCompletionStreamingHandler(
streaming_response=None, sync_stream=True
)
handler = OpenAIChatCompletionStreamingHandler(streaming_response=None, sync_stream=True)
tiered_chunk = {
"id": "gen-123",
@ -323,9 +305,7 @@ class TestOpenAIChatCompletionStreamingHandler:
ends the stream (https://github.com/BerriAI/litellm/issues/25492)."""
from litellm.llms.openai.common_utils import OpenAIError
handler = OpenAIChatCompletionStreamingHandler(
streaming_response=None, sync_stream=True
)
handler = OpenAIChatCompletionStreamingHandler(streaming_response=None, sync_stream=True)
error_chunk = {
"error": {
@ -348,14 +328,10 @@ class TestOpenAIChatCompletionStreamingHandler:
"invalid_api_key") or none at all; those must map to 500, not crash."""
from litellm.llms.openai.common_utils import OpenAIError
handler = OpenAIChatCompletionStreamingHandler(
streaming_response=None, sync_stream=True
)
handler = OpenAIChatCompletionStreamingHandler(streaming_response=None, sync_stream=True)
with pytest.raises(OpenAIError) as excinfo:
handler.chunk_parser(
{"error": {"message": "engine crashed", "code": "server_error"}}
)
handler.chunk_parser({"error": {"message": "engine crashed", "code": "server_error"}})
assert excinfo.value.status_code == 500
assert "engine crashed" in excinfo.value.message
@ -372,9 +348,7 @@ class TestOpenAIChatCompletionStreamingHandler:
def test_chunk_parser_tolerates_null_error_field(self):
"""A chunk that carries "error": null alongside real data must parse
normally, not raise."""
handler = OpenAIChatCompletionStreamingHandler(
streaming_response=None, sync_stream=True
)
handler = OpenAIChatCompletionStreamingHandler(streaming_response=None, sync_stream=True)
chunk = {
"id": "gen-123",
@ -396,9 +370,7 @@ class TestOpenAIChatCompletionStreamingHandler:
def test_chunk_parser_without_usage(self):
"""Test that chunk_parser works normally for chunks without usage."""
handler = OpenAIChatCompletionStreamingHandler(
streaming_response=None, sync_stream=True
)
handler = OpenAIChatCompletionStreamingHandler(streaming_response=None, sync_stream=True)
chunk = {
"id": "gen-123",
@ -430,9 +402,7 @@ class TestOpenAIChatCompletionStreamingHandler:
Regression test for: Streaming responses with delta.reasoning field
coming back empty when using openai/ or hosted_vllm/ providers.
"""
handler = OpenAIChatCompletionStreamingHandler(
streaming_response=None, sync_stream=True
)
handler = OpenAIChatCompletionStreamingHandler(streaming_response=None, sync_stream=True)
# Simulate a chunk with reasoning field (as returned by GLM-5)
chunk = {
@ -456,9 +426,7 @@ class TestOpenAIChatCompletionStreamingHandler:
parsed_chunk = handler.chunk_parser(chunk)
# Verify that reasoning was mapped to reasoning_content
assert (
parsed_chunk.choices[0].delta.reasoning_content == "The capital of France"
)
assert parsed_chunk.choices[0].delta.reasoning_content == "The capital of France"
# Verify that the original 'reasoning' field was removed
assert not hasattr(parsed_chunk.choices[0].delta, "reasoning")
@ -466,9 +434,7 @@ class TestOpenAIChatCompletionStreamingHandler:
"""
Test that chunks without reasoning field still work correctly.
"""
handler = OpenAIChatCompletionStreamingHandler(
streaming_response=None, sync_stream=True
)
handler = OpenAIChatCompletionStreamingHandler(streaming_response=None, sync_stream=True)
# Simulate a chunk without reasoning field
chunk = {
@ -506,9 +472,7 @@ class TestOpenAIChatCompletionStreamingHandler:
Regression test for: KeyError: 'id' when using MiniMax m2.5 model
"""
handler = OpenAIChatCompletionStreamingHandler(
streaming_response=None, sync_stream=True
)
handler = OpenAIChatCompletionStreamingHandler(streaming_response=None, sync_stream=True)
# Simulate a chunk without 'id' field (as returned by MiniMax)
chunk = {
@ -625,9 +589,7 @@ class TestGPT5ReasoningEffortPreservation:
map_openai_params normalizes all dicts to string. Full dict is restored in main.py
when routing to Responses API (test_gpt_5_4_responses_bridge_preserves_reasoning_summary_dict).
"""
non_default_params = {
"reasoning_effort": {"effort": "high", "summary": "detailed"}
}
non_default_params = {"reasoning_effort": {"effort": "high", "summary": "detailed"}}
optional_params = {}
self.config.map_openai_params(
@ -642,9 +604,7 @@ class TestGPT5ReasoningEffortPreservation:
def test_reasoning_effort_dict_with_generate_summary_normalized(self):
"""Test that reasoning_effort dict with 'generate_summary' is normalized for Chat Completions API."""
non_default_params = {
"reasoning_effort": {"effort": "medium", "generate_summary": "auto"}
}
non_default_params = {"reasoning_effort": {"effort": "medium", "generate_summary": "auto"}}
optional_params = {}
self.config.map_openai_params(
@ -686,9 +646,7 @@ class TestGPT5ReasoningEffortPreservation:
"""
import litellm
non_default_params = {
"reasoning_effort": {"effort": "xhigh", "summary": "detailed"}
}
non_default_params = {"reasoning_effort": {"effort": "xhigh", "summary": "detailed"}}
optional_params = {}
with pytest.raises(litellm.utils.UnsupportedParamsError):
@ -701,9 +659,7 @@ class TestGPT5ReasoningEffortPreservation:
def test_reasoning_effort_dict_xhigh_dropped_when_requested(self):
"""xhigh-dict with drop_params=True: reasoning_effort is dropped."""
non_default_params = {
"reasoning_effort": {"effort": "xhigh", "summary": "detailed"}
}
non_default_params = {"reasoning_effort": {"effort": "xhigh", "summary": "detailed"}}
optional_params = {}
self.config.map_openai_params(
@ -717,9 +673,7 @@ class TestGPT5ReasoningEffortPreservation:
def test_reasoning_effort_dict_none_passed_through_for_gpt5_4_with_tools(self):
"""none-dict with tools on gpt-5.4: reasoning_effort is passed through (routing to Responses at completion level)."""
tools = [
{"type": "function", "function": {"name": "test", "description": "test"}}
]
tools = [{"type": "function", "function": {"name": "test", "description": "test"}}]
non_default_params = {
"reasoning_effort": {"effort": "none", "summary": "detailed"},
"tools": tools,
@ -830,63 +784,34 @@ class TestCacheControlPreservationForCustomEndpoint:
)
def test_predicate_openai_provider_custom_api_base_preserves(self):
assert (
self.config._should_preserve_cache_control_for_endpoint(
"openai", "http://localhost:4000/v1"
)
is True
)
assert self.config._should_preserve_cache_control_for_endpoint("openai", "http://localhost:4000/v1") is True
def test_predicate_real_openai_no_api_base_strips(self):
assert (
self.config._should_preserve_cache_control_for_endpoint("openai", None)
is False
)
assert self.config._should_preserve_cache_control_for_endpoint("openai", None) is False
def test_predicate_explicit_openai_host_strips(self):
assert (
self.config._should_preserve_cache_control_for_endpoint(
"openai", "https://api.openai.com/v1"
)
is False
)
assert self.config._should_preserve_cache_control_for_endpoint("openai", "https://api.openai.com/v1") is False
def test_predicate_non_openai_provider_strips(self):
assert (
self.config._should_preserve_cache_control_for_endpoint(
"deepseek", "https://api.deepseek.com"
)
is False
)
assert self.config._should_preserve_cache_control_for_endpoint("deepseek", "https://api.deepseek.com") is False
def test_predicate_resolves_openai_base_url_env(self, monkeypatch):
monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:4000/v1")
assert (
self.config._should_preserve_cache_control_for_endpoint("openai", None)
is True
)
assert self.config._should_preserve_cache_control_for_endpoint("openai", None) is True
def test_predicate_resolves_openai_api_base_env(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_BASE", "http://localhost:4000/v1")
assert (
self.config._should_preserve_cache_control_for_endpoint("openai", None)
is True
)
assert self.config._should_preserve_cache_control_for_endpoint("openai", None) is True
def test_predicate_lookalike_host_is_not_treated_as_openai(self):
assert (
self.config._should_preserve_cache_control_for_endpoint(
"openai", "https://api.openai.com.evil.example/v1"
)
self.config._should_preserve_cache_control_for_endpoint("openai", "https://api.openai.com.evil.example/v1")
is True
)
def test_predicate_openai_subdomain_strips(self):
assert (
self.config._should_preserve_cache_control_for_endpoint(
"openai", "https://eu.api.openai.com/v1"
)
is False
self.config._should_preserve_cache_control_for_endpoint("openai", "https://eu.api.openai.com/v1") is False
)
def test_transform_request_preserves_for_custom_api_base(self):
@ -909,9 +834,7 @@ class TestCacheControlPreservationForCustomEndpoint:
"cache_control": {"type": "ephemeral"},
}
]
body = self._transform(
"openai", "http://localhost:4000/v1", optional_params={"tools": tools}
)
body = self._transform("openai", "http://localhost:4000/v1", optional_params={"tools": tools})
assert "cache_control" in body["tools"][0]
@pytest.mark.asyncio
@ -1193,9 +1116,7 @@ class TestToolChoiceWithoutToolsDropped:
assert "tool_choice" not in body
def test_drops_named_function_tool_choice_without_tools(self):
body = self._transform(
{"tool_choice": {"type": "function", "function": {"name": "get_weather"}}}
)
body = self._transform({"tool_choice": {"type": "function", "function": {"name": "get_weather"}}})
assert "tool_choice" not in body
def test_drops_tool_choice_but_keeps_empty_tools_array(self):
@ -1369,9 +1290,7 @@ class TestToolMessageImageHoisting:
{
"role": "assistant",
"content": None,
"tool_calls": [
{"id": "call_1", "type": "function", "function": {"name": "read", "arguments": "{}"}}
],
"tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "read", "arguments": "{}"}}],
},
{
"role": "tool",
@ -1588,7 +1507,9 @@ class TestOpenAIPromptCacheBreakpointChatPath:
assert request["messages"][0]["content"] == [
{"type": "text", "text": "sys", "prompt_cache_breakpoint": self.EXPLICIT}
]
assert request["messages"][1]["content"] == [{"type": "text", "text": "hi", "prompt_cache_breakpoint": self.EXPLICIT}]
assert request["messages"][1]["content"] == [
{"type": "text", "text": "hi", "prompt_cache_breakpoint": self.EXPLICIT}
]
assert request["extra_body"] == {"prompt_cache_options": self.EXPLICIT}
assert "prompt_cache_options" not in request
@ -1729,9 +1650,7 @@ class TestToolSchemaCombinatorFlatteningForOpenAI:
"parameters": {"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"]},
},
}
request = self._transform(
self.config, "gpt-4o", {"custom_llm_provider": "openai", "api_base": None}, [tool]
)
request = self._transform(self.config, "gpt-4o", {"custom_llm_provider": "openai", "api_base": None}, [tool])
assert request["tools"][0] is tool
@pytest.mark.asyncio
@ -1793,3 +1712,48 @@ class TestToolSchemaCombinatorFlatteningForOpenAI:
)
assert request["tools"][0] is tool
def test_openai_transform_strips_is_error_carrier_from_tool_messages():
# Anthropic tool_result.is_error rides on the intermediate tool message as
# a litellm-internal carrier (see the anthropic adapter); real OpenAI must
# not receive it, mirroring how cache_control is stripped for openai.com
# hosts.
messages: Final = [
{"role": "user", "content": "check the weather"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "get_weather", "arguments": "{}"},
}
],
},
{
"role": "tool",
"tool_call_id": "call_1",
"content": "ToolNotFound: get_weather is not available",
"is_error": True,
"cache_control": {"type": "ephemeral"},
},
]
transformed: Final = OpenAIGPTConfig().transform_request(
model="gpt-4o",
messages=messages,
optional_params={},
litellm_params={},
headers={},
)
tool_messages: Final = [m for m in transformed["messages"] if m.get("role") == "tool"]
assert len(tool_messages) == 1
# whole-value assert: the internal carriers are gone and nothing else moved
assert tool_messages[0] == {
"role": "tool",
"tool_call_id": "call_1",
"content": "ToolNotFound: get_weather is not available",
}

View file

@ -28922,6 +28922,8 @@ export interface components {
ChatCompletionToolMessage: {
/** Content */
content: string | (components["schemas"]["ChatCompletionTextObject"] | components["schemas"]["ChatCompletionImageObject"] | components["schemas"]["ChatCompletionToolReferenceObject"])[];
/** Is Error */
is_error?: boolean;
/**
* Role
* @constant