diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index b704dfa2675..eca9ce38823 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -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 diff --git a/litellm/llms/anthropic/pass_through/adapters/transformation.py b/litellm/llms/anthropic/pass_through/adapters/transformation.py index 0d3e48c49fd..9ef9c8d0609 100644 --- a/litellm/llms/anthropic/pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/pass_through/adapters/transformation.py @@ -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) diff --git a/litellm/llms/openai/chat/gpt_transformation.py b/litellm/llms/openai/chat/gpt_transformation.py index 4d7ef833181..ca2b4af4f30 100644 --- a/litellm/llms/openai/chat/gpt_transformation.py +++ b/litellm/llms/openai/chat/gpt_transformation.py @@ -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): diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 7198d74909c..8eb64d56d9c 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -47066,6 +47066,10 @@ ], "title": "Content" }, + "is_error": { + "title": "Is Error", + "type": "boolean" + }, "role": { "const": "tool", "title": "Role", diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 8cc9e912e80..8ec8808320d 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -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): diff --git a/tests/unit/llms/anthropic/pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/unit/llms/anthropic/pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index 9e0bd5c60b8..065afdf9de9 100644 --- a/tests/unit/llms/anthropic/pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/unit/llms/anthropic/pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -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", + } diff --git a/tests/unit/llms/openai/chat/test_openai_gpt_transformation.py b/tests/unit/llms/openai/chat/test_openai_gpt_transformation.py index 76faf338307..fa969fd1c05 100644 --- a/tests/unit/llms/openai/chat/test_openai_gpt_transformation.py +++ b/tests/unit/llms/openai/chat/test_openai_gpt_transformation.py @@ -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", + } diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index c33ef86af72..9ac1ba90eef 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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