From 7c2c18544449138134c9f54314af747bba6075e1 Mon Sep 17 00:00:00 2001 From: Nafay Rizwani <80161736+Nafay-0@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:23:39 +0000 Subject: [PATCH] fix(guardrails): preserve tool_result.is_error and system output_config in Anthropic guardrail write-back Keep those fields through the Anthropic guardrail OpenAI round-trip so tool errors and per-turn effort settings still reach the model. --- .../prompt_templates/factory.py | 2 + .../chat/guardrail_translation/handler.py | 14 +- .../messages/adapters/transformation.py | 12 +- ..._anthropic_guardrail_field_preservation.py | 146 ++++++++++++++++++ 4 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 tests/unit/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_field_preservation.py diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index ae36484b294..2d5d457a38f 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1946,6 +1946,8 @@ def convert_to_anthropic_tool_result( tool_use_id=sanitized_tool_use_id, content=anthropic_content, ) + if "is_error" in tool_message: + anthropic_tool_result["is_error"] = tool_message["is_error"] if message["role"] == "function": function_message: Final[ChatCompletionFunctionMessage] = message diff --git a/litellm/llms/anthropic/chat/guardrail_translation/handler.py b/litellm/llms/anthropic/chat/guardrail_translation/handler.py index ca9529ba246..7d308c39b93 100644 --- a/litellm/llms/anthropic/chat/guardrail_translation/handler.py +++ b/litellm/llms/anthropic/chat/guardrail_translation/handler.py @@ -857,7 +857,7 @@ class AnthropicMessagesHandler(BaseTranslation): ) translation_source: Final = {**{key: value for key, value in data.items() if key != "system"}, "model": model} chat_completion_compatible_request: Final = self._translate_to_openai( - translation_source, keep_cache_control=keep_cache_control + translation_source, keep_cache_control=keep_cache_control, keep_tool_errors=True ) full_structured_messages: Final = cast( @@ -1047,7 +1047,10 @@ class AnthropicMessagesHandler(BaseTranslation): """Convert an OpenAI system message to the client's Anthropic-shaped entry.""" content: Final = message.get("content") if isinstance(content, str): - return {"role": "system", "content": content} if content else None + result: dict[str, object] = {"role": "system", "content": content} + if "output_config" in message: + result["output_config"] = deepcopy(message["output_config"]) + return result if content else None if not isinstance(content, list): return None blocks: Final[list[dict[str, object]]] = [] # mutable-ok: API message payload @@ -1065,7 +1068,12 @@ class AnthropicMessagesHandler(BaseTranslation): if cache_control: anthropic_block["cache_control"] = deepcopy(cache_control) blocks.append(anthropic_block) - return {"role": "system", "content": blocks} if blocks else None + if not blocks: + return None + result: dict[str, object] = {"role": "system", "content": blocks} + if "output_config" in message: + result["output_config"] = deepcopy(message["output_config"]) + return result @staticmethod def _fold_leading_systems_into_top_level( diff --git a/litellm/llms/anthropic/messages/adapters/transformation.py b/litellm/llms/anthropic/messages/adapters/transformation.py index 5e4fba6d706..28d2610f86d 100644 --- a/litellm/llms/anthropic/messages/adapters/transformation.py +++ b/litellm/llms/anthropic/messages/adapters/transformation.py @@ -1025,7 +1025,10 @@ class LiteLLMAnthropicMessagesAdapter: """Translate an in-sequence system entry without changing its role or position.""" content: Final = message.get("content") if isinstance(content, str): - return ChatCompletionSystemMessage(role="system", content=content) if content else None + result = ChatCompletionSystemMessage(role="system", content=content) if content else None + if result and "output_config" in message: + result["output_config"] = message["output_config"] + return result if not isinstance(content, list): return None text_parts: Final[list[ChatCompletionTextObject]] = [] # mutable-ok: API message payload @@ -1038,7 +1041,12 @@ class LiteLLMAnthropicMessagesAdapter: text_obj = ChatCompletionTextObject(type="text", text=text) self._add_cache_control_if_applicable(block, text_obj, model) text_parts.append(self._add_prompt_cache_breakpoint_if_present(block, text_obj)) - return ChatCompletionSystemMessage(role="system", content=text_parts) if text_parts else None + if not text_parts: + return None + result = ChatCompletionSystemMessage(role="system", content=text_parts) + if "output_config" in message: + result["output_config"] = message["output_config"] + return result def _add_system_message_to_messages( self, diff --git a/tests/unit/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_field_preservation.py b/tests/unit/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_field_preservation.py new file mode 100644 index 00000000000..10e6907efcd --- /dev/null +++ b/tests/unit/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_field_preservation.py @@ -0,0 +1,146 @@ +""" +Test that Anthropic guardrail write-back preserves tool_result.is_error and system output_config. + +Regression tests for https://github.com/BerriAI/litellm/issues/50269 +""" + +from litellm.litellm_core_utils.prompt_templates.factory import convert_to_anthropic_tool_result +from litellm.llms.anthropic.chat.guardrail_translation.handler import AnthropicMessagesHandler +from litellm.types.llms.openai import ChatCompletionToolMessage + + +def test_convert_to_anthropic_tool_result_preserves_is_error_true(): + """Tool message with is_error=true must survive OpenAI->Anthropic conversion.""" + tool_message: ChatCompletionToolMessage = { + "role": "tool", + "tool_call_id": "toolu_01abc", + "content": "cat: file.txt: No such file or directory", + "is_error": True, + } + + result = convert_to_anthropic_tool_result(tool_message) + + assert result["type"] == "tool_result" + assert result["tool_use_id"] == "toolu_01abc" + assert result["content"] == "cat: file.txt: No such file or directory" + assert result.get("is_error") is True + + +def test_convert_to_anthropic_tool_result_preserves_is_error_false(): + """Tool message with is_error=false must survive OpenAI->Anthropic conversion.""" + tool_message: ChatCompletionToolMessage = { + "role": "tool", + "tool_call_id": "toolu_02xyz", + "content": "success output", + "is_error": False, + } + + result = convert_to_anthropic_tool_result(tool_message) + + assert result["type"] == "tool_result" + assert result["tool_use_id"] == "toolu_02xyz" + assert result["content"] == "success output" + assert result.get("is_error") is False + + +def test_convert_to_anthropic_tool_result_no_is_error(): + """Tool message without is_error field should not add one.""" + tool_message: ChatCompletionToolMessage = { + "role": "tool", + "tool_call_id": "toolu_03def", + "content": "normal output", + } + + result = convert_to_anthropic_tool_result(tool_message) + + assert result["type"] == "tool_result" + assert result["tool_use_id"] == "toolu_03def" + assert result["content"] == "normal output" + assert "is_error" not in result + + +def test_openai_system_message_to_anthropic_preserves_output_config(): + """System message with output_config must survive OpenAI->Anthropic conversion.""" + openai_system_message = { + "role": "system", + "content": [ + { + "type": "text", + "text": "Environment update", + } + ], + "output_config": {"effort": "medium"}, + } + + result = AnthropicMessagesHandler._openai_system_message_to_anthropic(openai_system_message) + + assert result is not None + assert result["role"] == "system" + assert isinstance(result["content"], list) + assert len(result["content"]) == 1 + assert result["content"][0]["type"] == "text" + assert result["content"][0]["text"] == "Environment update" + assert result.get("output_config") == {"effort": "medium"} + + +def test_openai_system_message_to_anthropic_preserves_cache_control(): + """System message with cache_control must still be preserved.""" + openai_system_message = { + "role": "system", + "content": [ + { + "type": "text", + "text": "Cached content", + "cache_control": {"type": "ephemeral"}, + } + ], + } + + result = AnthropicMessagesHandler._openai_system_message_to_anthropic(openai_system_message) + + assert result is not None + assert result["role"] == "system" + assert isinstance(result["content"], list) + assert len(result["content"]) == 1 + assert result["content"][0]["type"] == "text" + assert result["content"][0]["text"] == "Cached content" + assert result["content"][0]["cache_control"] == {"type": "ephemeral"} + + +def test_openai_system_message_to_anthropic_preserves_both_fields(): + """System message with both output_config and cache_control must preserve both.""" + openai_system_message = { + "role": "system", + "content": [ + { + "type": "text", + "text": "Complex system message", + "cache_control": {"type": "ephemeral"}, + } + ], + "output_config": {"effort": "high"}, + } + + result = AnthropicMessagesHandler._openai_system_message_to_anthropic(openai_system_message) + + assert result is not None + assert result["role"] == "system" + assert isinstance(result["content"], list) + assert result["content"][0]["cache_control"] == {"type": "ephemeral"} + assert result.get("output_config") == {"effort": "high"} + + +def test_openai_system_message_to_anthropic_string_content_with_output_config(): + """System message with string content and output_config must preserve output_config.""" + openai_system_message = { + "role": "system", + "content": "Simple system prompt", + "output_config": {"effort": "low"}, + } + + result = AnthropicMessagesHandler._openai_system_message_to_anthropic(openai_system_message) + + assert result is not None + assert result["role"] == "system" + assert result["content"] == "Simple system prompt" + assert result.get("output_config") == {"effort": "low"}