From 6bb9d3e18a714c25416b2553ea9aa134096233a1 Mon Sep 17 00:00:00 2001 From: "Eugenio \"Jay\" Zuccarelli" <11176606+jayzuccarelli@users.noreply.github.com> Date: Wed, 7 Oct 2026 03:00:26 +0000 Subject: [PATCH 1/4] fix(anthropic): keep tool_result is_error when translating for gemini --- .../prompt_templates/factory.py | 4 +-- .../pass_through/adapters/transformation.py | 11 ++++++ litellm/types/llms/openai.py | 1 + ...llm_core_utils_prompt_templates_factory.py | 30 ++++++++++++++++ ...al_pass_through_adapters_transformation.py | 34 +++++++++++++++++++ 5 files changed, 77 insertions(+), 3 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index f1eb9a0aef4..1904311ba29 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1522,11 +1522,9 @@ def convert_to_gemini_tool_call_result( # Not valid JSON, wrap in content field response_data = {"content": content_str} - # We can't determine from openai message format whether it's a successful or - # error call result so default to the successful result template _function_response: Final = VertexFunctionResponse( name=name, - response=response_data, + response={"error": content_str} if message.get("is_error") is True else response_data, ) if gemini_call_id: _function_response["id"] = gemini_call_id diff --git a/litellm/llms/anthropic/pass_through/adapters/transformation.py b/litellm/llms/anthropic/pass_through/adapters/transformation.py index 88b99eb733e..ccaa8bfc19c 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: content=self._tool_result_content(content.get("content")), ) self._add_cache_control_if_applicable(content, tool_result, model) + if content.get("is_error") is True and self.target_consumes_tool_error( + model, custom_llm_provider + ): + tool_result["is_error"] = True tool_message_list.append(tool_result) if len(tool_message_list) > 0: @@ -699,6 +703,13 @@ class LiteLLMAnthropicMessagesAdapter: def target_consumes_cache_control(cls, model: str) -> bool: return cls.is_anthropic_claude_model(model) or cls.is_bedrock_arn_model(model) or "gemini" in model.lower() + @staticmethod + def target_consumes_tool_error(model: str | None, custom_llm_provider: str | None) -> bool: + if not model or "gemini" not in model.lower(): + return False + provider: Final = custom_llm_provider or model.split("/", 1)[0] + return provider in ("gemini", "vertex_ai", "vertex_ai_beta") + @staticmethod def translate_thinking_for_model( thinking: AnthropicThinkingParam, diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index f241c4a7a27..5cab1093af4 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -875,6 +875,7 @@ class ChatCompletionToolMessage(TypedDict): role: Literal["tool"] content: str | Iterable[ToolMessageContentPart] # writable-ok: Pydantic warns on ReadOnly TypedDict fields tool_call_id: str + is_error: NotRequired[bool] # writable-ok: Pydantic warns on ReadOnly TypedDict fields class ChatCompletionFunctionMessage(TypedDict): diff --git a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index d91bc3b6201..72743254ed9 100644 --- a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -986,6 +986,36 @@ def test_convert_gemini_tool_call_result_with_data_url_extra_params(): ) +def test_convert_gemini_tool_call_result_error_goes_to_response_error(): + """https://github.com/BerriAI/litellm/issues/44979""" + last_message_with_tool_calls = { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "index": 0, + "function": {"name": "deploy", "arguments": "{}"}, + } + ], + } + + failed = convert_to_gemini_tool_call_result( + message=ChatCompletionToolMessage( + role="tool", tool_call_id="call_1", content="exit code 1: migration failed", is_error=True + ), + last_message_with_tool_calls=last_message_with_tool_calls, + ) + succeeded = convert_to_gemini_tool_call_result( + message=ChatCompletionToolMessage(role="tool", tool_call_id="call_1", content="deployed"), + last_message_with_tool_calls=last_message_with_tool_calls, + ) + + assert failed["function_response"]["response"] == {"error": "exit code 1: migration failed"} + assert succeeded["function_response"]["response"] == {"content": "deployed"} + + def test_bedrock_tools_unpack_defs(): """ Test that the unpack_defs method handles nested $ref inside anyOf items correctly 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 06aaa4e61fb..1c9f5da5dba 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 @@ -2696,6 +2696,40 @@ def test_cache_control_not_preserved_in_tool_result_for_non_claude(): assert "cache_control" not in tool_message +@pytest.mark.parametrize( + "model,custom_llm_provider,keeps_is_error", + [ + ("gemini/gemini-3.5-flash-lite", None, True), + ("gemini-3.5-flash-lite", "vertex_ai", True), + ("gpt-4", "openai", False), + ("openrouter/google/gemini-3.5-flash-lite", None, False), + ], +) +def test_tool_result_is_error_kept_only_for_gemini_targets(model, custom_llm_provider, keeps_is_error): + """https://github.com/BerriAI/litellm/issues/44979""" + anthropic_messages = [ + AnthropicMessagesUserMessageParam( + role="user", + content=[ + { + "type": "tool_result", + "tool_use_id": "toolu_1", + "content": "exit code 1: migration failed", + "is_error": True, + } + ], + ) + ] + + result = LiteLLMAnthropicMessagesAdapter().translate_anthropic_messages_to_openai( + messages=anthropic_messages, model=model, custom_llm_provider=custom_llm_provider + ) + + tool_message = next(msg for msg in result if msg.get("role") == "tool") + assert tool_message["content"] == "exit code 1: migration failed" + assert ("is_error" in tool_message) is keeps_is_error + + def test_cache_control_preserved_in_assistant_text_for_claude(): """Cache control should be preserved in assistant text blocks for Claude models.""" anthropic_messages = [ From 591bb9787bf0698d440fdf0511bea152cbf7f04e Mon Sep 17 00:00:00 2001 From: "Eugenio \"Jay\" Zuccarelli" <11176606+jayzuccarelli@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:50:44 +0000 Subject: [PATCH 2/4] chore: regenerate lazy OpenAPI snapshot and dashboard API types for is_error --- litellm/proxy/_lazy_openapi_snapshot.json | 4 ++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index d864ec04a8d..c6a4236a7ba 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -43711,6 +43711,10 @@ ], "title": "Content" }, + "is_error": { + "title": "Is Error", + "type": "boolean" + }, "role": { "const": "tool", "title": "Role", diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index cbaea37d409..f80ba9686bc 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -28926,6 +28926,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 10942c3cfea9a9ed6cd24ec0083c45d5d544217b Mon Sep 17 00:00:00 2001 From: "Eugenio \"Jay\" Zuccarelli" <11176606+jayzuccarelli@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:08:51 +0000 Subject: [PATCH 3/4] fix(anthropic): decide Gemini tool-error support on the provider, not the model name Aliased models routed to gemini (e.g. "prod-model" with custom_llm_provider="gemini") dropped is_error because the check required "gemini" in the model string. The test now asserts is_error is True, not just present. --- .../llms/anthropic/pass_through/adapters/transformation.py | 6 +++--- ...pic_experimental_pass_through_adapters_transformation.py | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/litellm/llms/anthropic/pass_through/adapters/transformation.py b/litellm/llms/anthropic/pass_through/adapters/transformation.py index ccaa8bfc19c..b725725d01e 100644 --- a/litellm/llms/anthropic/pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/pass_through/adapters/transformation.py @@ -705,9 +705,9 @@ class LiteLLMAnthropicMessagesAdapter: @staticmethod def target_consumes_tool_error(model: str | None, custom_llm_provider: str | None) -> bool: - if not model or "gemini" not in model.lower(): - return False - provider: Final = custom_llm_provider or model.split("/", 1)[0] + # Decide on the provider alone: aliased model names ("prod-model" routed to + # gemini) carry no "gemini" substring. + provider: Final = custom_llm_provider or (model.split("/", 1)[0] if model and "/" in model else None) return provider in ("gemini", "vertex_ai", "vertex_ai_beta") @staticmethod 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 1c9f5da5dba..bc1766ac6a0 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 @@ -2703,6 +2703,7 @@ def test_cache_control_not_preserved_in_tool_result_for_non_claude(): ("gemini-3.5-flash-lite", "vertex_ai", True), ("gpt-4", "openai", False), ("openrouter/google/gemini-3.5-flash-lite", None, False), + ("prod-model", "gemini", True), ], ) def test_tool_result_is_error_kept_only_for_gemini_targets(model, custom_llm_provider, keeps_is_error): @@ -2727,7 +2728,7 @@ def test_tool_result_is_error_kept_only_for_gemini_targets(model, custom_llm_pro tool_message = next(msg for msg in result if msg.get("role") == "tool") assert tool_message["content"] == "exit code 1: migration failed" - assert ("is_error" in tool_message) is keeps_is_error + assert tool_message.get("is_error") is (True if keeps_is_error else None) def test_cache_control_preserved_in_assistant_text_for_claude(): From ee24e7bd8fb3a698b2650ab604a0909fbb5e1f3d Mon Sep 17 00:00:00 2001 From: "Eugenio \"Jay\" Zuccarelli" <11176606+jayzuccarelli@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:26:38 +0000 Subject: [PATCH 4/4] fix(anthropic): type tool_result block for is_error lookup Removes the reportUnknownMemberType added by content.get("is_error") on the untyped client payload; same cast-ok pattern as _tool_result_part. Co-Authored-By: Claude Opus 5.5 --- litellm/llms/anthropic/pass_through/adapters/transformation.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/litellm/llms/anthropic/pass_through/adapters/transformation.py b/litellm/llms/anthropic/pass_through/adapters/transformation.py index de504419d8a..d215657b65d 100644 --- a/litellm/llms/anthropic/pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/pass_through/adapters/transformation.py @@ -535,7 +535,8 @@ class LiteLLMAnthropicMessagesAdapter: content=self._tool_result_content(content.get("content")), ) self._add_cache_control_if_applicable(content, tool_result, model) - if content.get("is_error") is True and self.target_consumes_tool_error( + tool_result_block = cast(Mapping[str, object], content) # cast-ok: untrusted client payload + if tool_result_block.get("is_error") is True and self.target_consumes_tool_error( model, custom_llm_provider ): tool_result["is_error"] = True