diff --git a/docs/my-website/docs/completion/provider_specific_params.md b/docs/my-website/docs/completion/provider_specific_params.md index a8307fc8a20..772ca13e293 100644 --- a/docs/my-website/docs/completion/provider_specific_params.md +++ b/docs/my-website/docs/completion/provider_specific_params.md @@ -433,4 +433,54 @@ curl -X POST 'http://0.0.0.0:4000/chat/completions' \ ], "adapater_id": "my-special-adapter-id" # 👈 PROVIDER-SPECIFIC PARAM }' + +## Provider-Specific Metadata Parameters + +| Provider | Parameter | Use Case | +|----------|-----------|----------| +| **AWS Bedrock** | `requestMetadata` | Cost attribution, logging | +| **Gemini/Vertex AI** | `labels` | Resource labeling | +| **Anthropic** | `metadata` | User identification | + + + + +```python +import litellm + +response = litellm.completion( + model="bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0", + messages=[{"role": "user", "content": "Hello!"}], + requestMetadata={"cost_center": "engineering"} +) +``` + + + + +```python +import litellm + +response = litellm.completion( + model="vertex_ai/gemini-pro", + messages=[{"role": "user", "content": "Hello!"}], + labels={"environment": "production"} +) +``` + + + + +```python +import litellm + +response = litellm.completion( + model="anthropic/claude-3-sonnet-20240229", + messages=[{"role": "user", "content": "Hello!"}], + metadata={"user_id": "user123"} +) +``` + + + ``` \ No newline at end of file diff --git a/docs/my-website/docs/providers/bedrock.md b/docs/my-website/docs/providers/bedrock.md index 13f017333d3..86e9ac5e3e6 100644 --- a/docs/my-website/docs/providers/bedrock.md +++ b/docs/my-website/docs/providers/bedrock.md @@ -308,6 +308,65 @@ print(response) +## Usage - Request Metadata + +Attach metadata to Bedrock requests for logging and cost attribution. + + + + +```python +import os +from litellm import completion + +os.environ["AWS_ACCESS_KEY_ID"] = "" +os.environ["AWS_SECRET_ACCESS_KEY"] = "" +os.environ["AWS_REGION_NAME"] = "" + +response = completion( + model="bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0", + messages=[{"role": "user", "content": "Hello, how are you?"}], + requestMetadata={ + "cost_center": "engineering", + "user_id": "user123" + } +) +``` + + + +**Set on yaml** + +```yaml +model_list: + - model_name: bedrock-claude-v1 + litellm_params: + model: bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0 + requestMetadata: + cost_center: "engineering" +``` + +**Set on request** + +```python +import openai +client = openai.OpenAI( + api_key="anything", + base_url="http://0.0.0.0:4000" +) + +response = client.chat.completions.create( + model="bedrock-claude-v1", + messages=[{"role": "user", "content": "Hello"}], + extra_body={ + "requestMetadata": {"cost_center": "engineering"} + } +) +``` + + + + ## Usage - Function Calling / Tool calling LiteLLM supports tool calling via Bedrock's Converse and Invoke API's. diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index d99cff6b6bc..e8094444330 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -175,6 +175,77 @@ class AmazonConverseConfig(BaseConfig): and v is not None } + def _validate_request_metadata(self, metadata: dict) -> None: + """ + Validate requestMetadata according to AWS Bedrock Converse API constraints. + + Constraints: + - Maximum of 16 items + - Keys: 1-256 characters, pattern [a-zA-Z0-9\\s:_@$#=/+,-.]{1,256} + - Values: 0-256 characters, pattern [a-zA-Z0-9\\s:_@$#=/+,-.]{0,256} + """ + import re + + if not isinstance(metadata, dict): + raise litellm.exceptions.BadRequestError( + message="requestMetadata must be a dictionary", + model="bedrock", + llm_provider="bedrock", + ) + + if len(metadata) > 16: + raise litellm.exceptions.BadRequestError( + message="requestMetadata can contain a maximum of 16 items", + model="bedrock", + llm_provider="bedrock", + ) + + key_pattern = re.compile(r'^[a-zA-Z0-9\s:_@$#=/+,.-]{1,256}$') + value_pattern = re.compile(r'^[a-zA-Z0-9\s:_@$#=/+,.-]{0,256}$') + + for key, value in metadata.items(): + if not isinstance(key, str): + raise litellm.exceptions.BadRequestError( + message="requestMetadata keys must be strings", + model="bedrock", + llm_provider="bedrock", + ) + + if not isinstance(value, str): + raise litellm.exceptions.BadRequestError( + message="requestMetadata values must be strings", + model="bedrock", + llm_provider="bedrock", + ) + + if len(key) == 0 or len(key) > 256: + raise litellm.exceptions.BadRequestError( + message="requestMetadata key length must be 1-256 characters", + model="bedrock", + llm_provider="bedrock", + ) + + if len(value) > 256: + raise litellm.exceptions.BadRequestError( + message="requestMetadata value length must be 0-256 characters", + model="bedrock", + llm_provider="bedrock", + ) + + if not key_pattern.match(key): + raise litellm.exceptions.BadRequestError( + message=f"requestMetadata key '{key}' contains invalid characters. Allowed: [a-zA-Z0-9\\s:_@$#=/+,.-]", + model="bedrock", + llm_provider="bedrock", + ) + + if not value_pattern.match(value): + raise litellm.exceptions.BadRequestError( + message=f"requestMetadata value '{value}' contains invalid characters. Allowed: [a-zA-Z0-9\\s:_@$#=/+,.-]", + model="bedrock", + llm_provider="bedrock", + ) + def get_supported_openai_params(self, model: str) -> List[str]: from litellm.utils import supports_function_calling @@ -188,6 +259,7 @@ class AmazonConverseConfig(BaseConfig): "top_p", "extra_headers", "response_format", + "requestMetadata", ] if ( @@ -497,6 +569,10 @@ class AmazonConverseConfig(BaseConfig): optional_params["thinking"] = AnthropicConfig._map_reasoning_effort( value ) + if param == "requestMetadata": + if value is not None and isinstance(value, dict): + self._validate_request_metadata(value) # type: ignore + optional_params["requestMetadata"] = value # Only update thinking tokens for non-GPT-OSS models if "gpt-oss" not in model: @@ -686,34 +762,8 @@ class AmazonConverseConfig(BaseConfig): return {} - def _transform_request_helper( - self, - model: str, - system_content_blocks: List[SystemContentBlock], - optional_params: dict, - messages: Optional[List[AllMessageValues]] = None, - headers: Optional[dict] = None, - ) -> CommonRequestObject: - ## VALIDATE REQUEST - """ - Bedrock doesn't support tool calling without `tools=` param specified. - """ - if ( - "tools" not in optional_params - and messages is not None - and has_tool_call_blocks(messages) - ): - if litellm.modify_params: - optional_params["tools"] = add_dummy_tool( - custom_llm_provider="bedrock_converse" - ) - else: - raise litellm.UnsupportedParamsError( - message="Bedrock doesn't support tool calling without `tools=` param specified. Pass `tools=` param OR set `litellm.modify_params = True` // `litellm_settings::modify_params: True` to add dummy tool to the request.", - model="", - llm_provider="bedrock", - ) - + def _prepare_request_params(self, optional_params: dict, model: str) -> tuple[dict, dict, dict]: + """Prepare and separate request parameters.""" inference_params = copy.deepcopy(optional_params) supported_converse_params = list( AmazonConverseConfig.__annotations__.keys() @@ -727,6 +777,11 @@ class AmazonConverseConfig(BaseConfig): ) inference_params.pop("json_mode", None) # used for handling json_schema + # Extract requestMetadata before processing other parameters + request_metadata = inference_params.pop("requestMetadata", None) + if request_metadata is not None: + self._validate_request_metadata(request_metadata) + # keep supported params in 'inference_params', and set all model-specific params in 'additional_request_params' additional_request_params = { k: v for k, v in inference_params.items() if k not in total_supported_params @@ -740,9 +795,10 @@ class AmazonConverseConfig(BaseConfig): self._handle_top_k_value(model, inference_params) ) - original_tools = inference_params.pop("tools", []) + return inference_params, additional_request_params, request_metadata - # Initialize bedrock_tools + def _process_tools_and_beta(self, original_tools: list, model: str, headers: Optional[dict], additional_request_params: dict) -> tuple[List[ToolBlock], list]: + """Process tools and collect anthropic_beta values.""" bedrock_tools: List[ToolBlock] = [] # Collect anthropic_beta values from user headers @@ -784,6 +840,44 @@ class AmazonConverseConfig(BaseConfig): seen.add(beta) additional_request_params["anthropic_beta"] = unique_betas + return bedrock_tools, anthropic_beta_list + + def _transform_request_helper( + self, + model: str, + system_content_blocks: List[SystemContentBlock], + optional_params: dict, + messages: Optional[List[AllMessageValues]] = None, + headers: Optional[dict] = None, + ) -> CommonRequestObject: + ## VALIDATE REQUEST + """ + Bedrock doesn't support tool calling without `tools=` param specified. + """ + if ( + "tools" not in optional_params + and messages is not None + and has_tool_call_blocks(messages) + ): + if litellm.modify_params: + optional_params["tools"] = add_dummy_tool( + custom_llm_provider="bedrock_converse" + ) + else: + raise litellm.UnsupportedParamsError( + message="Bedrock doesn't support tool calling without `tools=` param specified. Pass `tools=` param OR set `litellm.modify_params = True` // `litellm_settings::modify_params: True` to add dummy tool to the request.", + model="", + llm_provider="bedrock", + ) + + # Prepare and separate parameters + inference_params, additional_request_params, request_metadata = self._prepare_request_params(optional_params, model) + + original_tools = inference_params.pop("tools", []) + + # Process tools and collect beta values + bedrock_tools, anthropic_beta_list = self._process_tools_and_beta(original_tools, model, headers, additional_request_params) + bedrock_tool_config: Optional[ToolConfigBlock] = None if len(bedrock_tools) > 0: tool_choice_values: ToolChoiceValuesBlock = inference_params.pop( @@ -813,6 +907,10 @@ class AmazonConverseConfig(BaseConfig): if bedrock_tool_config is not None: data["toolConfig"] = bedrock_tool_config + # Request Metadata (top-level field) + if request_metadata is not None: + data["requestMetadata"] = request_metadata + return data async def _async_transform_request( diff --git a/litellm/types/llms/bedrock.py b/litellm/types/llms/bedrock.py index a3de26ef45a..cebcd0522a1 100644 --- a/litellm/types/llms/bedrock.py +++ b/litellm/types/llms/bedrock.py @@ -1,5 +1,5 @@ import json -from typing import Any, List, Literal, Optional, Union +from typing import Any, Dict, List, Literal, Optional, Union from typing_extensions import ( TYPE_CHECKING, @@ -231,6 +231,7 @@ class CommonRequestObject( toolConfig: ToolConfigBlock guardrailConfig: Optional[GuardrailConfigBlock] performanceConfig: Optional[PerformanceConfigBlock] + requestMetadata: Optional[Dict[str, str]] class RequestObject(CommonRequestObject, total=False): diff --git a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py index 5be081c35b9..75c4484037a 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py @@ -1,7 +1,7 @@ +import asyncio import json import os import sys -import asyncio import pytest from fastapi.testclient import TestClient @@ -12,7 +12,7 @@ sys.path.insert( from unittest.mock import MagicMock, patch import litellm -from litellm import completion, RateLimitError, ModelResponse +from litellm import ModelResponse, RateLimitError, completion from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig from litellm.types.llms.bedrock import ConverseTokenUsageBlock @@ -308,8 +308,12 @@ def test_transform_request_helper_includes_anthropic_beta_and_tools(): def test_transform_response_with_computer_use_tool(): """Test response transformation with computer use tool call.""" import httpx - from litellm.types.llms.bedrock import ConverseResponseBlock, ConverseTokenUsageBlock + from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig + from litellm.types.llms.bedrock import ( + ConverseResponseBlock, + ConverseTokenUsageBlock, + ) from litellm.types.utils import ModelResponse # Simulate a Bedrock Converse response with a computer-use tool call @@ -397,8 +401,12 @@ def test_transform_response_with_computer_use_tool(): def test_transform_response_with_bash_tool(): """Test response transformation with bash tool call.""" import httpx - from litellm.types.llms.bedrock import ConverseResponseBlock, ConverseTokenUsageBlock + from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig + from litellm.types.llms.bedrock import ( + ConverseResponseBlock, + ConverseTokenUsageBlock, + ) from litellm.types.utils import ModelResponse # Simulate a Bedrock Converse response with a bash tool call @@ -1236,9 +1244,11 @@ def test_map_openai_params_with_response_format(): @pytest.mark.asyncio async def test_assistant_message_cache_control(): """Test that assistant messages with cache_control generate cachePoint blocks.""" - from litellm.litellm_core_utils.prompt_templates.factory import _bedrock_converse_messages_pt - from litellm.litellm_core_utils.prompt_templates.factory import BedrockConverseMessagesProcessor - + from litellm.litellm_core_utils.prompt_templates.factory import ( + BedrockConverseMessagesProcessor, + _bedrock_converse_messages_pt, + ) + # Test assistant message with string content and cache_control messages = [ {"role": "user", "content": "Hello"}, @@ -1287,8 +1297,10 @@ async def test_assistant_message_cache_control(): @pytest.mark.asyncio async def test_assistant_message_list_content_cache_control(): """Test assistant messages with list content and cache_control.""" - from litellm.litellm_core_utils.prompt_templates.factory import _bedrock_converse_messages_pt - from litellm.litellm_core_utils.prompt_templates.factory import BedrockConverseMessagesProcessor + from litellm.litellm_core_utils.prompt_templates.factory import ( + BedrockConverseMessagesProcessor, + _bedrock_converse_messages_pt, + ) messages = [ {"role": "user", "content": "Hello"}, @@ -1329,8 +1341,10 @@ async def test_assistant_message_list_content_cache_control(): @pytest.mark.asyncio async def test_tool_message_cache_control(): """Test that tool messages with cache_control generate cachePoint blocks.""" - from litellm.litellm_core_utils.prompt_templates.factory import _bedrock_converse_messages_pt - from litellm.litellm_core_utils.prompt_templates.factory import BedrockConverseMessagesProcessor + from litellm.litellm_core_utils.prompt_templates.factory import ( + BedrockConverseMessagesProcessor, + _bedrock_converse_messages_pt, + ) messages = [ {"role": "user", "content": "What's the weather?"}, @@ -1391,8 +1405,10 @@ async def test_tool_message_cache_control(): @pytest.mark.asyncio async def test_tool_message_string_content_cache_control(): """Test tool messages with string content and message-level cache_control.""" - from litellm.litellm_core_utils.prompt_templates.factory import _bedrock_converse_messages_pt - from litellm.litellm_core_utils.prompt_templates.factory import BedrockConverseMessagesProcessor + from litellm.litellm_core_utils.prompt_templates.factory import ( + BedrockConverseMessagesProcessor, + _bedrock_converse_messages_pt, + ) messages = [ {"role": "user", "content": "What's the weather?"}, @@ -1445,8 +1461,10 @@ async def test_tool_message_string_content_cache_control(): @pytest.mark.asyncio async def test_assistant_tool_calls_cache_control(): """Test that assistant tool_calls with cache_control generate cachePoint blocks.""" - from litellm.litellm_core_utils.prompt_templates.factory import _bedrock_converse_messages_pt - from litellm.litellm_core_utils.prompt_templates.factory import BedrockConverseMessagesProcessor + from litellm.litellm_core_utils.prompt_templates.factory import ( + BedrockConverseMessagesProcessor, + _bedrock_converse_messages_pt, + ) messages = [ {"role": "user", "content": "Calculate 2+2"}, @@ -1495,8 +1513,10 @@ async def test_assistant_tool_calls_cache_control(): @pytest.mark.asyncio async def test_multiple_tool_calls_with_mixed_cache_control(): """Test multiple tool calls where only some have cache_control.""" - from litellm.litellm_core_utils.prompt_templates.factory import _bedrock_converse_messages_pt - from litellm.litellm_core_utils.prompt_templates.factory import BedrockConverseMessagesProcessor + from litellm.litellm_core_utils.prompt_templates.factory import ( + BedrockConverseMessagesProcessor, + _bedrock_converse_messages_pt, + ) messages = [ {"role": "user", "content": "Do multiple calculations"}, @@ -1554,8 +1574,10 @@ async def test_multiple_tool_calls_with_mixed_cache_control(): @pytest.mark.asyncio async def test_no_cache_control_no_cache_point(): """Test that messages without cache_control don't generate cachePoint blocks.""" - from litellm.litellm_core_utils.prompt_templates.factory import _bedrock_converse_messages_pt - from litellm.litellm_core_utils.prompt_templates.factory import BedrockConverseMessagesProcessor + from litellm.litellm_core_utils.prompt_templates.factory import ( + BedrockConverseMessagesProcessor, + _bedrock_converse_messages_pt, + ) messages = [ {"role": "user", "content": "Hello"}, @@ -1599,7 +1621,7 @@ async def test_no_cache_control_no_cache_point(): def test_guarded_text_wraps_in_guardrail_converse_content(): """Test that guarded_text content type gets wrapped in guardContent blocks.""" from litellm.litellm_core_utils.prompt_templates.factory import _bedrock_converse_messages_pt - + messages = [ { "role": "user", @@ -1610,36 +1632,34 @@ def test_guarded_text_wraps_in_guardrail_converse_content(): ] } ] - + result = _bedrock_converse_messages_pt( messages=messages, model="us.amazon.nova-pro-v1:0", llm_provider="bedrock_converse" ) - + # Should have 1 message assert len(result) == 1 assert result[0]["role"] == "user" - + # Should have 3 content blocks content = result[0]["content"] assert len(content) == 3 - + # First and third should be regular text assert "text" in content[0] assert content[0]["text"] == "Regular text content" assert "text" in content[2] assert content[2]["text"] == "More regular text" - # Second should be guardContent assert "guardContent" in content[1] assert content[1]["guardContent"]["text"]["text"] == "This should be guarded" - def test_guarded_text_with_system_messages(): """Test guarded_text with system messages using the full transformation.""" config = AmazonConverseConfig() - + messages = [ {"role": "system", "content": "You are a helpful assistant."}, { @@ -1650,14 +1670,14 @@ def test_guarded_text_with_system_messages(): ] } ] - + optional_params = { "guardrailConfig": { "guardrailIdentifier": "gr-abc123", "guardrailVersion": "DRAFT" } } - + result = config._transform_request( model="us.amazon.nova-pro-v1:0", messages=messages, @@ -1665,26 +1685,25 @@ def test_guarded_text_with_system_messages(): litellm_params={}, headers={} ) - + # Should have system content blocks assert "system" in result assert len(result["system"]) == 1 assert result["system"][0]["text"] == "You are a helpful assistant." - + # Should have 1 message (system messages are removed) assert "messages" in result assert len(result["messages"]) == 1 - + # User message should have both regular text and guarded text user_message = result["messages"][0] assert user_message["role"] == "user" content = user_message["content"] assert len(content) == 2 - + # First should be regular text assert "text" in content[0] assert content[0]["text"] == "What is the main topic of this legal document?" - # Second should be guardContent assert "guardContent" in content[1] assert content[1]["guardContent"]["text"]["text"] == "This is a set of very long instructions that you will follow. Here is a legal document that you will use to answer the user's question." @@ -1693,7 +1712,7 @@ def test_guarded_text_with_system_messages(): def test_guarded_text_with_mixed_content_types(): """Test guarded_text with mixed content types including images.""" from litellm.litellm_core_utils.prompt_templates.factory import _bedrock_converse_messages_pt - + messages = [ { "role": "user", @@ -1704,38 +1723,37 @@ def test_guarded_text_with_mixed_content_types(): ] } ] - + result = _bedrock_converse_messages_pt( messages=messages, model="us.amazon.nova-pro-v1:0", llm_provider="bedrock_converse" ) - + # Should have 1 message assert len(result) == 1 assert result[0]["role"] == "user" - + # Should have 3 content blocks content = result[0]["content"] assert len(content) == 3 - + # First should be regular text assert "text" in content[0] assert content[0]["text"] == "Look at this image" - + # Second should be image assert "image" in content[1] - + # Third should be guardContent assert "guardContent" in content[2] assert content[2]["guardContent"]["text"]["text"] == "This sensitive content should be guarded" - @pytest.mark.asyncio async def test_async_guarded_text(): """Test async version of guarded_text processing.""" from litellm.litellm_core_utils.prompt_templates.factory import BedrockConverseMessagesProcessor - + messages = [ { "role": "user", @@ -1745,25 +1763,25 @@ async def test_async_guarded_text(): ] } ] - + result = await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( messages=messages, model="us.amazon.nova-pro-v1:0", llm_provider="bedrock_converse" ) - + # Should have 1 message assert len(result) == 1 assert result[0]["role"] == "user" - + # Should have 2 content blocks content = result[0]["content"] assert len(content) == 2 - + # First should be regular text assert "text" in content[0] assert content[0]["text"] == "Hello" - + # Second should be guardContent assert "guardContent" in content[1] assert content[1]["guardContent"]["text"]["text"] == "This should be guarded" @@ -1772,7 +1790,7 @@ async def test_async_guarded_text(): def test_guarded_text_with_tool_calls(): """Test guarded_text with tool calls in the conversation.""" from litellm.litellm_core_utils.prompt_templates.factory import _bedrock_converse_messages_pt - + messages = [ { "role": "user", @@ -1798,22 +1816,22 @@ def test_guarded_text_with_tool_calls(): "content": "It's sunny and 25°C" } ] - + result = _bedrock_converse_messages_pt( messages=messages, model="us.amazon.nova-pro-v1:0", llm_provider="bedrock_converse" ) - + # Should have 3 messages assert len(result) == 3 - + # First message (user) should have both text and guarded_text user_message = result[0] assert user_message["role"] == "user" content = user_message["content"] assert len(content) == 2 - + # First should be regular text assert "text" in content[0] assert content[0]["text"] == "What's the weather?" @@ -1832,7 +1850,7 @@ def test_guarded_text_with_tool_calls(): def test_guarded_text_guardrail_config_preserved(): """Test that guardrailConfig is preserved when using guarded_text.""" config = AmazonConverseConfig() - + messages = [ { "role": "user", @@ -1842,14 +1860,14 @@ def test_guarded_text_guardrail_config_preserved(): ] } ] - + optional_params = { "guardrailConfig": { "guardrailIdentifier": "gr-abc123", "guardrailVersion": "DRAFT" } } - + result = config._transform_request( model="us.amazon.nova-pro-v1:0", messages=messages, @@ -1857,11 +1875,11 @@ def test_guarded_text_guardrail_config_preserved(): litellm_params={}, headers={} ) - + # GuardrailConfig should be present at top level assert "guardrailConfig" in result assert result["guardrailConfig"]["guardrailIdentifier"] == "gr-abc123" - + # GuardrailConfig should also be in inferenceConfig assert "inferenceConfig" in result assert "guardrailConfig" in result["inferenceConfig"] @@ -1871,7 +1889,7 @@ def test_guarded_text_guardrail_config_preserved(): def test_auto_convert_last_user_message_to_guarded_text(): """Test that last user message is automatically converted to guarded_text when guardrailConfig is present.""" config = AmazonConverseConfig() - + messages = [ { "role": "user", @@ -1883,17 +1901,17 @@ def test_auto_convert_last_user_message_to_guarded_text(): ] } ] - + optional_params = { "guardrailConfig": { "guardrailIdentifier": "gr-abc123", "guardrailVersion": "1" } } - + # Test the helper method directly converted_messages = config._convert_consecutive_user_messages_to_guarded_text(messages, optional_params) - + # Verify the conversion assert len(converted_messages) == 1 assert converted_messages[0]["role"] == "user" @@ -1905,24 +1923,24 @@ def test_auto_convert_last_user_message_to_guarded_text(): def test_auto_convert_last_user_message_string_content(): """Test that last user message with string content is automatically converted to guarded_text when guardrailConfig is present.""" config = AmazonConverseConfig() - + messages = [ { "role": "user", "content": "What is the main topic of this legal document?" } ] - + optional_params = { "guardrailConfig": { "guardrailIdentifier": "gr-abc123", "guardrailVersion": "1" } } - + # Test the helper method directly converted_messages = config._convert_consecutive_user_messages_to_guarded_text(messages, optional_params) - + # Verify the conversion assert len(converted_messages) == 1 assert converted_messages[0]["role"] == "user" @@ -1934,7 +1952,7 @@ def test_auto_convert_last_user_message_string_content(): def test_no_conversion_when_no_guardrail_config(): """Test that no conversion happens when guardrailConfig is not present.""" config = AmazonConverseConfig() - + messages = [ { "role": "user", @@ -1946,12 +1964,12 @@ def test_no_conversion_when_no_guardrail_config(): ] } ] - + optional_params = {} - + # Test the helper method directly converted_messages = config._convert_consecutive_user_messages_to_guarded_text(messages, optional_params) - + # Verify no conversion happened assert converted_messages == messages @@ -1959,7 +1977,7 @@ def test_no_conversion_when_no_guardrail_config(): def test_no_conversion_when_guarded_text_already_present(): """Test that no conversion happens when guarded_text is already present in the last user message.""" config = AmazonConverseConfig() - + messages = [ { "role": "user", @@ -1971,17 +1989,17 @@ def test_no_conversion_when_guarded_text_already_present(): ] } ] - + optional_params = { "guardrailConfig": { "guardrailIdentifier": "gr-abc123", "guardrailVersion": "1" } } - + # Test the helper method directly converted_messages = config._convert_consecutive_user_messages_to_guarded_text(messages, optional_params) - + # Verify no conversion happened assert converted_messages == messages @@ -1989,7 +2007,7 @@ def test_no_conversion_when_guarded_text_already_present(): def test_auto_convert_with_mixed_content(): """Test that only text elements are converted to guarded_text, other content types are preserved.""" config = AmazonConverseConfig() - + messages = [ { "role": "user", @@ -2005,26 +2023,26 @@ def test_auto_convert_with_mixed_content(): ] } ] - + optional_params = { "guardrailConfig": { "guardrailIdentifier": "gr-abc123", "guardrailVersion": "1" } } - + # Test the helper method directly converted_messages = config._convert_consecutive_user_messages_to_guarded_text(messages, optional_params) - + # Verify the conversion assert len(converted_messages) == 1 assert converted_messages[0]["role"] == "user" assert len(converted_messages[0]["content"]) == 2 - + # First element should be converted to guarded_text assert converted_messages[0]["content"][0]["type"] == "guarded_text" assert converted_messages[0]["content"][0]["text"] == "What is the main topic of this legal document?" - + # Second element should remain unchanged assert converted_messages[0]["content"][1]["type"] == "image_url" assert converted_messages[0]["content"][1]["image_url"]["url"] == "https://example.com/image.jpg" @@ -2033,7 +2051,7 @@ def test_auto_convert_with_mixed_content(): def test_auto_convert_in_full_transformation(): """Test that the automatic conversion works in the full transformation pipeline.""" config = AmazonConverseConfig() - + messages = [ { "role": "user", @@ -2045,14 +2063,14 @@ def test_auto_convert_in_full_transformation(): ] } ] - + optional_params = { "guardrailConfig": { "guardrailIdentifier": "gr-abc123", "guardrailVersion": "1" } } - + # Test the full transformation result = config._transform_request( model="anthropic.claude-3-sonnet-20240229-v1:0", @@ -2061,7 +2079,7 @@ def test_auto_convert_in_full_transformation(): litellm_params={}, headers={} ) - + # Verify the transformation worked assert "messages" in result assert len(result["messages"]) == 1 @@ -2074,3 +2092,487 @@ def test_auto_convert_in_full_transformation(): assert message["content"][0]["guardContent"]["text"]["text"] == "What is the main topic of this legal document?" +def test_convert_consecutive_user_messages_to_guarded_text(): + """Test that consecutive user messages at the end are converted to guarded_text.""" + config = AmazonConverseConfig() + + messages = [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "First user message" + } + ] + }, + { + "role": "assistant", + "content": "Assistant response" + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Second user message" + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Third user message" + } + ] + } + ] + + optional_params = { + "guardrailConfig": { + "guardrailIdentifier": "gr-abc123", + "guardrailVersion": "1" + } + } + + # Test the helper method directly + converted_messages = config._convert_consecutive_user_messages_to_guarded_text(messages, optional_params) + + # Verify the conversion - only the last two user messages should be converted + assert len(converted_messages) == 4 + + # First user message should remain unchanged + assert converted_messages[0]["role"] == "user" + assert converted_messages[0]["content"][0]["type"] == "text" + assert converted_messages[0]["content"][0]["text"] == "First user message" + + # Assistant message should remain unchanged + assert converted_messages[1]["role"] == "assistant" + assert converted_messages[1]["content"] == "Assistant response" + + # Second user message should be converted to guarded_text + assert converted_messages[2]["role"] == "user" + assert converted_messages[2]["content"][0]["type"] == "guarded_text" + assert converted_messages[2]["content"][0]["text"] == "Second user message" + + # Third user message should be converted to guarded_text + assert converted_messages[3]["role"] == "user" + assert converted_messages[3]["content"][0]["type"] == "guarded_text" + assert converted_messages[3]["content"][0]["text"] == "Third user message" + + +def test_convert_all_user_messages_when_all_consecutive(): + """Test that all user messages are converted when they are all consecutive at the end.""" + config = AmazonConverseConfig() + + messages = [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "First user message" + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Second user message" + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Third user message" + } + ] + } + ] + + optional_params = { + "guardrailConfig": { + "guardrailIdentifier": "gr-abc123", + "guardrailVersion": "1" + } + } + + # Test the helper method directly + converted_messages = config._convert_consecutive_user_messages_to_guarded_text(messages, optional_params) + + # Verify all three user messages are converted + assert len(converted_messages) == 3 + + for i in range(3): + assert converted_messages[i]["role"] == "user" + assert converted_messages[i]["content"][0]["type"] == "guarded_text" + + assert converted_messages[0]["content"][0]["text"] == "First user message" + assert converted_messages[1]["content"][0]["text"] == "Second user message" + assert converted_messages[2]["content"][0]["text"] == "Third user message" + + +def test_convert_consecutive_user_messages_with_string_content(): + """Test that consecutive user messages with string content are converted to guarded_text.""" + config = AmazonConverseConfig() + + messages = [ + { + "role": "assistant", + "content": "Assistant response" + }, + { + "role": "user", + "content": "First user message" + }, + { + "role": "user", + "content": "Second user message" + } + ] + + optional_params = { + "guardrailConfig": { + "guardrailIdentifier": "gr-abc123", + "guardrailVersion": "1" + } + } + + # Test the helper method directly + converted_messages = config._convert_consecutive_user_messages_to_guarded_text(messages, optional_params) + + # Verify the conversion + assert len(converted_messages) == 3 + + # Assistant message should remain unchanged + assert converted_messages[0]["role"] == "assistant" + assert converted_messages[0]["content"] == "Assistant response" + + # Both user messages should be converted to guarded_text + assert converted_messages[1]["role"] == "user" + assert len(converted_messages[1]["content"]) == 1 + assert converted_messages[1]["content"][0]["type"] == "guarded_text" + assert converted_messages[1]["content"][0]["text"] == "First user message" + + assert converted_messages[2]["role"] == "user" + assert len(converted_messages[2]["content"]) == 1 + assert converted_messages[2]["content"][0]["type"] == "guarded_text" + assert converted_messages[2]["content"][0]["text"] == "Second user message" + + +def test_skip_consecutive_user_messages_with_existing_guarded_text(): + """Test that consecutive user messages with existing guarded_text are skipped.""" + config = AmazonConverseConfig() + + messages = [ + { + "role": "user", + "content": [ + { + "type": "guarded_text", + "text": "Already guarded" + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Should be converted" + } + ] + } + ] + + optional_params = { + "guardrailConfig": { + "guardrailIdentifier": "gr-abc123", + "guardrailVersion": "1" + } + } + + # Test the helper method directly + converted_messages = config._convert_consecutive_user_messages_to_guarded_text(messages, optional_params) + + # Verify the conversion + assert len(converted_messages) == 2 + + # First message should remain unchanged (already has guarded_text) + assert converted_messages[0]["role"] == "user" + assert converted_messages[0]["content"][0]["type"] == "guarded_text" + assert converted_messages[0]["content"][0]["text"] == "Already guarded" + + # Second message should be converted + assert converted_messages[1]["role"] == "user" + assert converted_messages[1]["content"][0]["type"] == "guarded_text" + assert converted_messages[1]["content"][0]["text"] == "Should be converted" + + +def test_request_metadata_parameter_support(): + """Test that requestMetadata is in supported parameters.""" + config = AmazonConverseConfig() + supported_params = config.get_supported_openai_params( + model="bedrock/converse/us.anthropic.claude-sonnet-4-20250514-v1:0" + ) + assert "requestMetadata" in supported_params + + +def test_request_metadata_transformation(): + """Test that requestMetadata is properly transformed to top-level field.""" + config = AmazonConverseConfig() + + request_metadata = { + "cost_center": "engineering", + "user_id": "user123", + "session_id": "sess_abc123" + } + + messages = [ + {"role": "user", "content": "Hello!"}, + ] + + # Transform request with requestMetadata + request_data = config.transform_request( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + messages=messages, + optional_params={"requestMetadata": request_metadata}, + litellm_params={}, + headers={} + ) + + # Verify that requestMetadata appears as top-level field + assert "requestMetadata" in request_data + assert request_data["requestMetadata"] == request_metadata + + +def test_request_metadata_validation(): + """Test validation of requestMetadata constraints.""" + config = AmazonConverseConfig() + + messages = [{"role": "user", "content": "Hello!"}] + + # Test valid metadata + valid_metadata = { + "cost_center": "engineering", + "user_id": "user123", + } + + # Should not raise exception + config.transform_request( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + messages=messages, + optional_params={"requestMetadata": valid_metadata}, + litellm_params={}, + headers={} + ) + + # Test too many items (max 16) + too_many_items = {f"key_{i}": f"value_{i}" for i in range(17)} + + try: + config.transform_request( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + messages=messages, + optional_params={"requestMetadata": too_many_items}, + litellm_params={}, + headers={} + ) + assert False, "Should have raised validation error for too many items" + except Exception as e: + assert "maximum of 16 items" in str(e).lower() + + +def test_request_metadata_key_constraints(): + """Test key constraint validation.""" + config = AmazonConverseConfig() + + messages = [{"role": "user", "content": "Hello!"}] + + # Test key too long (max 256 characters) + long_key = "a" * 257 + invalid_metadata = {long_key: "value"} + + try: + config.transform_request( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + messages=messages, + optional_params={"requestMetadata": invalid_metadata}, + litellm_params={}, + headers={} + ) + assert False, "Should have raised validation error for key too long" + except Exception as e: + assert "key length" in str(e).lower() or "256 characters" in str(e).lower() + + # Test empty key + invalid_metadata = {"": "value"} + + try: + config.transform_request( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + messages=messages, + optional_params={"requestMetadata": invalid_metadata}, + litellm_params={}, + headers={} + ) + assert False, "Should have raised validation error for empty key" + except Exception as e: + assert "key length" in str(e).lower() or "empty" in str(e).lower() + + +def test_request_metadata_value_constraints(): + """Test value constraint validation.""" + config = AmazonConverseConfig() + + messages = [{"role": "user", "content": "Hello!"}] + + # Test value too long (max 256 characters) + long_value = "a" * 257 + invalid_metadata = {"key": long_value} + + try: + config.transform_request( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + messages=messages, + optional_params={"requestMetadata": invalid_metadata}, + litellm_params={}, + headers={} + ) + assert False, "Should have raised validation error for value too long" + except Exception as e: + assert "value length" in str(e).lower() or "256 characters" in str(e).lower() + + # Test empty value (should be allowed) + valid_metadata = {"key": ""} + + # Should not raise exception + config.transform_request( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + messages=messages, + optional_params={"requestMetadata": valid_metadata}, + litellm_params={}, + headers={} + ) + + +def test_request_metadata_character_pattern(): + """Test character pattern validation for keys and values.""" + config = AmazonConverseConfig() + + messages = [{"role": "user", "content": "Hello!"}] + + # Test valid characters + valid_metadata = { + "cost-center_2024": "engineering@team#1", + "user:id": "$100.00", + "session+token": "/path/to=resource", + } + + # Should not raise exception + config.transform_request( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + messages=messages, + optional_params={"requestMetadata": valid_metadata}, + litellm_params={}, + headers={} + ) + + +def test_request_metadata_with_other_params(): + """Test that requestMetadata works alongside other parameters.""" + config = AmazonConverseConfig() + + request_metadata = { + "experiment": "test_A", + "user_type": "premium" + } + + messages = [ + {"role": "user", "content": "What's the weather?"}, + ] + + tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather", + "parameters": { + "type": "object", + "properties": { + "location": {"type": "string"} + }, + "required": ["location"] + } + } + } + ] + + # Transform request with multiple parameters including request_metadata + request_data = config.transform_request( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + messages=messages, + optional_params={ + "requestMetadata": request_metadata, + "tools": tools, + "max_tokens": 100, + "temperature": 0.7 + }, + litellm_params={}, + headers={} + ) + + # Verify requestMetadata is at top level + assert "requestMetadata" in request_data + assert request_data["requestMetadata"] == request_metadata + + # Verify other parameters are also processed correctly + assert "toolConfig" in request_data + assert "inferenceConfig" in request_data + assert request_data["inferenceConfig"]["temperature"] == 0.7 + + +def test_request_metadata_empty(): + """Test handling of empty requestMetadata.""" + config = AmazonConverseConfig() + + messages = [{"role": "user", "content": "Hello!"}] + + # Empty dict should be allowed + request_data = config.transform_request( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + messages=messages, + optional_params={"requestMetadata": {}}, + litellm_params={}, + headers={} + ) + + assert "requestMetadata" in request_data + assert request_data["requestMetadata"] == {} + + +def test_request_metadata_not_provided(): + """Test that requestMetadata is not included when not provided.""" + config = AmazonConverseConfig() + + messages = [{"role": "user", "content": "Hello!"}] + + # No requestMetadata provided + request_data = config.transform_request( + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + messages=messages, + optional_params={}, + litellm_params={}, + headers={} + ) + + # requestMetadata should not be in the request + assert "requestMetadata" not in request_data