diff --git a/docs/my-website/docs/completion/message_sanitization.md b/docs/my-website/docs/completion/message_sanitization.md new file mode 100644 index 0000000000..17482c5933 --- /dev/null +++ b/docs/my-website/docs/completion/message_sanitization.md @@ -0,0 +1,465 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# Message Sanitization for Tool Calling for anthropic models + +**Automatically fix common message formatting issues when using tool calling with `modify_params=True`** + +LiteLLM can automatically sanitize messages to handle common issues that occur during tool calling workflows, especially when using OpenAI-compatible clients with providers that have strict message format requirements (like Anthropic Claude). + +## Overview + +When `litellm.modify_params = True` is enabled, LiteLLM automatically sanitizes messages to fix three common issues: + +1. **Orphaned Tool Calls** - Assistant messages with tool_calls but missing tool results +2. **Orphaned Tool Results** - Tool messages that reference non-existent tool_call_ids +3. **Empty Message Content** - Messages with empty or whitespace-only text content + +This ensures your tool calling workflows work seamlessly across different LLM providers without manual message validation. + +## Why Message Sanitization? + +Different LLM providers have varying requirements for message formats, especially during tool calling: + +- **Anthropic Claude** requires every tool_call to have a corresponding tool result +- Some providers reject messages with empty content +- OpenAI-compatible clients may not always maintain perfect message consistency + +Without sanitization, these issues cause API errors that interrupt your workflows. With `modify_params=True`, LiteLLM handles these edge cases automatically. + +## Quick Start + + + + +```python +import litellm + +# Enable automatic message sanitization +litellm.modify_params = True + +# This will work even if messages have formatting issues +response = litellm.completion( + model="anthropic/claude-3-5-sonnet-20241022", + messages=[ + {"role": "user", "content": "What's the weather in Boston?"}, + { + "role": "assistant", + "tool_calls": [ + { + "id": "call_123", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"city": "Boston"}'} + } + ] + # Missing tool result - LiteLLM will add a dummy result automatically + }, + {"role": "user", "content": "Thanks!"} + ], + tools=[{ + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather for a city", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"] + } + } + }] +) +``` + + + + +```yaml +litellm_settings: + modify_params: true # Enable automatic message sanitization + +model_list: + - model_name: claude-3-5-sonnet + litellm_params: + model: anthropic/claude-3-5-sonnet-20241022 +``` + + + + +## Sanitization Cases + +### Case A: Orphaned Tool Calls (Missing Tool Results) + +**Problem:** An assistant message contains `tool_calls`, but no corresponding tool result messages follow. + +**Solution:** LiteLLM automatically adds dummy tool result messages for any missing tool results. + +**Example:** + +```python +import litellm +litellm.modify_params = True + +# Messages with orphaned tool calls +messages = [ + {"role": "user", "content": "Search for Python tutorials"}, + { + "role": "assistant", + "tool_calls": [ + { + "id": "call_abc123", + "type": "function", + "function": {"name": "web_search", "arguments": '{"query": "Python tutorials"}'} + } + ] + }, + # Missing tool result here! + {"role": "user", "content": "What about JavaScript?"} +] + +# LiteLLM automatically adds: +# { +# "role": "tool", +# "tool_call_id": "call_abc123", +# "content": "[System: Tool execution skipped/interrupted by user. No result provided for tool 'web_search'.]" +# } + +response = litellm.completion( + model="anthropic/claude-3-5-sonnet-20241022", + messages=messages, + tools=[...] +) +``` + +**When this happens:** +- User interrupts tool execution +- Client loses tool results due to network issues +- Conversation flow changes before tool completes +- Multi-turn conversations where tools are optional + +### Case B: Orphaned Tool Results (Invalid tool_call_id) + +**Problem:** A tool message references a `tool_call_id` that doesn't exist in any previous assistant message. + +**Solution:** LiteLLM automatically removes these orphaned tool result messages. + +**Example:** + +```python +import litellm +litellm.modify_params = True + +# Messages with orphaned tool result +messages = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi! How can I help?"}, + { + "role": "tool", + "tool_call_id": "call_nonexistent", # This tool_call_id doesn't exist! + "content": "Some result" + } +] + +# LiteLLM automatically removes the orphaned tool message + +response = litellm.completion( + model="anthropic/claude-3-5-sonnet-20241022", + messages=messages +) +``` + +**When this happens:** +- Message history is manually edited +- Tool results are duplicated or mismatched +- Conversation state is restored incorrectly +- Messages are merged from different conversations + +### Case C: Empty Message Content + +**Problem:** User or assistant messages have empty or whitespace-only content. + +**Solution:** LiteLLM replaces empty content with a system placeholder message. + +**Example:** + +```python +import litellm +litellm.modify_params = True + +# Messages with empty content +messages = [ + {"role": "user", "content": ""}, # Empty content + {"role": "assistant", "content": " "}, # Whitespace only +] + +# LiteLLM automatically replaces with: +# {"role": "user", "content": "[System: Empty message content sanitised to satisfy protocol]"} +# {"role": "assistant", "content": "[System: Empty message content sanitised to satisfy protocol]"} + +response = litellm.completion( + model="anthropic/claude-3-5-sonnet-20241022", + messages=messages +) +``` + +**When this happens:** +- UI sends empty messages +- Content is stripped during preprocessing +- Placeholder messages in conversation history +- Edge cases in message construction + +## Configuration + +### Enable Globally + + + + +```python +import litellm + +# Enable for all completion calls +litellm.modify_params = True +``` + + + + +```yaml +litellm_settings: + modify_params: true +``` + + + + +```bash +export LITELLM_MODIFY_PARAMS=True +``` + + + + +### Enable Per-Request + +```python +import litellm + +# Enable only for specific requests +response = litellm.completion( + model="anthropic/claude-3-5-sonnet-20241022", + messages=messages, + modify_params=True # Override global setting +) +``` + +## Supported Providers + +Message sanitization currently works with: + +- ✅ Anthropic (Claude) + +**Note:** While the sanitization logic is provider-agnostic, it is currently only applied in the Anthropic message transformation pipeline. Support for additional providers may be added in future releases. + +## Implementation Details + +### How It Works + +The message sanitization process runs **before** messages are converted to provider-specific formats: + +1. **Input:** OpenAI-format messages with potential issues +2. **Sanitization:** Three helper functions process the messages: + - `_sanitize_empty_text_content()` - Fixes empty content + - `_add_missing_tool_results()` - Adds dummy tool results + - `_is_orphaned_tool_result()` - Identifies orphaned results +3. **Output:** Clean, provider-compatible messages + +### Code Reference + +The sanitization logic is implemented in: +- `litellm/litellm_core_utils/prompt_templates/factory.py` +- Function: `sanitize_messages_for_tool_calling()` + +### Logging + +When sanitization occurs, LiteLLM logs debug messages: + +```python +import litellm +litellm.set_verbose = True # Enable debug logging + +# You'll see logs like: +# "_add_missing_tool_results: Found 1 orphaned tool calls. Adding dummy tool results." +# "_is_orphaned_tool_result: Found orphaned tool result with tool_call_id=call_123" +# "_sanitize_empty_text_content: Replaced empty text content in user message" +``` + +## Best Practices + +### 1. Enable for Production Workflows + +```python +# Recommended for production +litellm.modify_params = True + +# Ensures robust handling of edge cases +response = litellm.completion( + model="anthropic/claude-3-5-sonnet-20241022", + messages=messages, + tools=tools +) +``` + +### 2. Preserve Tool Results When Possible + +While sanitization handles missing tool results, it's better to provide actual results: + +```python +# Good: Provide actual tool results +messages = [ + {"role": "user", "content": "Search for Python"}, + {"role": "assistant", "tool_calls": [...]}, + {"role": "tool", "tool_call_id": "call_123", "content": "Actual search results"} +] + +# Fallback: Sanitization adds dummy result if missing +messages = [ + {"role": "user", "content": "Search for Python"}, + {"role": "assistant", "tool_calls": [...]}, + # Missing tool result - sanitization adds dummy +] +``` + +### 3. Monitor Sanitization Events + +Use logging to track when sanitization occurs: + +```python +import litellm +import logging + +# Enable debug logging +litellm.set_verbose = True +logging.basicConfig(level=logging.DEBUG) + +# Track sanitization events in your application +response = litellm.completion( + model="anthropic/claude-3-5-sonnet-20241022", + messages=messages +) +``` + +### 4. Test Edge Cases + +Ensure your application handles sanitized messages correctly: + +```python +import litellm +litellm.modify_params = True + +# Test orphaned tool calls +test_messages = [ + {"role": "user", "content": "Test"}, + {"role": "assistant", "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "test", "arguments": "{}"}}]}, + {"role": "user", "content": "Continue"} # No tool result +] + +response = litellm.completion( + model="anthropic/claude-3-5-sonnet-20241022", + messages=test_messages, + tools=[...] +) + +# Verify the response handles the dummy tool result appropriately +``` + +## Related Features + +- **[Drop Params](./drop_params.md)** - Drop unsupported parameters for specific providers +- **[Message Trimming](./message_trimming.md)** - Trim messages to fit token limits +- **[Function Calling](./function_call.md)** - Complete guide to tool/function calling +- **[Reasoning Content](../reasoning_content.md)** - Extended thinking with tool calling + +## Troubleshooting + +### Sanitization Not Working + +**Issue:** Messages still cause errors despite `modify_params=True` + +**Solution:** +1. Verify `modify_params` is enabled: + ```python + import litellm + print(litellm.modify_params) # Should be True + ``` + +2. Check if the issue is provider-specific: + ```python + litellm.set_verbose = True # Enable debug logging + ``` + +3. Ensure you're using a recent version of LiteLLM: + ```bash + pip install --upgrade litellm + ``` + +### Unexpected Dummy Tool Results + +**Issue:** Dummy tool results appear when you expect actual results + +**Cause:** Tool result messages are missing or have incorrect `tool_call_id` + +**Solution:** +1. Verify tool result messages have correct `tool_call_id`: + ```python + # Correct + {"role": "tool", "tool_call_id": "call_123", "content": "result"} + + # Incorrect - will be treated as orphaned + {"role": "tool", "tool_call_id": "wrong_id", "content": "result"} + ``` + +2. Ensure tool results immediately follow assistant messages with tool_calls + +### Performance Impact + +**Issue:** Concerned about performance overhead + +**Details:** Message sanitization has minimal performance impact: +- Runs in O(n) time where n = number of messages +- Only processes messages when `modify_params=True` +- Typically adds < 1ms to request processing time + +## FAQ + +**Q: Does sanitization modify my original messages?** + +A: No, sanitization creates a new list of messages. Your original messages remain unchanged. + +**Q: Can I disable specific sanitization cases?** + +A: Currently, all three cases are handled together when `modify_params=True`. To disable sanitization entirely, set `modify_params=False`. + +**Q: What happens to the dummy tool results?** + +A: Dummy tool results are sent to the LLM provider along with other messages. The model sees them as regular tool results with informative error messages. + +**Q: Does this work with streaming?** + +A: Yes, message sanitization works with both streaming and non-streaming requests. + +**Q: Is this related to `drop_params`?** + +A: No, they're separate features: +- `modify_params` - Modifies/fixes message content and structure +- `drop_params` - Removes unsupported API parameters + +Both can be enabled simultaneously. + +## See Also + +- [Reasoning Content with Tool Calling](../reasoning_content.md) +- [Function Calling Guide](./function_call.md) +- [Bedrock Provider Documentation](../providers/bedrock.md) +- [Anthropic Provider Documentation](../providers/anthropic.md) diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index f2d7f6f423..3acfa3937a 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -944,6 +944,7 @@ const sidebars = { "providers/anthropic_tool_search", "guides/code_interpreter", "completion/message_trimming", + "completion/message_sanitization", "completion/model_alias", "completion/mock_requests", "completion/predict_outputs", diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index c907ed32b9..7b485501f6 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -2018,6 +2018,235 @@ def anthropic_process_openai_file_message( ) +def _sanitize_empty_text_content( + message: AllMessageValues, +) -> AllMessageValues: + """ + Case C: Sanitize empty text content + - Replace empty or whitespace-only text content with a placeholder message. + + Returns: + The message with sanitized content if needed, otherwise the original message + """ + if message.get("role") in ["user", "assistant"]: + content = message.get("content") + if isinstance(content, str): + if not content or not content.strip(): + message = cast(AllMessageValues, dict(message)) # Make a copy + message["content"] = "[System: Empty message content sanitised to satisfy protocol]" + verbose_logger.debug( + f"_sanitize_empty_text_content: Replaced empty text content in {message.get('role')} message" + ) + return message + + +def _add_missing_tool_results( # noqa: PLR0915 + current_message: AllMessageValues, + messages: List[AllMessageValues], + current_index: int, +) -> Tuple[List[AllMessageValues], int]: + """ + Case A: Missing tool_result for tool_use (orphaned tool calls) + - If an assistant message has tool_calls but no corresponding tool result follows, + add a dummy tool result message indicating the user did not provide the result. + + Returns: + A tuple of: + - List containing the assistant message, followed by existing tool results, + followed by any dummy tool results needed + - Number of original messages consumed (to adjust iteration index) + """ + result_messages: List[AllMessageValues] = [] + tool_calls = current_message.get("tool_calls") + + if not tool_calls or len(cast(list, tool_calls)) == 0: + return ([current_message], 0) + + # Collect all tool_call_ids from this assistant message + expected_tool_call_ids = set() + for tool_call in cast(list, tool_calls): + tool_call_id = None + if isinstance(tool_call, dict): + tool_call_id = tool_call.get("id") + else: + tool_call_id = getattr(tool_call, "id", None) + if tool_call_id: + expected_tool_call_ids.add(tool_call_id) + + # Collect actual tool result messages that follow this assistant message + found_tool_call_ids = set() + actual_tool_results: List[AllMessageValues] = [] + j = current_index + 1 + + while j < len(messages): + next_msg = messages[j] + next_role = next_msg.get("role") + + if next_role == "assistant": + break + + if next_role in ["tool", "function"]: + tool_call_id = next_msg.get("tool_call_id") + if tool_call_id and tool_call_id in expected_tool_call_ids: + found_tool_call_ids.add(tool_call_id) + actual_tool_results.append(next_msg) + + j += 1 + + # Find missing tool results + missing_tool_call_ids = expected_tool_call_ids - found_tool_call_ids + + if missing_tool_call_ids: + verbose_logger.debug( + f"_add_missing_tool_results: Found {len(missing_tool_call_ids)} orphaned tool calls. Adding dummy tool results." + ) + + result_messages.append(current_message) + + # Add existing tool results FIRST + result_messages.extend(actual_tool_results) + + # Then add dummy tool results for missing ones + for tool_call_id in missing_tool_call_ids: + tool_name = "unknown_tool" + for tool_call in cast(list, tool_calls): + tc_id = None + if isinstance(tool_call, dict): + tc_id = tool_call.get("id") + else: + tc_id = getattr(tool_call, "id", None) + + if tc_id == tool_call_id: + if isinstance(tool_call, dict): + function = tool_call.get("function", {}) + if isinstance(function, dict): + tool_name = function.get("name", "unknown_tool") + else: + tool_name = getattr(function, "name", "unknown_tool") + else: + function = getattr(tool_call, "function", None) + if function: + tool_name = getattr(function, "name", "unknown_tool") + break + + dummy_tool_result: ChatCompletionToolMessage = { + "role": "tool", + "tool_call_id": tool_call_id, + "content": f"[System: Tool execution skipped/interrupted by user. No result provided for tool '{tool_name}'.]", + } + result_messages.append(dummy_tool_result) + + # Return the messages and the number of original messages to skip + return (result_messages, len(actual_tool_results)) + + return ([current_message], 0) + + +def _is_orphaned_tool_result( + current_message: AllMessageValues, + sanitized_messages: List[AllMessageValues], +) -> bool: + """ + Case B: Orphaned tool_result (unexpected result) + - Check if a tool message references a tool_call_id that doesn't exist in the previous + assistant message. + + Returns: + True if this is an orphaned tool result that should be removed, False otherwise + """ + if current_message.get("role") not in ["tool", "function"]: + return False + + tool_call_id = current_message.get("tool_call_id") + + if not tool_call_id: + return False + + # Look back to find the most recent assistant message with tool_calls + found_matching_tool_call = False + + for j in range(len(sanitized_messages) - 1, -1, -1): + prev_msg = sanitized_messages[j] + if prev_msg.get("role") == "assistant": + tool_calls = prev_msg.get("tool_calls") + if tool_calls: + for tool_call in cast(list, tool_calls): + tc_id = None + if isinstance(tool_call, dict): + tc_id = tool_call.get("id") + else: + tc_id = getattr(tool_call, "id", None) + + if tc_id == tool_call_id: + found_matching_tool_call = True + break + + break + + if not found_matching_tool_call: + verbose_logger.debug( + "_is_orphaned_tool_result: Found orphaned tool result with redacted tool_call_id" + ) + return True + + return False + + +def sanitize_messages_for_tool_calling( + messages: List[AllMessageValues], +) -> List[AllMessageValues]: + """ + Sanitize messages for tool calling to handle common issues when modify_params=True: + + Case A: Missing tool_result for tool_use (orphaned tool calls) + - If an assistant message has tool_calls but no corresponding tool result follows, + add a dummy tool result message indicating the user did not provide the result. + + Case B: Orphaned tool_result (unexpected result) + - If a tool message references a tool_call_id that doesn't exist in the previous + assistant message, remove that tool message. + + Case C: Empty text content + - Replace empty or whitespace-only text content with a placeholder message. + + This function operates on OpenAI format messages before they are converted to + provider-specific formats. + """ + if not litellm.modify_params: + return messages + + sanitized_messages: List[AllMessageValues] = [] + i = 0 + + while i < len(messages): + current_message = messages[i] + + # Case C: Sanitize empty text content + current_message = _sanitize_empty_text_content(current_message) + + # Case A: Check if assistant message has tool_calls without following tool results + if current_message.get("role") == "assistant": + result_messages, messages_consumed = _add_missing_tool_results(current_message, messages, i) + + # If dummy tool results were added, extend sanitized_messages and skip consumed messages + if len(result_messages) > 1: + sanitized_messages.extend(result_messages) + # Skip the assistant message and any actual tool results that were included + i += 1 + messages_consumed + continue + + # Case B: Check for orphaned tool results + if _is_orphaned_tool_result(current_message, sanitized_messages): + i += 1 + continue # Skip this orphaned tool result + + # Add the message to sanitized list + sanitized_messages.append(current_message) + i += 1 + + return sanitized_messages + + def anthropic_messages_pt( # noqa: PLR0915 messages: List[AllMessageValues], model: str, @@ -2037,6 +2266,9 @@ def anthropic_messages_pt( # noqa: PLR0915 5. System messages are a separate param to the Messages API 6. Ensure we only accept role, content. (message.name is not supported) """ + # Sanitize messages for tool calling issues when modify_params=True + messages = sanitize_messages_for_tool_calling(messages) + # add role=tool support to allow function call result/error submission user_message_types = {"user", "tool", "function"} # reformat messages to ensure user/assistant are alternating, if there's either 2 consecutive 'user' messages or 2 consecutive 'assistant' message, merge them. diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py index c4ade2f1a8..7058e7644c 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py @@ -31,11 +31,15 @@ from litellm import Router from litellm._logging import verbose_proxy_logger from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.proxy._types import UserAPIKeyAuth -from litellm.types.utils import GuardrailTracingDetail, ModelResponseStream +from litellm.types.utils import ( + GenericGuardrailAPIInputs, + GuardrailStatus, + GuardrailTracingDetail, + ModelResponseStream, +) if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj - from litellm.types.utils import GenericGuardrailAPIInputs, GuardrailStatus from litellm.types.guardrails import ( BlockedWord, @@ -1546,8 +1550,6 @@ class ContentFilterGuardrail(CustomGuardrail): Raises: HTTPException: If sensitive content is detected and action is BLOCK """ - from litellm.types.utils import GuardrailStatus - start_time = datetime.now() detections: List[ContentFilterDetection] = [] masked_entity_count: Dict[str, int] = {} @@ -1693,4 +1695,4 @@ class ContentFilterGuardrail(CustomGuardrail): LitellmContentFilterGuardrailConfigModel, ) - return LitellmContentFilterGuardrailConfigModel + return LitellmContentFilterGuardrailConfigModel \ No newline at end of file diff --git a/tests/test_litellm/llms/anthropic/test_message_sanitization.py b/tests/test_litellm/llms/anthropic/test_message_sanitization.py new file mode 100644 index 0000000000..973f289788 --- /dev/null +++ b/tests/test_litellm/llms/anthropic/test_message_sanitization.py @@ -0,0 +1,380 @@ +""" +Test message sanitization for Anthropic API when modify_params=True + +Tests three cases: +A. Missing tool_result for tool_use (orphaned tool calls) +B. Orphaned tool_result without matching tool_use +C. Empty text content +""" + +import pytest +import sys +import os + +# Add the parent directory to the path so we can import litellm +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../.."))) + +import litellm +from litellm.litellm_core_utils.prompt_templates.factory import ( + sanitize_messages_for_tool_calling, + anthropic_messages_pt, +) + + +class TestMessageSanitization: + """Test message sanitization for tool calling scenarios""" + + def setup_method(self): + """Setup for each test""" + # Save original modify_params value + self.original_modify_params = litellm.modify_params + litellm.modify_params = True + + def teardown_method(self): + """Cleanup after each test""" + # Restore original modify_params value + litellm.modify_params = self.original_modify_params + + def test_case_a_orphaned_tool_call_single(self): + """ + Test Case A: Assistant message with tool_calls but no tool result + Should add a dummy tool result message + """ + messages = [ + { + "role": "user", + "content": "What is the weather in Nashik?" + }, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "toolu_01Kus2cC3ydjBW7UK4GJqBP4", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"location": "Nashik, India"}' + } + } + ] + } + ] + + sanitized = sanitize_messages_for_tool_calling(messages) + + # Should have 3 messages: user, assistant, and dummy tool result + assert len(sanitized) == 3 + assert sanitized[0]["role"] == "user" + assert sanitized[1]["role"] == "assistant" + assert sanitized[2]["role"] == "tool" + assert sanitized[2]["tool_call_id"] == "toolu_01Kus2cC3ydjBW7UK4GJqBP4" + assert "skipped" in sanitized[2]["content"].lower() or "interrupted" in sanitized[2]["content"].lower() + assert "get_weather" in sanitized[2]["content"] + + def test_case_a_orphaned_tool_call_multiple(self): + """ + Test Case A: Assistant message with multiple tool_calls, some missing results + """ + messages = [ + { + "role": "user", + "content": "Get weather for Nashik and Mumbai" + }, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"location": "Nashik"}' + } + }, + { + "id": "call_2", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"location": "Mumbai"}' + } + } + ] + }, + { + "role": "tool", + "tool_call_id": "call_1", + "content": "Weather in Nashik: 25°C" + } + ] + + sanitized = sanitize_messages_for_tool_calling(messages) + + # Should have 4 messages: user, assistant, tool result for call_1, dummy for call_2 + assert len(sanitized) == 4 + assert sanitized[0]["role"] == "user" + assert sanitized[1]["role"] == "assistant" + assert sanitized[2]["tool_call_id"] == "call_1" # Original tool result (first in tool_calls) + assert sanitized[3]["tool_call_id"] == "call_2" # Dummy added for missing call_2 + + def test_case_b_orphaned_tool_result(self): + """ + Test Case B: Tool result without matching tool_call in previous assistant message + Should remove the orphaned tool result + """ + messages = [ + { + "role": "user", + "content": "Hello" + }, + { + "role": "assistant", + "content": "Hi there!" + }, + { + "role": "tool", + "tool_call_id": "nonexistent_id", + "content": "Some result" + } + ] + + sanitized = sanitize_messages_for_tool_calling(messages) + + # Should have only 2 messages, orphaned tool result removed + assert len(sanitized) == 2 + assert sanitized[0]["role"] == "user" + assert sanitized[1]["role"] == "assistant" + + def test_case_b_valid_tool_result_preserved(self): + """ + Test Case B: Valid tool result with matching tool_call should be preserved + """ + messages = [ + { + "role": "user", + "content": "What's the weather?" + }, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_123", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"location": "Boston"}' + } + } + ] + }, + { + "role": "tool", + "tool_call_id": "call_123", + "content": "Weather: 20°C" + } + ] + + sanitized = sanitize_messages_for_tool_calling(messages) + + # All messages should be preserved + assert len(sanitized) == 3 + assert sanitized[2]["role"] == "tool" + assert sanitized[2]["tool_call_id"] == "call_123" + + def test_case_c_empty_text_content_user(self): + """ + Test Case C: Empty text content in user message + Should replace with placeholder + """ + messages = [ + { + "role": "user", + "content": "" + }, + { + "role": "assistant", + "content": "Hello!" + } + ] + + sanitized = sanitize_messages_for_tool_calling(messages) + + assert len(sanitized) == 2 + assert sanitized[0]["role"] == "user" + assert sanitized[0]["content"] == "[System: Empty message content sanitised to satisfy protocol]" + + def test_case_c_whitespace_only_content(self): + """ + Test Case C: Whitespace-only content + Should replace with placeholder + """ + messages = [ + { + "role": "user", + "content": " \n \t " + }, + { + "role": "assistant", + "content": " " + } + ] + + sanitized = sanitize_messages_for_tool_calling(messages) + + assert len(sanitized) == 2 + assert sanitized[0]["content"] == "[System: Empty message content sanitised to satisfy protocol]" + assert sanitized[1]["content"] == "[System: Empty message content sanitised to satisfy protocol]" + + def test_case_c_valid_content_preserved(self): + """ + Test Case C: Valid non-empty content should be preserved + """ + messages = [ + { + "role": "user", + "content": "Hello" + }, + { + "role": "assistant", + "content": "Hi there!" + } + ] + + sanitized = sanitize_messages_for_tool_calling(messages) + + assert len(sanitized) == 2 + assert sanitized[0]["content"] == "Hello" + assert sanitized[1]["content"] == "Hi there!" + + def test_combined_cases(self): + """ + Test combination of multiple cases + """ + messages = [ + { + "role": "user", + "content": "Get weather" + }, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"location": "NYC"}' + } + } + ] + }, + # Missing tool result for call_1 + { + "role": "user", + "content": "" # Empty content + }, + { + "role": "assistant", + "content": "Response" + }, + { + "role": "tool", + "tool_call_id": "orphaned_id", # Orphaned tool result + "content": "Some data" + } + ] + + sanitized = sanitize_messages_for_tool_calling(messages) + + # Should have: user, assistant, dummy tool result, user (sanitized), assistant + # Orphaned tool result should be removed + assert len(sanitized) == 5 + assert sanitized[0]["role"] == "user" + assert sanitized[1]["role"] == "assistant" + assert sanitized[2]["role"] == "tool" + assert sanitized[2]["tool_call_id"] == "call_1" # Dummy added + assert sanitized[3]["role"] == "user" + assert sanitized[3]["content"] == "[System: Empty message content sanitised to satisfy protocol]" + assert sanitized[4]["role"] == "assistant" + + def test_modify_params_false_no_sanitization(self): + """ + Test that sanitization is skipped when modify_params=False + """ + litellm.modify_params = False + + messages = [ + { + "role": "user", + "content": "" + }, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{}' + } + } + ] + } + ] + + sanitized = sanitize_messages_for_tool_calling(messages) + + # Messages should be unchanged + assert len(sanitized) == 2 + assert sanitized[0]["content"] == "" + assert len(sanitized[1].get("tool_calls", [])) == 1 + + def test_anthropic_messages_pt_integration(self): + """ + Test that sanitization is integrated into anthropic_messages_pt + """ + litellm.modify_params = True + + messages = [ + { + "role": "user", + "content": "What is the weather in Nashik?" + }, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "toolu_01Kus2cC3ydjBW7UK4GJqBP4", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"location": "Nashik, India"}' + } + } + ] + } + ] + + # This should not raise an error and should add dummy tool result + result = anthropic_messages_pt( + messages=messages, + model="claude-sonnet-4-5", + llm_provider="anthropic" + ) + + # Should have at least 2 messages (user and assistant) + # The tool result will be merged into user content + assert len(result) >= 2 + assert result[0]["role"] == "user" + assert result[1]["role"] == "assistant" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])