diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 4c3907dc52f..b9516ef2650 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1750,11 +1750,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/messages/adapters/transformation.py b/litellm/llms/anthropic/messages/adapters/transformation.py index 4b1b5a8a11a..594eef9106b 100644 --- a/litellm/llms/anthropic/messages/adapters/transformation.py +++ b/litellm/llms/anthropic/messages/adapters/transformation.py @@ -562,6 +562,11 @@ class LiteLLMAnthropicMessagesAdapter: content=self._tool_result_content(content.get("content")), ) self._add_cache_control_if_applicable(content, tool_result, model) + 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 tool_message_list.append(tool_result) if len(tool_message_list) > 0: @@ -737,6 +742,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: + # 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 def translate_thinking_for_model( thinking: AnthropicThinkingParam, diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 5cc1c53d396..0b0a4c8905b 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -49412,6 +49412,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 c9739eee91d..378899b667d 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -957,6 +957,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 d65f094971a..a1df0ae4035 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 @@ -2279,6 +2279,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 _gemini_tool_result_fixture( content: str, ) -> tuple[ChatCompletionToolMessage, dict]: diff --git a/tests/unit/llms/anthropic/messages/adapters/test_anthropic_messages_adapters_transformation.py b/tests/unit/llms/anthropic/messages/adapters/test_anthropic_messages_adapters_transformation.py index cc366dcde4c..e4e4fc9f6fb 100644 --- a/tests/unit/llms/anthropic/messages/adapters/test_anthropic_messages_adapters_transformation.py +++ b/tests/unit/llms/anthropic/messages/adapters/test_anthropic_messages_adapters_transformation.py @@ -2736,6 +2736,41 @@ 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), + ("prod-model", "gemini", True), + ], +) +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 tool_message.get("is_error") is (True if keeps_is_error else None) + + def test_cache_control_preserved_in_assistant_text_for_claude(): """Cache control should be preserved in assistant text blocks for Claude models.""" anthropic_messages = [ diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index e2e1210bc2a..cd97c2ae76d 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -29462,6 +29462,8 @@ export interface components { ChatCompletionToolMessage: { /** Content */ content: string | (components["schemas"]["ChatCompletionTextObject"] | components["schemas"]["ChatCompletionImageObject"] | components["schemas"]["ChatCompletionAudioObject"] | components["schemas"]["ChatCompletionFileObject"] | components["schemas"]["ChatCompletionToolReferenceObject"])[]; + /** Is Error */ + is_error?: boolean; /** * Role * @constant