From c6d6498196bb49e7798165e0dd676df8c5520431 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Wed, 7 Oct 2026 22:24:17 +0800 Subject: [PATCH 1/7] fix(anthropic adapter): carry tool_result.is_error through translation translate_anthropic_messages_to_openai dropped the is_error flag from Anthropic tool_result blocks: the translated tool message carried only role, tool_call_id and content. Every downstream provider translation works off that OpenAI shape, so nothing (e.g. Gemini's functionResponse error mapping) could ever see that the tool call failed (#44979). Carry the flag as an optional is_error field on the intermediate tool message - the same non-wire-key convention the adapter already uses for cache_control - and only set it when Anthropic reported an error, so non-error tool results keep their existing shape byte-for-byte. Fixes #44979 Generated-by: GLM-5.3-Flash (ZCode) --- .../pass_through/adapters/transformation.py | 4 + litellm/types/llms/openai.py | 4 + ...al_pass_through_adapters_transformation.py | 296 +++++++----------- 3 files changed, 127 insertions(+), 177 deletions(-) diff --git a/litellm/llms/anthropic/pass_through/adapters/transformation.py b/litellm/llms/anthropic/pass_through/adapters/transformation.py index 2ee23852894..4952b54b8e7 100644 --- a/litellm/llms/anthropic/pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/pass_through/adapters/transformation.py @@ -530,6 +530,10 @@ class LiteLLMAnthropicMessagesAdapter: tool_call_id=content.get("tool_use_id", ""), content=self._tool_result_content(content.get("content")), ) + if content.get("is_error") is True: + # carry the error bit on the intermediate tool + # message; downstream translations restore it + tool_result["is_error"] = True self._add_cache_control_if_applicable(content, tool_result, model) tool_message_list.append(tool_result) diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 26f65fee4fe..b660e1521a6 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -915,6 +915,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] 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 2eee8549c0b..609db656298 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 @@ -7,7 +7,6 @@ import pytest import litellm - from litellm.litellm_core_utils.prompt_templates.common_utils import ( TOOL_RESULT_IMAGE_PLACEHOLDER, encrypted_reasoning_signature, @@ -60,10 +59,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}) @@ -172,9 +178,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, ) @@ -218,9 +222,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, ) @@ -457,10 +459,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" @@ -600,9 +599,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( @@ -1068,9 +1065,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 @@ -1115,7 +1110,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 ), ) ], @@ -1130,9 +1126,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] @@ -1256,9 +1250,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"}'), ) ], ), @@ -1268,9 +1260,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 @@ -1311,9 +1301,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) @@ -1422,9 +1410,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" @@ -1467,9 +1453,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" @@ -1573,9 +1557,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"] @@ -1613,18 +1595,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=[ @@ -1776,9 +1754,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" @@ -1786,9 +1762,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" @@ -1834,10 +1808,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(): @@ -1895,9 +1866,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 @@ -1913,12 +1882,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" @@ -1967,17 +1932,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" @@ -2081,15 +2043,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" ) @@ -2105,9 +2064,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 @@ -2122,9 +2079,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 @@ -2139,9 +2094,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 @@ -2179,9 +2132,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 @@ -2192,9 +2143,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 @@ -2300,12 +2249,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(): @@ -2903,9 +2847,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" @@ -2937,9 +2879,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 @@ -2952,9 +2892,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" @@ -3006,9 +2944,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) @@ -3028,9 +2964,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}, @@ -3055,9 +2989,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 @@ -3079,9 +3011,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 @@ -3095,9 +3025,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} @@ -3129,9 +3057,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"] @@ -3552,9 +3478,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") # ===================================================================== @@ -3603,9 +3527,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"}} @@ -3750,15 +3672,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 = { @@ -3833,19 +3748,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: @@ -4049,9 +3954,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 @@ -4083,9 +3986,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 @@ -4140,13 +4041,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") @@ -4248,7 +4145,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" @@ -4256,8 +4155,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 ], ) @@ -4377,9 +4275,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()]}), ] ) @@ -4481,10 +4377,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] @@ -4543,7 +4436,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 == [ @@ -5143,3 +5038,50 @@ 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 = [ + AnthropicMessagesUserMessageParam( + role="user", + content=[ + { + "type": "tool_result", + "tool_use_id": "toolu_error", + "is_error": True, + "content": "ToolNotFound: get_weather is not available", + }, + ], + ), + ] + + result = LiteLLMAnthropicMessagesAdapter().translate_anthropic_messages_to_openai(messages=messages) + + tool_messages = [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]["is_error"] is True + assert tool_messages[0]["tool_call_id"] == "toolu_error" + + +def test_translate_anthropic_messages_omits_is_error_for_plain_tool_results(): + messages = [ + AnthropicMessagesUserMessageParam( + role="user", + content=[ + { + "type": "tool_result", + "tool_use_id": "toolu_ok", + "content": "Sunny, 75°F", + }, + ], + ), + ] + + result = LiteLLMAnthropicMessagesAdapter().translate_anthropic_messages_to_openai(messages=messages) + + tool_messages = [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 "is_error" not in tool_messages[0] From b83aaa095f9f0345e8e00f80fda9e3e09124c947 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Thu, 8 Oct 2026 09:00:45 +0800 Subject: [PATCH 2/7] fix(openai): strip is_error carrier from outbound tool messages The anthropic adapter carries tool_result.is_error on the intermediate tool message so error-aware translations can restore it (see #44979). Real OpenAI has no such field and unknown message keys should not reach the wire - strip it in remove_cache_control_flag_from_messages_and_tools the same way cache_control is stripped for openai.com hosts. Generated-by: GLM-5.3-Flash (ZCode) --- .../llms/openai/chat/gpt_transformation.py | 8 + .../chat/test_openai_gpt_transformation.py | 199 +++++++----------- 2 files changed, 88 insertions(+), 119 deletions(-) diff --git a/litellm/llms/openai/chat/gpt_transformation.py b/litellm/llms/openai/chat/gpt_transformation.py index 936ec67ae92..e2c3b8794d3 100644 --- a/litellm/llms/openai/chat/gpt_transformation.py +++ b/litellm/llms/openai/chat/gpt_transformation.py @@ -414,6 +414,14 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig): AllMessageValues, filter_value_from_dict(message, "cache_control"), ) + if message.get("role") == "tool": + # 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). + messages[i] = cast( + AllMessageValues, + filter_value_from_dict(message, "is_error"), + ) if tools is not None: for i, tool in enumerate(tools): tools[i] = cast( 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 cdb1b068987..44c66df026c 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 @@ -994,9 +917,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): @@ -1170,9 +1091,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", @@ -1389,7 +1308,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 @@ -1493,9 +1414,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 @@ -1557,3 +1476,45 @@ 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 = [ + {"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 = OpenAIGPTConfig().transform_request( + model="gpt-4o", + messages=messages, + optional_params={}, + litellm_params={}, + headers={}, + ) + + tool_messages = [m for m in transformed["messages"] if m.get("role") == "tool"] + assert len(tool_messages) == 1 + assert "is_error" not in tool_messages[0] + assert "cache_control" not in tool_messages[0] + assert tool_messages[0]["content"] == "ToolNotFound: get_weather is not available" From 579506b808edca13e6f1070df3b8db294714350a Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Thu, 8 Oct 2026 15:28:17 +0800 Subject: [PATCH 3/7] refactor: address review comments on the is_error carrier - construct the translated tool message with is_error included instead of mutating the dict after construction - mark the is_error TypedDict field writable-ok, matching the sibling fields in ChatCompletionToolMessage - Final-annotate the new test variables and pin the whole translated tool message in the new tests (whole-value assertions) Generated-by: GLM-5.3-Flash (ZCode) --- .../pass_through/adapters/transformation.py | 5 +--- litellm/types/llms/openai.py | 2 +- ...al_pass_through_adapters_transformation.py | 26 ++++++++++++------- .../chat/test_openai_gpt_transformation.py | 15 ++++++----- 4 files changed, 28 insertions(+), 20 deletions(-) diff --git a/litellm/llms/anthropic/pass_through/adapters/transformation.py b/litellm/llms/anthropic/pass_through/adapters/transformation.py index 4952b54b8e7..ffdb424a1f3 100644 --- a/litellm/llms/anthropic/pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/pass_through/adapters/transformation.py @@ -529,11 +529,8 @@ class LiteLLMAnthropicMessagesAdapter: role="tool", tool_call_id=content.get("tool_use_id", ""), content=self._tool_result_content(content.get("content")), + **({"is_error": True} if content.get("is_error") is True else {}), ) - if content.get("is_error") is True: - # carry the error bit on the intermediate tool - # message; downstream translations restore it - tool_result["is_error"] = True self._add_cache_control_if_applicable(content, tool_result, model) tool_message_list.append(tool_result) diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index b660e1521a6..05b6ec72988 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -918,7 +918,7 @@ class ChatCompletionToolMessage(TypedDict): # 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] + 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 609db656298..111018732fb 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 @@ -5041,7 +5041,7 @@ def test_eager_input_streaming_tool_reaches_bedrock_converse_as_beta(): def test_translate_anthropic_messages_preserves_tool_result_is_error(): - messages = [ + messages: Final = [ AnthropicMessagesUserMessageParam( role="user", content=[ @@ -5055,18 +5055,22 @@ def test_translate_anthropic_messages_preserves_tool_result_is_error(): ), ] - result = LiteLLMAnthropicMessagesAdapter().translate_anthropic_messages_to_openai(messages=messages) + result: Final = LiteLLMAnthropicMessagesAdapter().translate_anthropic_messages_to_openai(messages=messages) - tool_messages = [m for m in result if isinstance(m, dict) and m.get("role") == "tool"] + 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]["is_error"] is True - assert tool_messages[0]["tool_call_id"] == "toolu_error" + 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 = [ + messages: Final = [ AnthropicMessagesUserMessageParam( role="user", content=[ @@ -5079,9 +5083,13 @@ def test_translate_anthropic_messages_omits_is_error_for_plain_tool_results(): ), ] - result = LiteLLMAnthropicMessagesAdapter().translate_anthropic_messages_to_openai(messages=messages) + result: Final = LiteLLMAnthropicMessagesAdapter().translate_anthropic_messages_to_openai(messages=messages) - tool_messages = [m for m in result if isinstance(m, dict) and m.get("role") == "tool"] + 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 "is_error" not in tool_messages[0] + 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 44c66df026c..e18383ca9e7 100644 --- a/tests/unit/llms/openai/chat/test_openai_gpt_transformation.py +++ b/tests/unit/llms/openai/chat/test_openai_gpt_transformation.py @@ -1483,7 +1483,7 @@ def test_openai_transform_strips_is_error_carrier_from_tool_messages(): # 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 = [ + messages: Final = [ {"role": "user", "content": "check the weather"}, { "role": "assistant", @@ -1505,7 +1505,7 @@ def test_openai_transform_strips_is_error_carrier_from_tool_messages(): }, ] - transformed = OpenAIGPTConfig().transform_request( + transformed: Final = OpenAIGPTConfig().transform_request( model="gpt-4o", messages=messages, optional_params={}, @@ -1513,8 +1513,11 @@ def test_openai_transform_strips_is_error_carrier_from_tool_messages(): headers={}, ) - tool_messages = [m for m in transformed["messages"] if m.get("role") == "tool"] + tool_messages: Final = [m for m in transformed["messages"] if m.get("role") == "tool"] assert len(tool_messages) == 1 - assert "is_error" not in tool_messages[0] - assert "cache_control" not in tool_messages[0] - assert tool_messages[0]["content"] == "ToolNotFound: get_weather is not available" + # 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", + } From c0119578bca18ac5b9437dab258cd128bbb05598 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Thu, 8 Oct 2026 15:35:36 +0800 Subject: [PATCH 4/7] chore(proxy): regenerate lazy OpenAPI snapshot for is_error field The ChatCompletionToolMessage schema gained the litellm-internal is_error carrier; regenerate the snapshot so Check UI API Types stays green. Generated-by: GLM-5.3-Flash (ZCode) --- litellm/proxy/_lazy_openapi_snapshot.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index b04a958cd03..d5ba37f0409 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -45833,6 +45833,10 @@ ], "title": "Content" }, + "is_error": { + "title": "Is Error", + "type": "boolean" + }, "role": { "const": "tool", "title": "Role", From eec60ec639507b914331143983d6a847b52a6884 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Thu, 8 Oct 2026 23:54:53 +0800 Subject: [PATCH 5/7] fix(openai): strip is_error in place, avoiding new LIT006/LIT011 gate sites The tool-message is_error strip used `messages[i] = cast(AllMessageValues, filter_value_from_dict(message, "is_error"))`, which added one LIT006 (cast) and one LIT011 (parameter item assignment) over the type-discipline merge-base ceiling and failed the lint job on CI. filter_value_from_dict mutates the message dict in place and returns the same object, so the cast and write-back are runtime no-ops: call the filter directly and the list slot keeps referencing the updated message. Behavior is unchanged; type_discipline_gate and ruff_strict_gate pass against the merge base. Generated-by: GLM-5.3-Flash (ZCode) --- litellm/llms/openai/chat/gpt_transformation.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/litellm/llms/openai/chat/gpt_transformation.py b/litellm/llms/openai/chat/gpt_transformation.py index e2c3b8794d3..5692f385ebd 100644 --- a/litellm/llms/openai/chat/gpt_transformation.py +++ b/litellm/llms/openai/chat/gpt_transformation.py @@ -418,10 +418,9 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig): # 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). - messages[i] = cast( - AllMessageValues, - filter_value_from_dict(message, "is_error"), - ) + # filter_value_from_dict mutates the message dict in place, so + # the list slot keeps referencing the updated message. + filter_value_from_dict(message, "is_error") if tools is not None: for i, tool in enumerate(tools): tools[i] = cast( From 016348c6c200032636714000b017ef5a8e73e7ce Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Thu, 8 Oct 2026 23:54:56 +0800 Subject: [PATCH 6/7] chore(ui): regenerate schema.d.ts for tool message is_error field The lazy OpenAPI snapshot was regenerated in 229dd3845e but the dashboard API types were not, so "Verify schema.d.ts matches the proxy OpenAPI spec" failed: the generated types were missing the new optional `is_error` field. Regenerated with openapi-typescript 7.13.0 (locked version) from the proxy spec; the diff is exactly the two expected lines. Generated-by: GLM-5.3-Flash (ZCode) --- ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index ada0bb2b8a2..fdba0ad8668 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -28636,6 +28636,8 @@ export interface components { ChatCompletionToolMessage: { /** Content */ content: string | (components["schemas"]["ChatCompletionTextObject"] | components["schemas"]["ChatCompletionImageObject"] | components["schemas"]["ChatCompletionToolReferenceObject"])[]; + /** Is Error */ + is_error?: boolean; /** * Role * @constant From 328866c020879b034e40d4e71f18725888e4c96d Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Fri, 9 Oct 2026 12:34:55 +0800 Subject: [PATCH 7/7] fix(types): strip is_error in the existing cache_control pass for the pyright ceiling The previous in-place strip left two basedpyright breaches over the merge-base ceiling (base 8683f1418ee5): the bare second `filter_value_from_dict(message, "is_error")` call added a reportArgumentType (AllMessageValues is not assignable to the bare-dict parameter), and `message.get("role")` on the AllMessageValues union added two reportUnknownMemberType. Fold is_error into the existing cache_control pass via a new `*extra_keys` parameter on filter_value_from_dict (no new call site, so no new diagnostic); the unconditional strip is behavior-equivalent because is_error only ever exists as the litellm-internal carrier on tool messages, which real OpenAI would reject as an unknown field anyway. The adapter side reads is_error through a membership guard instead of a second unknown-typed `.get`. Verification: type_check_gate OK (every rule within ceiling, base 8683f1418ee5), type_discipline_gate OK, targeted per-file basedpyright delta vs merge-base zero on all four touched files, adapter + gpt transformation test suites 316 passed. Generated-by: GLM-5.3-Flash (ZCode) --- .../prompt_templates/common_utils.py | 10 +++++----- .../pass_through/adapters/transformation.py | 2 +- litellm/llms/openai/chat/gpt_transformation.py | 13 +++++-------- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 0243141eda2..343f245c2ae 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -1807,11 +1807,11 @@ def strip_litellm_internal_message_fields(message: AllMessageValues) -> AllMessa ) -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 @@ -1822,14 +1822,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 ffdb424a1f3..b4a78993159 100644 --- a/litellm/llms/anthropic/pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/pass_through/adapters/transformation.py @@ -529,7 +529,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 content.get("is_error") is True else {}), + **({"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 5692f385ebd..24323fc7b8a 100644 --- a/litellm/llms/openai/chat/gpt_transformation.py +++ b/litellm/llms/openai/chat/gpt_transformation.py @@ -410,17 +410,14 @@ 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 message.get("role") == "tool": - # 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). - # filter_value_from_dict mutates the message dict in place, so - # the list slot keeps referencing the updated message. - filter_value_from_dict(message, "is_error") if tools is not None: for i, tool in enumerate(tools): tools[i] = cast(