diff --git a/litellm/llms/anthropic/chat/guardrail_translation/handler.py b/litellm/llms/anthropic/chat/guardrail_translation/handler.py index 24ff63c9433..15406f8bf60 100644 --- a/litellm/llms/anthropic/chat/guardrail_translation/handler.py +++ b/litellm/llms/anthropic/chat/guardrail_translation/handler.py @@ -637,7 +637,11 @@ class AnthropicMessagesHandler(BaseTranslation): guardrailed_texts: Final = guardrailed_inputs.get("texts", []) guardrailed_tools: Final = guardrailed_inputs.get("tools") - if guardrailed_tools is not None: + # A guardrail signals "I rewrote the tools" by returning a new list (same contract as + # structured_messages below). Re-serializing an untouched list would stamp type:"custom" onto + # every tool, which strict Anthropic-compat upstreams (e.g. DeepSeek) reject with + # `tools[0]: unknown variant 'custom'`. + if guardrailed_tools is not None and guardrailed_tools is not tools_to_check: # Convert tools back from OpenAI format to Anthropic format anthropic_config: Final = AnthropicConfig() anthropic_tools: Final[list[AllAnthropicToolsValues]] = [] diff --git a/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py b/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py index 1c1b68de6d6..f9b9f24b614 100644 --- a/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py +++ b/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py @@ -1463,12 +1463,13 @@ class TestAnthropicMessagesHandlerInputProcessing: tool_search_tool_regex_20251119 were being converted to OpenAI format and then not properly converted back, causing API errors. - The guardrail converts tools to OpenAI format for processing, then they need to be - converted back to Anthropic format. Native Anthropic tools should be preserved as-is, - while regular tools should be converted to type="custom". + The guardrail converts tools to OpenAI format for processing, then a guardrail that + rewrites them (returns a new list) needs them converted back to Anthropic format. + Native Anthropic tools should be preserved as-is, while regular tools should be + converted to type="custom". """ handler = AnthropicMessagesHandler() - guardrail = MockPassThroughGuardrail(guardrail_name="test") + guardrail = ToolRewritingGuardrail(guardrail_name="test") data = { "model": "claude-opus-4-6", @@ -1518,6 +1519,97 @@ class TestAnthropicMessagesHandlerInputProcessing: assert "input_schema" in tools[1] +class ToolRewritingGuardrail(CustomGuardrail): + """Returns a new `tools` list, which is how a guardrail signals that it rewrote tool schemas.""" + + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict, + input_type: Literal["request", "response"], + logging_obj: Optional[Any] = None, + ) -> GenericGuardrailAPIInputs: + rewritten = inputs.copy() + rewritten["tools"] = list(inputs.get("tools") or []) + return rewritten + + +class ToolDescriptionMaskingGuardrail(CustomGuardrail): + """Masks a secret out of every tool description, returning a new `tools` list.""" + + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict, + input_type: Literal["request", "response"], + logging_obj: Optional[Any] = None, + ) -> GenericGuardrailAPIInputs: + masked = inputs.copy() + masked["tools"] = [ + { + **tool, + "function": { + **tool["function"], + "description": str(tool["function"].get("description", "")).replace("sk-live-123", "[MASKED]"), + }, + } + for tool in inputs.get("tools") or [] + ] + return masked + + +class TestAnthropicMessagesHandlerUnmodifiedTools: + """Claude Code CLI sends Anthropic-native tools with no `type` key. A guardrail that hands + `tools` back untouched must not trigger the OpenAI-to-Anthropic re-serialization, because that + stamps type:"custom" and strict Anthropic-compat upstreams (DeepSeek) reject that variant with + `tools[0]: unknown variant 'custom'`.""" + + @staticmethod + def _client_tools() -> list: + return [ + { + "name": "Bash", + "description": "Run a shell command, token sk-live-123", + "input_schema": { + "type": "object", + "properties": {"command": {"type": "string"}}, + "required": ["command"], + }, + } + ] + + def _data(self) -> dict: + return { + "model": "deepseek-v4-flash", + "max_tokens": 128, + "messages": [{"role": "user", "content": [{"type": "text", "text": "list /tmp"}]}], + "tools": self._client_tools(), + } + + @pytest.mark.asyncio + async def test_tools_reach_upstream_verbatim_when_guardrail_leaves_them_alone(self): + data = self._data() + + result = await AnthropicMessagesHandler().process_input_messages( + data=data, guardrail_to_apply=MockPassThroughGuardrail(guardrail_name="test") + ) + + assert result["tools"] == self._client_tools() + assert "type" not in result["tools"][0] + + @pytest.mark.asyncio + async def test_tools_are_rewritten_when_guardrail_returns_a_new_list(self): + data = self._data() + + result = await AnthropicMessagesHandler().process_input_messages( + data=data, guardrail_to_apply=ToolDescriptionMaskingGuardrail(guardrail_name="test") + ) + + assert [tool["name"] for tool in result["tools"]] == ["Bash"] + assert result["tools"][0]["description"] == "Run a shell command, token [MASKED]" + assert "sk-live-123" not in json.dumps(result["tools"]) + + class ToolAppendingGuardrail(CustomGuardrail): """Guardrail that appends a new OpenAI-format function tool, mimicking a guardrail that injects a retrieval/recovery tool the model can later call."""