From 18101345a015540c3b5643f2e0c4206c54700f72 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Tue, 7 Jul 2026 17:30:19 -0400 Subject: [PATCH 01/11] fix(responses): propagate message cache_control safely through objects and models --- .../context_caching/transformation.py | 20 +- .../transformation.py | 65 +- litellm/utils.py | 53 +- .../test_litellm_completion_responses.py | 559 ++++++++---------- 4 files changed, 347 insertions(+), 350 deletions(-) diff --git a/litellm/llms/vertex_ai/context_caching/transformation.py b/litellm/llms/vertex_ai/context_caching/transformation.py index f0ce3323ef66..183d9743f13f 100644 --- a/litellm/llms/vertex_ai/context_caching/transformation.py +++ b/litellm/llms/vertex_ai/context_caching/transformation.py @@ -62,23 +62,27 @@ def extract_ttl_from_cached_messages(messages: List[AllMessageValues]) -> Option if not is_cached_message(message): continue - content = message.get("content") + content = message.get("content") if isinstance(message, dict) else getattr(message, "content", None) if not content or isinstance(content, str): continue for content_item in content: - # Type check to ensure content_item is a dictionary before calling .get() - if not isinstance(content_item, dict): - continue + # Check if content_item is dict or object model + if isinstance(content_item, dict): + cache_control = content_item.get("cache_control") + else: + cache_control = getattr(content_item, "cache_control", None) - cache_control = content_item.get("cache_control") - if not cache_control or not isinstance(cache_control, dict): + if not cache_control: continue - if cache_control.get("type") != "ephemeral": + cc_type = ( + cache_control.get("type") if isinstance(cache_control, dict) else getattr(cache_control, "type", None) + ) + if cc_type != "ephemeral": continue - ttl = cache_control.get("ttl") + ttl = cache_control.get("ttl") if isinstance(cache_control, dict) else getattr(cache_control, "ttl", None) if ttl and _is_valid_ttl_format(ttl): return str(ttl) diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 6b1ca3564e36..8052f69f22bc 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -892,26 +892,38 @@ def _transform_responses_api_input_item_to_chat_completion_message( function_call=input_item ) else: - content = input_item.get("content") + content = ( + input_item.get("content") if isinstance(input_item, dict) else getattr(input_item, "content", None) + ) # Handle None content: Responses API allows None content, but GenericChatCompletionMessage requires content # Since guardrails skip None content anyway, we return empty list to exclude it from structured messages if content is None: return [] - return [ - GenericChatCompletionMessage( - role=input_item.get("role") or "user", - content=LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content( - content - ), - ) - ] + + role = input_item.get("role") if isinstance(input_item, dict) else getattr(input_item, "role", None) + cache_control = ( + input_item.get("cache_control") + if isinstance(input_item, dict) + else getattr(input_item, "cache_control", None) + ) + + msg = GenericChatCompletionMessage( + role=role or "user", + content=LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content( + content + ), + ) + if cache_control is not None: + msg["cache_control"] = cache_control + return [msg] @staticmethod def _is_input_item_tool_call_output(input_item: Any) -> bool: """ Check if the input item is a tool call output """ - return input_item.get("type") in [ + val = input_item.get("type") if isinstance(input_item, dict) else getattr(input_item, "type", None) + return val in [ "function_call_output", "custom_tool_call_output", "web_search_call", @@ -926,7 +938,8 @@ def _is_input_item_function_call(input_item: Any) -> bool: Both need to be reconstructed as assistant tool_calls for Chat Completions providers. """ - return input_item.get("type") in ("function_call", "custom_tool_call") + val = input_item.get("type") if isinstance(input_item, dict) else getattr(input_item, "type", None) + return val in ("function_call", "custom_tool_call") @staticmethod def _transform_responses_api_tool_call_output_to_chat_completion_message( @@ -1155,6 +1168,31 @@ def _transform_input_image_item_to_image_item( return ChatCompletionImageObject(type="image_url", image_url=image_url_obj) + @staticmethod + def _normalize_responses_api_object_to_dict(item: Any) -> dict[str, Any]: + """ + Normalize a Responses API object (Pydantic model or custom class) to a dictionary + """ + if hasattr(item, "model_dump"): + return item.model_dump() + elif hasattr(item, "dict"): + return item.dict() + + item_dict = {} + for attr in [ + "type", + "text", + "cache_control", + "file_id", + "file_data", + "file_url", + "image_url", + "detail", + ]: + if hasattr(item, attr): + item_dict[attr] = getattr(item, attr) + return item_dict + @staticmethod def _transform_responses_api_content_to_chat_completion_content( content: Any, @@ -1176,7 +1214,10 @@ def _transform_responses_api_content_to_chat_completion_content( for item in content: if isinstance(item, str): content_list.append(item) - elif isinstance(item, dict): + elif isinstance(item, dict) or (item is not None and not isinstance(item, (str, int, float, bool))): + if not isinstance(item, dict): + item = LiteLLMCompletionResponsesConfig._normalize_responses_api_object_to_dict(item) + if item.get("type") == "input_file": content_list.append( LiteLLMCompletionResponsesConfig._transform_input_file_item_to_file_item(item) diff --git a/litellm/utils.py b/litellm/utils.py index a11c5500503e..3fdf0a5eee68 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -7209,36 +7209,51 @@ def is_cached_message(message: AllMessageValues) -> bool: return False # Check message-level cache_control (set by cache_control_injection_points hook for string content) - message_level_cache_control = message.get("cache_control") - if ( - message_level_cache_control is not None - and isinstance(message_level_cache_control, dict) - and message_level_cache_control.get("type") == "ephemeral" - ): - return True - - if "content" not in message: - return False + message_level_cache_control = ( + message.get("cache_control") + if isinstance(message, dict) + else getattr(message, "cache_control", None) + ) + if message_level_cache_control is not None: + cc_type = ( + message_level_cache_control.get("type") + if isinstance(message_level_cache_control, dict) + else getattr(message_level_cache_control, "type", None) + ) + if cc_type == "ephemeral": + return True - content = message["content"] + if isinstance(message, dict): + if "content" not in message: + return False + content = message["content"] + else: + content = getattr(message, "content", None) # Handle non-list content types (None, str, etc.) if not isinstance(content, list): return False for content_item in content: - # Ensure content_item is a dictionary before accessing keys - if not isinstance(content_item, dict): - continue + # Check if content_item is dict or object model + if isinstance(content_item, dict): + cache_control = content_item.get("cache_control") + item_type = content_item.get("type") + else: + cache_control = getattr(content_item, "cache_control", None) + item_type = getattr(content_item, "type", None) - cache_control = content_item.get("cache_control") if ( - content_item.get("type") == "text" + item_type == "text" and cache_control is not None - and isinstance(cache_control, dict) - and cache_control.get("type") == "ephemeral" ): - return True + cc_type = ( + cache_control.get("type") + if isinstance(cache_control, dict) + else getattr(cache_control, "type", None) + ) + if cc_type == "ephemeral": + return True return False diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py index d8e3f495cedf..4b07d638261a 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -3,9 +3,7 @@ import pytest -sys.path.insert( - 0, os.path.abspath("../../..") -) # Adds the parent directory to the system path +sys.path.insert(0, os.path.abspath("../../..")) # Adds the parent directory to the system path from litellm.responses.litellm_completion_transformation.transformation import ( TOOL_CALLS_CACHE, @@ -34,11 +32,7 @@ def test_transform_input_file_item_to_file_item_with_file_id(self): input_item = {"type": "input_file", "file_id": "file-abc123xyz"} # Execute - result = ( - LiteLLMCompletionResponsesConfig._transform_input_file_item_to_file_item( - input_item - ) - ) + result = LiteLLMCompletionResponsesConfig._transform_input_file_item_to_file_item(input_item) # Assert expected = {"type": "file", "file": {"file_id": "file-abc123xyz"}} @@ -53,11 +47,7 @@ def test_transform_input_file_item_to_file_item_with_file_data(self): input_item = {"type": "input_file", "file_data": file_data} # Execute - result = ( - LiteLLMCompletionResponsesConfig._transform_input_file_item_to_file_item( - input_item - ) - ) + result = LiteLLMCompletionResponsesConfig._transform_input_file_item_to_file_item(input_item) # Assert expected = {"type": "file", "file": {"file_data": file_data}} @@ -75,11 +65,7 @@ def test_transform_input_file_item_to_file_item_with_both_fields(self): } # Execute - result = ( - LiteLLMCompletionResponsesConfig._transform_input_file_item_to_file_item( - input_item - ) - ) + result = LiteLLMCompletionResponsesConfig._transform_input_file_item_to_file_item(input_item) # Assert expected = { @@ -97,11 +83,7 @@ def test_transform_input_file_item_to_file_item_empty_file_fields(self): input_item = {"type": "input_file"} # Execute - result = ( - LiteLLMCompletionResponsesConfig._transform_input_file_item_to_file_item( - input_item - ) - ) + result = LiteLLMCompletionResponsesConfig._transform_input_file_item_to_file_item(input_item) # Assert expected = {"type": "file", "file": {}} @@ -120,11 +102,7 @@ def test_transform_input_file_item_to_file_item_ignores_other_fields(self): } # Execute - result = ( - LiteLLMCompletionResponsesConfig._transform_input_file_item_to_file_item( - input_item - ) - ) + result = LiteLLMCompletionResponsesConfig._transform_input_file_item_to_file_item(input_item) # Assert expected = {"type": "file", "file": {"file_id": "file-abc123xyz"}} @@ -134,10 +112,8 @@ def test_transform_input_file_item_to_file_item_ignores_other_fields(self): def test_transform_input_file_item_to_file_item_with_file_url(self): """file_url should be mapped to file_id for downstream URL handling""" - result = ( - LiteLLMCompletionResponsesConfig._transform_input_file_item_to_file_item( - {"type": "input_file", "file_url": "https://example.com/doc.pdf"} - ) + result = LiteLLMCompletionResponsesConfig._transform_input_file_item_to_file_item( + {"type": "input_file", "file_url": "https://example.com/doc.pdf"} ) assert result == { "type": "file", @@ -146,14 +122,12 @@ def test_transform_input_file_item_to_file_item_with_file_url(self): def test_transform_input_file_item_file_id_takes_precedence_over_file_url(self): """explicit file_id should not be overwritten by file_url""" - result = ( - LiteLLMCompletionResponsesConfig._transform_input_file_item_to_file_item( - { - "type": "input_file", - "file_id": "file-abc123", - "file_url": "https://example.com/doc.pdf", - } - ) + result = LiteLLMCompletionResponsesConfig._transform_input_file_item_to_file_item( + { + "type": "input_file", + "file_id": "file-abc123", + "file_url": "https://example.com/doc.pdf", + } ) assert result == {"type": "file", "file": {"file_id": "file-abc123"}} @@ -164,11 +138,7 @@ def test_transform_input_image_item_to_image_item_with_image_url(self): input_item = {"type": "input_image", "image_url": image_url, "detail": "high"} # Execute - result = ( - LiteLLMCompletionResponsesConfig._transform_input_image_item_to_image_item( - input_item - ) - ) + result = LiteLLMCompletionResponsesConfig._transform_input_image_item_to_image_item(input_item) # Assert expected = { @@ -187,11 +157,7 @@ def test_transform_input_image_item_to_image_item_with_image_data(self): input_item = {"type": "input_image", "image_url": image_url, "detail": "high"} # Execute - result = ( - LiteLLMCompletionResponsesConfig._transform_input_image_item_to_image_item( - input_item - ) - ) + result = LiteLLMCompletionResponsesConfig._transform_input_image_item_to_image_item(input_item) # Assert expected = { @@ -210,11 +176,7 @@ def test_transform_input_image_item_to_image_item_without_detail(self): input_item = {"type": "input_image", "image_url": image_url} # Execute - result = ( - LiteLLMCompletionResponsesConfig._transform_input_image_item_to_image_item( - input_item - ) - ) + result = LiteLLMCompletionResponsesConfig._transform_input_image_item_to_image_item(input_item) # Assert expected = { @@ -232,11 +194,7 @@ def test_transform_input_image_item_to_image_item_empty_image_fields(self): input_item = {"type": "input_image"} # Execute - result = ( - LiteLLMCompletionResponsesConfig._transform_input_image_item_to_image_item( - input_item - ) - ) + result = LiteLLMCompletionResponsesConfig._transform_input_image_item_to_image_item(input_item) # Assert expected = {"type": "image_url", "image_url": {"url": "", "detail": "auto"}} @@ -256,11 +214,7 @@ def test_transform_input_image_item_to_image_item_ignores_other_fields(self): } # Execute - result = ( - LiteLLMCompletionResponsesConfig._transform_input_image_item_to_image_item( - input_item - ) - ) + result = LiteLLMCompletionResponsesConfig._transform_input_image_item_to_image_item(input_item) # Assert expected = { @@ -296,28 +250,26 @@ def test_transform_chat_completion_response_with_reasoning_content(self): ) # Execute - responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( - request_input="What is the meaning of life?", - responses_api_request={}, - chat_completion_response=chat_completion_response, + responses_api_response = ( + LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="What is the meaning of life?", + responses_api_request={}, + chat_completion_response=chat_completion_response, + ) ) # Assert assert hasattr(responses_api_response, "output") assert len(responses_api_response.output) >= 2 - reasoning_items = [ - item for item in responses_api_response.output if item.type == "reasoning" - ] + reasoning_items = [item for item in responses_api_response.output if item.type == "reasoning"] assert len(reasoning_items) == 1, "Should have exactly one reasoning item" reasoning_item = reasoning_items[0] # Note: ID auto-generation was disabled, so reasoning items may not have IDs # Only assert ID format if an ID is present if hasattr(reasoning_item, "id") and reasoning_item.id: - assert reasoning_item.id.startswith( - "rs_" - ), f"Expected ID to start with 'rs_', got: {reasoning_item.id}" + assert reasoning_item.id.startswith("rs_"), f"Expected ID to start with 'rs_', got: {reasoning_item.id}" assert reasoning_item.status == "completed" assert reasoning_item.role == "assistant" assert len(reasoning_item.content) == 1 @@ -325,9 +277,7 @@ def test_transform_chat_completion_response_with_reasoning_content(self): assert "step by step" in reasoning_item.content[0].text assert "42" in reasoning_item.content[0].text - message_items = [ - item for item in responses_api_response.output if item.type == "message" - ] + message_items = [item for item in responses_api_response.output if item.type == "message"] assert len(message_items) == 1, "Should have exactly one message item" message_item = message_items[0] @@ -354,21 +304,19 @@ def test_transform_chat_completion_response_without_reasoning_content(self): ) # Execute - responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( - request_input="A simple question?", - responses_api_request={}, - chat_completion_response=chat_completion_response, + responses_api_response = ( + LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="A simple question?", + responses_api_request={}, + chat_completion_response=chat_completion_response, + ) ) # Assert - reasoning_items = [ - item for item in responses_api_response.output if item.type == "reasoning" - ] + reasoning_items = [item for item in responses_api_response.output if item.type == "reasoning"] assert len(reasoning_items) == 0, "Should have no reasoning items" - message_items = [ - item for item in responses_api_response.output if item.type == "message" - ] + message_items = [item for item in responses_api_response.output if item.type == "message"] assert len(message_items) == 1, "Should have exactly one message item" assert message_items[0].content[0].text == "Just a regular answer." assert responses_api_response.object == "response" @@ -404,22 +352,20 @@ def test_transform_chat_completion_response_multiple_choices_with_reasoning(self ) # Execute - responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( - request_input="A question with multiple answers?", - responses_api_request={}, - chat_completion_response=chat_completion_response, + responses_api_response = ( + LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="A question with multiple answers?", + responses_api_request={}, + chat_completion_response=chat_completion_response, + ) ) # Assert - reasoning_items = [ - item for item in responses_api_response.output if item.type == "reasoning" - ] + reasoning_items = [item for item in responses_api_response.output if item.type == "reasoning"] assert len(reasoning_items) == 1, "Should have exactly one reasoning item" assert reasoning_items[0].content[0].text == "First reasoning process." - message_items = [ - item for item in responses_api_response.output if item.type == "message" - ] + message_items = [item for item in responses_api_response.output if item.type == "message"] assert len(message_items) == 2, "Should have two message items" def test_transform_chat_completion_response_status_with_stop(self): @@ -446,10 +392,12 @@ def test_transform_chat_completion_response_status_with_stop(self): ], ) - responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( - request_input="this is a test", - responses_api_request={}, - chat_completion_response=chat_completion_response, + responses_api_response = ( + LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="this is a test", + responses_api_request={}, + chat_completion_response=chat_completion_response, + ) ) assert responses_api_response.status == "completed" @@ -485,15 +433,15 @@ def test_transform_chat_completion_response_output_item_status(self): ], ) - responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( - request_input="this is a test", - responses_api_request={}, - chat_completion_response=chat_completion_response, + responses_api_response = ( + LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="this is a test", + responses_api_request={}, + chat_completion_response=chat_completion_response, + ) ) - message_items = [ - item for item in responses_api_response.output if item.type == "message" - ] + message_items = [item for item in responses_api_response.output if item.type == "message"] assert len(message_items) > 0 for item in message_items: @@ -528,10 +476,12 @@ def test_transform_chat_completion_response_status_with_refusal(self): ], ) - responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( - request_input="this is a test", - responses_api_request={}, - chat_completion_response=chat_completion_response, + responses_api_response = ( + LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="this is a test", + responses_api_request={}, + chat_completion_response=chat_completion_response, + ) ) assert responses_api_response.status == "incomplete" @@ -563,10 +513,12 @@ def test_transform_chat_completion_response_preserves_hidden_params(self): } # Execute - responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( - request_input="Test", - responses_api_request={}, - chat_completion_response=chat_completion_response, + responses_api_response = ( + LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="Test", + responses_api_request={}, + chat_completion_response=chat_completion_response, + ) ) # Assert @@ -598,10 +550,12 @@ def test_transform_chat_completion_response_handles_missing_hidden_params(self): ) # Execute - responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( - request_input="Test", - responses_api_request={}, - chat_completion_response=chat_completion_response, + responses_api_response = ( + LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="Test", + responses_api_request={}, + chat_completion_response=chat_completion_response, + ) ) # Assert - should default to empty dict @@ -630,26 +584,14 @@ def test_function_call_detection(self): regular_message = {"type": "message", "role": "user", "content": "Hello"} # Test function_call detection - assert LiteLLMCompletionResponsesConfig._is_input_item_function_call( - function_call_item - ) - assert not LiteLLMCompletionResponsesConfig._is_input_item_function_call( - function_call_output_item - ) - assert not LiteLLMCompletionResponsesConfig._is_input_item_function_call( - regular_message - ) + assert LiteLLMCompletionResponsesConfig._is_input_item_function_call(function_call_item) + assert not LiteLLMCompletionResponsesConfig._is_input_item_function_call(function_call_output_item) + assert not LiteLLMCompletionResponsesConfig._is_input_item_function_call(regular_message) # Test function_call_output detection (should still work) - assert LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output( - function_call_output_item - ) - assert not LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output( - function_call_item - ) - assert not LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output( - regular_message - ) + assert LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output(function_call_output_item) + assert not LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output(function_call_item) + assert not LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output(regular_message) def test_function_call_transformation(self): """Test that function_call items are correctly transformed to assistant messages with tool calls""" @@ -733,9 +675,7 @@ def test_complete_input_transformation_with_function_calls(self): tool_msg = messages[2] assert tool_msg.get("role") == "tool" assert tool_msg.get("content") == "Rainy" - assert ( - tool_msg.get("tool_call_id") == "call_1fe70e2a-a596-45ef-b72c-9b8567c460e5" - ) + assert tool_msg.get("tool_call_id") == "call_1fe70e2a-a596-45ef-b72c-9b8567c460e5" def test_complete_request_transformation_with_function_calls(self): """Test the complete request transformation that would be used by the responses API""" @@ -940,9 +880,7 @@ def test_transform_tool_choice_cursor_bug_fix(self): Test that {"type": "tool"} is transformed to "required". This fixes the Anthropic error: "tool_choice.tool.name: Field required" """ - result = LiteLLMCompletionResponsesConfig._transform_tool_choice( - {"type": "tool"} - ) + result = LiteLLMCompletionResponsesConfig._transform_tool_choice({"type": "tool"}) assert result == "required" def test_transform_tool_choice_preserves_function_with_name(self): @@ -954,23 +892,17 @@ def test_transform_tool_choice_preserves_function_with_name(self): def test_transform_tool_choice_responses_flat_function_name(self): """Responses-API forced-function with a top-level name maps to the nested Chat Completions shape instead of degrading to required and dropping the name""" - result = LiteLLMCompletionResponsesConfig._transform_tool_choice( - {"type": "function", "name": "get_weather"} - ) + result = LiteLLMCompletionResponsesConfig._transform_tool_choice({"type": "function", "name": "get_weather"}) assert result == {"type": "function", "function": {"name": "get_weather"}} def test_transform_tool_choice_function_without_name_falls_back_to_required(self): """A function-type dict with no name still falls back to required""" - result = LiteLLMCompletionResponsesConfig._transform_tool_choice( - {"type": "function"} - ) + result = LiteLLMCompletionResponsesConfig._transform_tool_choice({"type": "function"}) assert result == "required" def test_transform_tool_choice_function_empty_name_falls_back_to_required(self): """An empty top-level name is falsy and must not produce an empty function name""" - result = LiteLLMCompletionResponsesConfig._transform_tool_choice( - {"type": "function", "name": ""} - ) + result = LiteLLMCompletionResponsesConfig._transform_tool_choice({"type": "function", "name": ""}) assert result == "required" @@ -982,20 +914,12 @@ def test_tool_result_content_type_transformed_to_text(self): Test that 'tool_result' content type is transformed to 'text'. This fixes: Invalid user message - content type 'tool_result' not valid. """ - result = ( - LiteLLMCompletionResponsesConfig._get_chat_completion_request_content_type( - "tool_result" - ) - ) + result = LiteLLMCompletionResponsesConfig._get_chat_completion_request_content_type("tool_result") assert result == "text" def test_input_text_content_type_transformed_to_text(self): """Test that 'input_text' content type is transformed to 'text'""" - result = ( - LiteLLMCompletionResponsesConfig._get_chat_completion_request_content_type( - "input_text" - ) - ) + result = LiteLLMCompletionResponsesConfig._get_chat_completion_request_content_type("input_text") assert result == "text" def test_none_text_blocks_filtered_out(self): @@ -1009,9 +933,7 @@ def test_none_text_blocks_filtered_out(self): {"type": "text", "text": None}, # Should be filtered out {"type": "text", "text": "another valid"}, ] - result = LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content( - content - ) + result = LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content(content) assert len(result) == 2 assert result[0]["text"] == "valid text" assert result[1]["text"] == "another valid" @@ -1033,9 +955,7 @@ def test_transform_vertex_ai_tools(self): ( result_tools, web_search_options, - ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(tools=tools) # Assert assert len(result_tools) == 1 @@ -1057,9 +977,7 @@ def test_transform_mcp_tools(self): ( result_tools, web_search_options, - ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(tools=tools) # Assert assert len(result_tools) == 1 @@ -1087,9 +1005,7 @@ def test_transform_computer_use_tools(self): ( result_tools, web_search_options, - ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(tools=tools) # Assert - computer_use has no Chat Completions equivalent, so it is dropped assert len(result_tools) == 0 @@ -1114,9 +1030,7 @@ def test_transform_custom_tools_to_function_tools(self): ( result_tools, web_search_options, - ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(tools=tools) # Assert - custom tool is converted to a function tool assert len(result_tools) == 1 @@ -1141,9 +1055,7 @@ def test_transform_custom_tools_without_format(self): ( result_tools, web_search_options, - ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(tools=tools) # Assert assert len(result_tools) == 1 @@ -1167,9 +1079,7 @@ def test_transform_custom_tools_preserves_allowed_callers(self): ( result_tools, _, - ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(tools=tools) # Assert assert len(result_tools) == 1 @@ -1190,9 +1100,7 @@ def test_transform_custom_tools_without_allowed_callers(self): ( result_tools, _, - ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(tools=tools) # Assert assert len(result_tools) == 1 @@ -1211,9 +1119,7 @@ def test_transform_custom_tools_rejects_invalid_allowed_callers(self): tools = [custom_tool] with pytest.raises(ValueError, match="allowed_callers must be a list of strings"): - LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(tools=tools) def test_transform_web_search_tools_to_web_search_options(self): """Test that web_search tools are converted to web_search_options""" @@ -1229,9 +1135,7 @@ def test_transform_web_search_tools_to_web_search_options(self): ( result_tools, web_search_options, - ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(tools=tools) # Assert assert len(result_tools) == 0 # Web search is not added to tools @@ -1262,9 +1166,7 @@ def test_transform_function_tools_with_anthropic_specific_fields(self): ( result_tools, web_search_options, - ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(tools=tools) # Assert assert len(result_tools) == 1 @@ -1294,9 +1196,7 @@ def test_transform_function_tools_with_cache_control_only(self): ( result_tools, _, - ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(tools=tools) # Assert assert len(result_tools) == 1 @@ -1322,9 +1222,7 @@ def test_transform_function_tools_without_anthropic_fields(self): ( result_tools, _, - ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(tools=tools) # Assert assert len(result_tools) == 1 @@ -1350,9 +1248,7 @@ def test_transform_code_execution_tools(self): ( result_tools, _, - ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(tools=tools) # Assert assert len(result_tools) == 1 @@ -1376,9 +1272,7 @@ def test_transform_tool_search_tools(self): ( result_tools, _, - ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(tools=tools) # Assert assert len(result_tools) == 2 @@ -1410,14 +1304,10 @@ def test_transform_mixed_tools_list(self): ( result_tools, web_search_options, - ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(tools=tools) # Assert - assert ( - len(result_tools) == 3 - ) # function, mcp, vertex (web_search becomes options) + assert len(result_tools) == 3 # function, mcp, vertex (web_search becomes options) assert web_search_options is not None # Check function tool @@ -1447,9 +1337,7 @@ def test_transform_function_tools_parameters_with_missing_type(self): ( result_tools, _, - ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(tools=tools) # Assert assert len(result_tools) == 1 @@ -1472,9 +1360,7 @@ def test_transform_function_tools_empty_parameters(self): ( result_tools, _, - ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(tools=tools) # Assert assert len(result_tools) == 1 @@ -1495,9 +1381,7 @@ def test_transform_function_tools_missing_parameters(self): ( result_tools, _, - ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(tools=tools) # Assert assert len(result_tools) == 1 @@ -1519,19 +1403,14 @@ def test_transform_function_tools_preserves_existing_type(self): ( result_tools, _, - ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(tools=tools) # Assert assert len(result_tools) == 1 result_tool = result_tools[0] assert result_tool["function"]["parameters"]["type"] == "object" assert "properties" in result_tool["function"]["parameters"] - assert ( - result_tool["function"]["parameters"]["properties"]["arg"]["type"] - == "string" - ) + assert result_tool["function"]["parameters"]["properties"]["arg"]["type"] == "string" def test_bedrock_anthropic_drops_derived_web_search_options(self): """ @@ -1657,11 +1536,7 @@ def test_bedrock_anthropic_responses_tools_yield_only_function_toolspec(self): assert "web_search_options" not in result bedrock_tool_blocks = _bedrock_tools_pt(tools=result["tools"], model=model) - names = [ - block["toolSpec"]["name"] - for block in bedrock_tool_blocks - if "toolSpec" in block - ] + names = [block["toolSpec"]["name"] for block in bedrock_tool_blocks if "toolSpec" in block] assert names == ["noop"] assert not any(name.startswith("litellm_unnamed_tool_") for name in names) @@ -1940,9 +1815,7 @@ def test_transform_usage_with_image_tokens(self): Choices( finish_reason="stop", index=0, - message=Message( - content="Here is the generated image.", role="assistant" - ), + message=Message(content="Here is the generated image.", role="assistant"), ) ], ) @@ -2166,9 +2039,7 @@ def test_streaming_iterator_uses_consistent_item_ids(self): # Verify the cached ID is set and matches assert iterator._cached_item_id is not None, "Iterator should cache the item_id" assert iterator._cached_item_id == item_id_1, "Cached ID should match event IDs" - assert ( - iterator._cached_item_id == "chatcmpl-first-id" - ), "Should use the first chunk's ID" + assert iterator._cached_item_id == "chatcmpl-first-id", "Should use the first chunk's ID" def test_streaming_iterator_initial_events_use_cached_id(self): """ @@ -2258,9 +2129,7 @@ def test_streaming_iterator_done_events_use_cached_id(self): # Create done events text_done_event = iterator.create_output_text_done_event(complete_response) - content_done_event = iterator.create_output_content_part_done_event( - complete_response - ) + content_done_event = iterator.create_output_content_part_done_event(complete_response) item_done_event = iterator.create_output_item_done_event(complete_response) # Extract IDs @@ -2320,27 +2189,19 @@ def test_parallel_tool_calls_merged_into_single_assistant_message(self): input=input_items ) - roles = [ - m.get("role") if isinstance(m, dict) else getattr(m, "role", None) - for m in messages - ] + roles = [m.get("role") if isinstance(m, dict) else getattr(m, "role", None) for m in messages] # Must not have two consecutive assistant messages for i in range(len(roles) - 1): - assert not ( - roles[i] == "assistant" and roles[i + 1] == "assistant" - ), f"Consecutive assistant messages at indices {i} and {i+1}: {roles}" + assert not (roles[i] == "assistant" and roles[i + 1] == "assistant"), ( + f"Consecutive assistant messages at indices {i} and {i + 1}: {roles}" + ) # The single assistant message must contain BOTH tool_calls assistant_messages = [ - m - for m in messages - if (m.get("role") if isinstance(m, dict) else getattr(m, "role", None)) - == "assistant" + m for m in messages if (m.get("role") if isinstance(m, dict) else getattr(m, "role", None)) == "assistant" ] - assert ( - len(assistant_messages) == 1 - ), f"Expected 1 assistant message, got {len(assistant_messages)}" + assert len(assistant_messages) == 1, f"Expected 1 assistant message, got {len(assistant_messages)}" assistant_msg = assistant_messages[0] tool_calls = ( @@ -2348,27 +2209,19 @@ def test_parallel_tool_calls_merged_into_single_assistant_message(self): if isinstance(assistant_msg, dict) else getattr(assistant_msg, "tool_calls", None) ) - assert ( - tool_calls is not None and len(tool_calls) == 2 - ), f"Expected 2 tool_calls in the merged assistant message, got: {tool_calls}" + assert tool_calls is not None and len(tool_calls) == 2, ( + f"Expected 2 tool_calls in the merged assistant message, got: {tool_calls}" + ) - call_ids = [ - (tc.get("id") if isinstance(tc, dict) else getattr(tc, "id", None)) - for tc in tool_calls - ] + call_ids = [(tc.get("id") if isinstance(tc, dict) else getattr(tc, "id", None)) for tc in tool_calls] assert "toolu_01" in call_ids, f"toolu_01 missing from tool_calls: {call_ids}" assert "toolu_02" in call_ids, f"toolu_02 missing from tool_calls: {call_ids}" # Both tool messages must be present tool_messages = [ - m - for m in messages - if (m.get("role") if isinstance(m, dict) else getattr(m, "role", None)) - == "tool" + m for m in messages if (m.get("role") if isinstance(m, dict) else getattr(m, "role", None)) == "tool" ] - assert ( - len(tool_messages) == 2 - ), f"Expected 2 tool messages, got {len(tool_messages)}" + assert len(tool_messages) == 2, f"Expected 2 tool messages, got {len(tool_messages)}" def test_single_tool_call_still_works_after_merge_fix(self): """ @@ -2390,20 +2243,14 @@ def test_single_tool_call_still_works_after_merge_fix(self): input=input_items ) - roles = [ - m.get("role") if isinstance(m, dict) else getattr(m, "role", None) - for m in messages - ] + roles = [m.get("role") if isinstance(m, dict) else getattr(m, "role", None) for m in messages] assert "user" in roles assert "assistant" in roles assert "tool" in roles assistant_messages = [ - m - for m in messages - if (m.get("role") if isinstance(m, dict) else getattr(m, "role", None)) - == "assistant" + m for m in messages if (m.get("role") if isinstance(m, dict) else getattr(m, "role", None)) == "assistant" ] assert len(assistant_messages) == 1 @@ -2576,9 +2423,7 @@ def _make_iterator(self): LiteLLMCompletionStreamingIterator, ) - iterator = LiteLLMCompletionStreamingIterator.__new__( - LiteLLMCompletionStreamingIterator - ) + iterator = LiteLLMCompletionStreamingIterator.__new__(LiteLLMCompletionStreamingIterator) iterator.sent_output_item_added_event = False iterator.sent_content_part_added_event = False iterator._sequence_number = 0 @@ -2671,9 +2516,7 @@ def test_emit_response_completed_uses_stream_finish_reason(self): usage=Usage(prompt_tokens=10, completion_tokens=1, total_tokens=11), ) - completed_event = iterator._emit_response_completed_event( - litellm_model_response - ) + completed_event = iterator._emit_response_completed_event(litellm_model_response) assert completed_event is not None assert completed_event.response.status == "incomplete" @@ -2716,9 +2559,7 @@ def test_cache_control_preserved_in_content_transformation(self): "cache_control": {"type": "ephemeral"}, } ] - result = LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content( - content - ) + result = LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content(content) assert isinstance(result, list) assert len(result) == 1 assert result[0]["cache_control"] == {"type": "ephemeral"} @@ -2726,9 +2567,7 @@ def test_cache_control_preserved_in_content_transformation(self): def test_content_without_cache_control_unaffected(self): """Content blocks that don't have cache_control should be unaffected.""" content = [{"type": "text", "text": "hello"}] - result = LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content( - content - ) + result = LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content(content) assert isinstance(result, list) assert len(result) == 1 assert "cache_control" not in result[0] @@ -2750,9 +2589,7 @@ def test_cache_control_preserved_in_input_item_transformation(self): ) assert len(messages) == 1 msg_content = ( - messages[0].get("content") - if isinstance(messages[0], dict) - else getattr(messages[0], "content", None) + messages[0].get("content") if isinstance(messages[0], dict) else getattr(messages[0], "content", None) ) assert isinstance(msg_content, list) assert msg_content[0]["cache_control"] == {"type": "ephemeral"} @@ -2765,9 +2602,7 @@ def test_cache_control_preserved_for_input_file_block(self): "cache_control": {"type": "ephemeral"}, } ] - result = LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content( - content - ) + result = LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content(content) assert isinstance(result, list) assert len(result) == 1 assert result[0]["cache_control"] == {"type": "ephemeral"} @@ -2780,13 +2615,121 @@ def test_cache_control_preserved_for_input_image_block(self): "cache_control": {"type": "ephemeral"}, } ] - result = LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content( - content - ) + result = LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content(content) assert isinstance(result, list) assert len(result) == 1 assert result[0]["cache_control"] == {"type": "ephemeral"} + def test_cache_control_preserved_for_object_input_item(self): + """Test that cache_control is preserved when input_item is a custom object / model.""" + + class MockInputItem: + def __init__(self): + self.role = "user" + self.content = "hello" + self.cache_control = {"type": "ephemeral"} + + input_item = MockInputItem() + messages = LiteLLMCompletionResponsesConfig._transform_responses_api_input_item_to_chat_completion_message( + input_item + ) + assert len(messages) == 1 + assert messages[0].get("cache_control") == {"type": "ephemeral"} + + def test_cache_control_preserved_for_object_content_item(self): + """Test that cache_control is preserved when content items are custom objects.""" + + class MockContentBlock: + def __init__(self): + self.type = "text" + self.text = "hello" + self.cache_control = {"type": "ephemeral"} + + class MockPydanticV2Block: + def __init__(self): + self.type = "text" + self.text = "hello v2" + self.cache_control = {"type": "ephemeral"} + + def model_dump(self): + return {"type": self.type, "text": self.text, "cache_control": self.cache_control} + + class MockPydanticV1Block: + def __init__(self): + self.type = "text" + self.text = "hello v1" + self.cache_control = {"type": "ephemeral"} + + def dict(self): + return {"type": self.type, "text": self.text, "cache_control": self.cache_control} + + content = [MockContentBlock(), MockPydanticV2Block(), MockPydanticV1Block()] + result = LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content(content) + assert isinstance(result, list) + assert len(result) == 3 + assert result[0]["cache_control"] == {"type": "ephemeral"} + assert result[1]["cache_control"] == {"type": "ephemeral"} + assert result[2]["cache_control"] == {"type": "ephemeral"} + assert result[1]["text"] == "hello v2" + assert result[2]["text"] == "hello v1" + + def test_is_cached_message_for_object_message_and_content_item(self): + """Test is_cached_message on custom objects / models.""" + from litellm.utils import is_cached_message + + # Test message level cache_control object + class MockCacheControl: + def __init__(self): + self.type = "ephemeral" + + class MockMessageLevelObj: + def __init__(self): + self.role = "system" + self.content = "hello" + self.cache_control = MockCacheControl() + + msg = MockMessageLevelObj() + assert is_cached_message(msg) is True + + # Test content level cache_control object + class MockContentItem: + def __init__(self): + self.type = "text" + self.text = "hello" + self.cache_control = MockCacheControl() + + class MockContentLevelObj: + def __init__(self): + self.role = "system" + self.content = [MockContentItem()] + + msg = MockContentLevelObj() + assert is_cached_message(msg) is True + + def test_extract_ttl_from_cached_messages_for_object_models(self): + """Test extract_ttl_from_cached_messages with object-based messages and content items.""" + from litellm.llms.vertex_ai.context_caching.transformation import extract_ttl_from_cached_messages + + class MockCacheControl: + def __init__(self): + self.type = "ephemeral" + self.ttl = "3600s" + + class MockContentItem: + def __init__(self): + self.type = "text" + self.text = "hello" + self.cache_control = MockCacheControl() + + class MockMessageObj: + def __init__(self): + self.role = "system" + self.content = [MockContentItem()] + + messages = [MockMessageObj()] + ttl = extract_ttl_from_cached_messages(messages) + assert ttl == "3600s" + def test_function_call_tool_id_falls_back_to_unique_id_for_degenerate_call_id(): """Bedrock Mantle returns a non-unique, index-based ``call_id`` (``call_0`` that @@ -2798,16 +2741,10 @@ def test_function_call_tool_id_falls_back_to_unique_id_for_degenerate_call_id(): for the bedrock-mantle gpt-5.5 non-streaming path.""" from types import SimpleNamespace - convert = ( - LiteLLMCompletionResponsesConfig.convert_response_function_tool_call_to_chat_completion_tool_call - ) + convert = LiteLLMCompletionResponsesConfig.convert_response_function_tool_call_to_chat_completion_tool_call - mantle = SimpleNamespace( - id="fc_unique_abc123", call_id="call_0", name="get_weather", arguments="{}" - ) + mantle = SimpleNamespace(id="fc_unique_abc123", call_id="call_0", name="get_weather", arguments="{}") assert convert(mantle)["id"] == "fc_unique_abc123" - openai = SimpleNamespace( - id="fc_2", call_id="call_tokyo", name="get_weather", arguments="{}" - ) + openai = SimpleNamespace(id="fc_2", call_id="call_tokyo", name="get_weather", arguments="{}") assert convert(openai)["id"] == "call_tokyo" From 42a39dd81988273c88835ebc44725660c7048c62 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Tue, 7 Jul 2026 20:05:42 -0400 Subject: [PATCH 02/11] fix(transformations): implementing greptile feedback --- .../transformation.py | 4 +++- litellm/utils.py | 13 +++---------- .../test_litellm_completion_responses.py | 11 +++++++++-- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 8052f69f22bc..217ed711d375 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -1190,7 +1190,9 @@ def _normalize_responses_api_object_to_dict(item: Any) -> dict[str, Any]: "detail", ]: if hasattr(item, attr): - item_dict[attr] = getattr(item, attr) + val = getattr(item, attr) + if val is not None: + item_dict[attr] = val return item_dict @staticmethod diff --git a/litellm/utils.py b/litellm/utils.py index 3fdf0a5eee68..d1c7af7050dd 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -7210,9 +7210,7 @@ def is_cached_message(message: AllMessageValues) -> bool: # Check message-level cache_control (set by cache_control_injection_points hook for string content) message_level_cache_control = ( - message.get("cache_control") - if isinstance(message, dict) - else getattr(message, "cache_control", None) + message.get("cache_control") if isinstance(message, dict) else getattr(message, "cache_control", None) ) if message_level_cache_control is not None: cc_type = ( @@ -7243,14 +7241,9 @@ def is_cached_message(message: AllMessageValues) -> bool: cache_control = getattr(content_item, "cache_control", None) item_type = getattr(content_item, "type", None) - if ( - item_type == "text" - and cache_control is not None - ): + if item_type == "text" and cache_control is not None: cc_type = ( - cache_control.get("type") - if isinstance(cache_control, dict) - else getattr(cache_control, "type", None) + cache_control.get("type") if isinstance(cache_control, dict) else getattr(cache_control, "type", None) ) if cc_type == "ephemeral": return True diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 4b07d638261a..537753ec3e12 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -2663,15 +2663,22 @@ def __init__(self): def dict(self): return {"type": self.type, "text": self.text, "cache_control": self.cache_control} - content = [MockContentBlock(), MockPydanticV2Block(), MockPydanticV1Block()] + class MockBlockWithNoneCacheControl: + def __init__(self): + self.type = "text" + self.text = "hello none" + self.cache_control = None + + content = [MockContentBlock(), MockPydanticV2Block(), MockPydanticV1Block(), MockBlockWithNoneCacheControl()] result = LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content(content) assert isinstance(result, list) - assert len(result) == 3 + assert len(result) == 4 assert result[0]["cache_control"] == {"type": "ephemeral"} assert result[1]["cache_control"] == {"type": "ephemeral"} assert result[2]["cache_control"] == {"type": "ephemeral"} assert result[1]["text"] == "hello v2" assert result[2]["text"] == "hello v1" + assert "cache_control" not in result[3] def test_is_cached_message_for_object_message_and_content_item(self): """Test is_cached_message on custom objects / models.""" From f09b8ce34af3e2521faf87a386835c6a42a32b35 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Wed, 8 Jul 2026 21:59:31 -0400 Subject: [PATCH 03/11] fix(responses): propagate message cache_control safely through objects and models --- .../context_caching/transformation.py | 52 ++++-- .../transformation.py | 45 +++-- litellm/types/llms/openai.py | 1 + .../test_context_caching_ttl.py | 157 ++++++++++++++++-- .../test_litellm_completion_responses.py | 144 +++++----------- 5 files changed, 247 insertions(+), 152 deletions(-) diff --git a/litellm/llms/vertex_ai/context_caching/transformation.py b/litellm/llms/vertex_ai/context_caching/transformation.py index 183d9743f13f..bc6ee47d3b88 100644 --- a/litellm/llms/vertex_ai/context_caching/transformation.py +++ b/litellm/llms/vertex_ai/context_caching/transformation.py @@ -59,32 +59,52 @@ def extract_ttl_from_cached_messages(messages: List[AllMessageValues]) -> Option Optional[str]: TTL string in format "3600s" or None if not found/invalid """ for message in messages: - if not is_cached_message(message): - continue + # Check message-level cache_control first + msg_cache_control = ( + message.get("cache_control") if isinstance(message, dict) else getattr(message, "cache_control", None) + ) + if msg_cache_control is not None: + cc_type = ( + msg_cache_control.get("type") + if isinstance(msg_cache_control, dict) + else getattr(msg_cache_control, "type", None) + ) + if cc_type == "ephemeral": + ttl = ( + msg_cache_control.get("ttl") + if isinstance(msg_cache_control, dict) + else getattr(msg_cache_control, "ttl", None) + ) + if ttl and _is_valid_ttl_format(ttl): + return str(ttl) content = message.get("content") if isinstance(message, dict) else getattr(message, "content", None) - if not content or isinstance(content, str): + if not isinstance(content, list): continue for content_item in content: # Check if content_item is dict or object model if isinstance(content_item, dict): cache_control = content_item.get("cache_control") + item_type = content_item.get("type") else: cache_control = getattr(content_item, "cache_control", None) - - if not cache_control: - continue - - cc_type = ( - cache_control.get("type") if isinstance(cache_control, dict) else getattr(cache_control, "type", None) - ) - if cc_type != "ephemeral": - continue - - ttl = cache_control.get("ttl") if isinstance(cache_control, dict) else getattr(cache_control, "ttl", None) - if ttl and _is_valid_ttl_format(ttl): - return str(ttl) + item_type = getattr(content_item, "type", None) + + if item_type == "text" and cache_control is not None: + cc_type = ( + cache_control.get("type") + if isinstance(cache_control, dict) + else getattr(cache_control, "type", None) + ) + if cc_type == "ephemeral": + ttl = ( + cache_control.get("ttl") + if isinstance(cache_control, dict) + else getattr(cache_control, "ttl", None) + ) + if ttl and _is_valid_ttl_format(ttl): + return str(ttl) return None diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 217ed711d375..4105fc0bcf59 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -912,9 +912,8 @@ def _transform_responses_api_input_item_to_chat_completion_message( content=LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content( content ), + **({"cache_control": cache_control} if cache_control is not None else {}), ) - if cache_control is not None: - msg["cache_control"] = cache_control return [msg] @staticmethod @@ -948,6 +947,11 @@ def _transform_responses_api_tool_call_output_to_chat_completion_message( """ ChatCompletionToolMessage is used to indicate the output from a tool call """ + if not isinstance(tool_call_output, dict): + tool_call_output = LiteLLMCompletionResponsesConfig._normalize_responses_api_object_to_dict( + tool_call_output + ) + call_id = tool_call_output.get("call_id") # If call_id is missing or empty, skip this message # Empty call_id means we can't create a valid tool message @@ -1097,6 +1101,9 @@ def _transform_responses_api_function_call_to_chat_completion_message( } ``` """ + if not isinstance(function_call, dict): + function_call = LiteLLMCompletionResponsesConfig._normalize_responses_api_object_to_dict(function_call) + # Create a tool call for the function call. Custom tool calls # store their payload in "input" (raw string) rather than # "arguments" (JSON string), so normalize to arguments here. @@ -1178,8 +1185,7 @@ def _normalize_responses_api_object_to_dict(item: Any) -> dict[str, Any]: elif hasattr(item, "dict"): return item.dict() - item_dict = {} - for attr in [ + target_attrs = ( "type", "text", "cache_control", @@ -1188,12 +1194,19 @@ def _normalize_responses_api_object_to_dict(item: Any) -> dict[str, Any]: "file_url", "image_url", "detail", - ]: - if hasattr(item, attr): - val = getattr(item, attr) - if val is not None: - item_dict[attr] = val - return item_dict + "call_id", + "arguments", + "input", + "name", + "id", + "output", + "status", + ) + return { + attr: getattr(item, attr) + for attr in target_attrs + if hasattr(item, attr) and getattr(item, attr) is not None + } @staticmethod def _transform_responses_api_content_to_chat_completion_content( @@ -1225,11 +1238,10 @@ def _transform_responses_api_content_to_chat_completion_content( LiteLLMCompletionResponsesConfig._transform_input_file_item_to_file_item(item) ) elif item.get("type") == "input_image": - image_block = dict( - LiteLLMCompletionResponsesConfig._transform_input_image_item_to_image_item(item) - ) - if "cache_control" in item: - image_block["cache_control"] = item["cache_control"] + image_block = { + **LiteLLMCompletionResponsesConfig._transform_input_image_item_to_image_item(item), + **({"cache_control": item["cache_control"]} if "cache_control" in item else {}), + } content_list.append(image_block) else: # Skip text blocks with None text to avoid downstream errors @@ -1241,9 +1253,8 @@ def _transform_responses_api_content_to_chat_completion_content( item.get("type") or "text" ), "text": text_value, + **({"cache_control": item["cache_control"]} if "cache_control" in item else {}), } - if "cache_control" in item: - content_block["cache_control"] = item["cache_control"] content_list.append(content_block) return content_list else: diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 9f689a2dd318..77d1680eefdd 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -791,6 +791,7 @@ class ChatCompletionDeveloperMessage(OpenAIChatCompletionDeveloperMessage, total class GenericChatCompletionMessage(TypedDict, total=False): role: Required[str] content: Required[Union[str, List]] + cache_control: ChatCompletionCachedContent ValidUserMessageContentTypes = [ diff --git a/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py b/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py index 8a13baa00062..2b786820f8fd 100644 --- a/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py +++ b/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py @@ -91,9 +91,7 @@ def test_extract_ttl_no_cache_control(self): messages = [ { "role": "user", - "content": [ - {"type": "text", "text": "Regular message without cache control"} - ], + "content": [{"type": "text", "text": "Regular message without cache control"}], } ] @@ -175,9 +173,7 @@ def test_extract_ttl_string_content(self): class TestTransformationWithTTL: """Test the complete transformation with TTL support""" - @pytest.mark.parametrize( - "custom_llm_provider", ["gemini", "vertex_ai", "vertex_ai_beta"] - ) + @pytest.mark.parametrize("custom_llm_provider", ["gemini", "vertex_ai", "vertex_ai_beta"]) def test_transform_with_valid_ttl(self, custom_llm_provider): """Test transformation includes TTL when provided""" messages = [ @@ -218,9 +214,7 @@ def test_transform_with_valid_ttl(self, custom_llm_provider): assert result["displayName"] == "test-cache-key" - @pytest.mark.parametrize( - "custom_llm_provider", ["gemini", "vertex_ai", "vertex_ai_beta"] - ) + @pytest.mark.parametrize("custom_llm_provider", ["gemini", "vertex_ai", "vertex_ai_beta"]) def test_transform_without_ttl(self, custom_llm_provider): """Test transformation without TTL""" messages = [ @@ -260,9 +254,7 @@ def test_transform_without_ttl(self, custom_llm_provider): assert result["displayName"] == "test-cache-key" - @pytest.mark.parametrize( - "custom_llm_provider", ["gemini", "vertex_ai", "vertex_ai_beta"] - ) + @pytest.mark.parametrize("custom_llm_provider", ["gemini", "vertex_ai", "vertex_ai_beta"]) def test_transform_with_invalid_ttl(self, custom_llm_provider): """Test transformation with invalid TTL (should be ignored)""" messages = [ @@ -301,9 +293,7 @@ def test_transform_with_invalid_ttl(self, custom_llm_provider): assert result["displayName"] == "test-cache-key" - @pytest.mark.parametrize( - "custom_llm_provider", ["gemini", "vertex_ai", "vertex_ai_beta"] - ) + @pytest.mark.parametrize("custom_llm_provider", ["gemini", "vertex_ai", "vertex_ai_beta"]) def test_transform_with_system_message_and_ttl(self, custom_llm_provider): """Test transformation with system message and TTL""" messages = [ @@ -388,6 +378,143 @@ def test_ttl_validation_type_conversion(self): assert isinstance(ttl, str) assert ttl == "3600s" + def test_cache_control_preserved_for_object_content_items(self): + """Test that cache_control is preserved when content items are real Pydantic models.""" + from pydantic import BaseModel, Field + from litellm.responses.litellm_completion_transformation.transformation import ( + LiteLLMCompletionResponsesConfig, + ) + + class MockContentBlock: + def __init__(self): + self.type = "text" + self.text = "hello" + self.cache_control = {"type": "ephemeral"} + + class RealPydanticV2Block(BaseModel): + type: str = "text" + text: str = "hello v2" + cache_control: dict = Field(default_factory=lambda: {"type": "ephemeral"}) + + class MockBlockWithNoneCacheControl: + def __init__(self): + self.type = "text" + self.text = "hello none" + self.cache_control = None + + content = [ + MockContentBlock(), + RealPydanticV2Block(), + MockBlockWithNoneCacheControl(), + ] + result = LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content(content) + assert result == [ + {"type": "text", "text": "hello", "cache_control": {"type": "ephemeral"}}, + {"type": "text", "text": "hello v2", "cache_control": {"type": "ephemeral"}}, + {"type": "text", "text": "hello none"}, + ] + + def test_is_cached_message_for_object_message_and_content_item(self): + """Test is_cached_message on custom objects / models.""" + from litellm.utils import is_cached_message + + # Test message level cache_control object + class MockCacheControl: + def __init__(self): + self.type = "ephemeral" + + class MockMessageLevelObj: + def __init__(self): + self.role = "system" + self.content = "hello" + self.cache_control = MockCacheControl() + + msg = MockMessageLevelObj() + assert is_cached_message(msg) is True + + # Test content level cache_control object + class MockContentItem: + def __init__(self): + self.type = "text" + self.text = "hello" + self.cache_control = MockCacheControl() + + class MockContentLevelObj: + def __init__(self): + self.role = "system" + self.content = [MockContentItem()] + + msg = MockContentLevelObj() + assert is_cached_message(msg) is True + + def test_extract_ttl_from_cached_messages_for_object_models(self): + """Test extract_ttl_from_cached_messages with object-based messages and content items.""" + + class MockCacheControl: + def __init__(self): + self.type = "ephemeral" + self.ttl = "3600s" + + class MockContentItem: + def __init__(self): + self.type = "text" + self.text = "hello" + self.cache_control = MockCacheControl() + + class MockMessageObj: + def __init__(self): + self.role = "system" + self.content = [MockContentItem()] + + messages = [MockMessageObj()] + ttl = extract_ttl_from_cached_messages(messages) + assert ttl == "3600s" + + def test_extract_ttl_from_cached_messages_with_message_level_object_cache_control(self): + """Test extract_ttl_from_cached_messages with message-level object cache_control.""" + + class MockCacheControl: + def __init__(self): + self.type = "ephemeral" + self.ttl = "7200s" + + class MockMessageObj: + def __init__(self): + self.role = "system" + self.content = "hello" + self.cache_control = MockCacheControl() + + messages = [MockMessageObj()] + ttl = extract_ttl_from_cached_messages(messages) + assert ttl == "7200s" + + def test_is_cached_message_for_dict_message_with_dict_content_items(self): + """Test is_cached_message with dict message and dict content list items.""" + from litellm.utils import is_cached_message + + # Dictionary message without content should return False + assert is_cached_message({"role": "user"}) is False + + msg = { + "role": "user", + "content": [ + {"type": "text", "text": "hello", "cache_control": {"type": "ephemeral"}} + ], + } + assert is_cached_message(msg) is True + + def test_normalize_responses_api_object_to_dict_pydantic_v1(self): + """Test _normalize_responses_api_object_to_dict with Pydantic v1 dict fallback.""" + from litellm.responses.litellm_completion_transformation.transformation import LiteLLMCompletionResponsesConfig + + class MockPydanticV1Model: + def dict(self): + return {"type": "text", "text": "hello", "cache_control": {"type": "ephemeral"}} + + item = MockPydanticV1Model() + res = LiteLLMCompletionResponsesConfig._normalize_responses_api_object_to_dict(item) + assert res == {"type": "text", "text": "hello", "cache_control": {"type": "ephemeral"}} + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 537753ec3e12..b34bdd812d6c 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -2621,121 +2621,57 @@ def test_cache_control_preserved_for_input_image_block(self): assert result[0]["cache_control"] == {"type": "ephemeral"} def test_cache_control_preserved_for_object_input_item(self): - """Test that cache_control is preserved when input_item is a custom object / model.""" + """Test that cache_control is preserved when input_item is a real Pydantic model.""" + from pydantic import BaseModel, Field - class MockInputItem: - def __init__(self): - self.role = "user" - self.content = "hello" - self.cache_control = {"type": "ephemeral"} + class RealInputItem(BaseModel): + role: str = "user" + content: str = "hello" + cache_control: dict = Field(default_factory=lambda: {"type": "ephemeral"}) - input_item = MockInputItem() + input_item = RealInputItem() messages = LiteLLMCompletionResponsesConfig._transform_responses_api_input_item_to_chat_completion_message( input_item ) - assert len(messages) == 1 - assert messages[0].get("cache_control") == {"type": "ephemeral"} - - def test_cache_control_preserved_for_object_content_item(self): - """Test that cache_control is preserved when content items are custom objects.""" - - class MockContentBlock: - def __init__(self): - self.type = "text" - self.text = "hello" - self.cache_control = {"type": "ephemeral"} - - class MockPydanticV2Block: - def __init__(self): - self.type = "text" - self.text = "hello v2" - self.cache_control = {"type": "ephemeral"} - - def model_dump(self): - return {"type": self.type, "text": self.text, "cache_control": self.cache_control} - - class MockPydanticV1Block: - def __init__(self): - self.type = "text" - self.text = "hello v1" - self.cache_control = {"type": "ephemeral"} - - def dict(self): - return {"type": self.type, "text": self.text, "cache_control": self.cache_control} - - class MockBlockWithNoneCacheControl: - def __init__(self): - self.type = "text" - self.text = "hello none" - self.cache_control = None - - content = [MockContentBlock(), MockPydanticV2Block(), MockPydanticV1Block(), MockBlockWithNoneCacheControl()] - result = LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content(content) - assert isinstance(result, list) - assert len(result) == 4 - assert result[0]["cache_control"] == {"type": "ephemeral"} - assert result[1]["cache_control"] == {"type": "ephemeral"} - assert result[2]["cache_control"] == {"type": "ephemeral"} - assert result[1]["text"] == "hello v2" - assert result[2]["text"] == "hello v1" - assert "cache_control" not in result[3] - - def test_is_cached_message_for_object_message_and_content_item(self): - """Test is_cached_message on custom objects / models.""" - from litellm.utils import is_cached_message - - # Test message level cache_control object - class MockCacheControl: - def __init__(self): - self.type = "ephemeral" - - class MockMessageLevelObj: - def __init__(self): - self.role = "system" - self.content = "hello" - self.cache_control = MockCacheControl() - - msg = MockMessageLevelObj() - assert is_cached_message(msg) is True - - # Test content level cache_control object - class MockContentItem: - def __init__(self): - self.type = "text" - self.text = "hello" - self.cache_control = MockCacheControl() - - class MockContentLevelObj: - def __init__(self): - self.role = "system" - self.content = [MockContentItem()] - - msg = MockContentLevelObj() - assert is_cached_message(msg) is True + assert messages == [{"role": "user", "content": "hello", "cache_control": {"type": "ephemeral"}}] - def test_extract_ttl_from_cached_messages_for_object_models(self): - """Test extract_ttl_from_cached_messages with object-based messages and content items.""" - from litellm.llms.vertex_ai.context_caching.transformation import extract_ttl_from_cached_messages - - class MockCacheControl: + def test_tool_call_output_as_custom_object(self): + """Test _transform_responses_api_tool_call_output_to_chat_completion_message with a custom object.""" + class MockToolCallOutput: def __init__(self): - self.type = "ephemeral" - self.ttl = "3600s" + self.call_id = "call_abc123" + self.output = "tool output content" + self.status = "completed" - class MockContentItem: - def __init__(self): - self.type = "text" - self.text = "hello" - self.cache_control = MockCacheControl() + item = MockToolCallOutput() + messages = LiteLLMCompletionResponsesConfig._transform_responses_api_tool_call_output_to_chat_completion_message( + item + ) + assert len(messages) == 1 + assert messages[0]["role"] == "tool" + assert messages[0]["tool_call_id"] == "call_abc123" + assert messages[0]["content"] == "tool output content" - class MockMessageObj: + def test_function_call_as_custom_object(self): + """Test _transform_responses_api_function_call_to_chat_completion_message with a custom object.""" + class MockFunctionCall: def __init__(self): - self.role = "system" - self.content = [MockContentItem()] + self.type = "function_call" + self.arguments = '{"location": "Boston"}' + self.call_id = "call_xyz789" + self.name = "get_weather" + self.id = "fc_12345" + self.status = "completed" - messages = [MockMessageObj()] - ttl = extract_ttl_from_cached_messages(messages) - assert ttl == "3600s" + item = MockFunctionCall() + messages = LiteLLMCompletionResponsesConfig._transform_responses_api_function_call_to_chat_completion_message( + item + ) + assert len(messages) == 1 + assert messages[0]["role"] == "assistant" + assert len(messages[0]["tool_calls"]) == 1 + assert messages[0]["tool_calls"][0]["id"] == "call_xyz789" + assert messages[0]["tool_calls"][0]["function"]["name"] == "get_weather" def test_function_call_tool_id_falls_back_to_unique_id_for_degenerate_call_id(): From 5822aa87eedbab9e017683c598145498d2001600 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Thu, 9 Jul 2026 22:52:14 -0400 Subject: [PATCH 04/11] feat(caching): enhance Gemini context caching propagation and TTL normalization --- .../adapters/transformation.py | 8 +- .../context_caching/transformation.py | 54 ++++++++++++- ...al_pass_through_adapters_transformation.py | 55 ++++++++++++- .../test_context_caching_ttl.py | 81 +++++++++++++++++++ .../test_litellm_completion_responses.py | 16 ++++ 5 files changed, 207 insertions(+), 7 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 4b6617fbeac1..97f98cbea7e1 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -305,11 +305,17 @@ def _add_cache_control_if_applicable( target: Dict or TypedDict to add cache_control to model: Model name to check if cache_control should be preserved """ + from litellm.utils import _is_gemini_model + # TypedDict objects are dicts at runtime, so .get() works cache_control = ( source.get("cache_control") if isinstance(source, dict) else getattr(source, "cache_control", None) ) - if cache_control and model and (self.is_anthropic_claude_model(model) or self.is_bedrock_arn_model(model)): + if cache_control and model and ( + self.is_anthropic_claude_model(model) + or self.is_bedrock_arn_model(model) + or _is_gemini_model(model, None) + ): # TypedDict objects support dict operations at runtime # Use type ignore consistent with codebase pattern (see anthropic/chat/transformation.py:432) if isinstance(target, dict): diff --git a/litellm/llms/vertex_ai/context_caching/transformation.py b/litellm/llms/vertex_ai/context_caching/transformation.py index bc6ee47d3b88..b325c61aeca5 100644 --- a/litellm/llms/vertex_ai/context_caching/transformation.py +++ b/litellm/llms/vertex_ai/context_caching/transformation.py @@ -75,8 +75,9 @@ def extract_ttl_from_cached_messages(messages: List[AllMessageValues]) -> Option if isinstance(msg_cache_control, dict) else getattr(msg_cache_control, "ttl", None) ) - if ttl and _is_valid_ttl_format(ttl): - return str(ttl) + normalized = _normalize_ttl_to_seconds(ttl) + if normalized is not None: + return normalized content = message.get("content") if isinstance(message, dict) else getattr(message, "content", None) if not isinstance(content, list): @@ -103,8 +104,9 @@ def extract_ttl_from_cached_messages(messages: List[AllMessageValues]) -> Option if isinstance(cache_control, dict) else getattr(cache_control, "ttl", None) ) - if ttl and _is_valid_ttl_format(ttl): - return str(ttl) + normalized = _normalize_ttl_to_seconds(ttl) + if normalized is not None: + return normalized return None @@ -138,6 +140,50 @@ def _is_valid_ttl_format(ttl: str) -> bool: return False +def _normalize_ttl_to_seconds(ttl: object) -> Optional[str]: + """ + Normalize a cache_control TTL into Gemini's "s" format. + + Accepts Gemini-native seconds (e.g. "3600s", "1.5s") and Anthropic-style + minute/hour units (e.g. "5m", "1h") that Claude Code and the Anthropic + /v1/messages spec use. Returns None for missing or unparseable values so + Gemini falls back to its own default TTL. + """ + if not isinstance(ttl, str): + return None + + if _is_valid_ttl_format(ttl): + return ttl + + match = re.match(r"^([0-9]*\.?[0-9]+)(m|h)$", ttl) + if not match: + return None + + value = float(match.group(1)) + + if value <= 0: + return None + + seconds = value * (60 if match.group(2) == "m" else 3600) + return f"{int(seconds)}s" if seconds.is_integer() else f"{seconds}s" + + +def get_gemini_context_caching_min_tokens(model: str) -> int: + """ + Minimum input token count required to create an explicit Gemini context cache. + + Gemini rejects a cachedContents create below a per-model floor with a 400, so + the caller skips caching below this value. Figures from + https://ai.google.dev/gemini-api/docs/caching (Gemini 2.5 -> 2048, Gemini 3.x + -> 4096). Unknown Gemini models default to the highest known floor so a create + is never attempted below the real minimum. + """ + model_lower = model.lower() + if "gemini-2.5" in model_lower or "gemini-2-5" in model_lower: + return 2048 + return 4096 + + def separate_cached_messages( messages: List[AllMessageValues], ) -> Tuple[List[AllMessageValues], List[AllMessageValues]]: diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index dfe7e0c3a516..e86010a00f0a 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -1388,14 +1388,17 @@ def test_should_add_cache_control_for_anthropic_model(): def test_should_not_add_cache_control_for_non_anthropic_model(): - """Should not add cache_control for non-Anthropic models.""" + """Should not add cache_control for providers that reject an explicit cache_control field. + + OpenAI/Azure do prompt caching implicitly and 400 on an unexpected + cache_control field, so it must not be forwarded to them. + """ adapter = LiteLLMAnthropicMessagesAdapter() cache_control = {"type": "ephemeral"} for model in [ CACHE_CONTROL_NON_ANTHROPIC_MODEL, "openai/gpt-4-turbo", - "gemini-pro", ]: target = {} adapter._add_cache_control_if_applicable( @@ -1404,6 +1407,54 @@ def test_should_not_add_cache_control_for_non_anthropic_model(): assert "cache_control" not in target +def test_should_add_cache_control_for_gemini_model(): + """Should add cache_control for Gemini / Vertex Gemini targets. + + These consume anthropic-style cache_control blocks via the Gemini context + caching path, so /v1/messages requests (e.g. Claude Code) routed to a + Gemini model must keep it. Regression for the adapter dropping the field + before it reaches the Gemini transformation. + """ + adapter = LiteLLMAnthropicMessagesAdapter() + cache_control = {"type": "ephemeral", "ttl": "1h"} + + for model in [ + "gemini-3.5-flash", + "gemini/gemini-3.5-flash", + "gemini-3.1-pro-preview", + "vertex_ai/gemini-2.5-pro", + ]: + target = {} + adapter._add_cache_control_if_applicable( + {"cache_control": cache_control}, target, model + ) + assert target.get("cache_control") == cache_control + + +def test_cache_control_preserved_in_text_content_for_gemini(): + """cache_control must survive message translation for a Gemini target.""" + anthropic_messages = [ + AnthropicMessagesUserMessageParam( + role="user", + content=[ + { + "type": "text", + "text": "This is cached content", + "cache_control": {"type": "ephemeral", "ttl": "1h"}, + } + ], + ) + ] + + adapter = LiteLLMAnthropicMessagesAdapter() + result = adapter.translate_anthropic_messages_to_openai( + messages=anthropic_messages, model="gemini/gemini-3.5-flash" + ) + + assert len(result) == 1 + assert result[0]["content"][0]["cache_control"] == {"type": "ephemeral", "ttl": "1h"} + + def test_should_not_add_cache_control_when_none(): """Should not add cache_control when source has None or empty cache_control.""" adapter = LiteLLMAnthropicMessagesAdapter() diff --git a/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py b/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py index 2b786820f8fd..ec193f8b9d80 100644 --- a/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py +++ b/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py @@ -1,11 +1,33 @@ import pytest from litellm.llms.vertex_ai.context_caching.transformation import ( extract_ttl_from_cached_messages, + get_gemini_context_caching_min_tokens, _is_valid_ttl_format, + _normalize_ttl_to_seconds, transform_openai_messages_to_gemini_context_caching, ) +class TestGeminiContextCachingMinTokens: + """Per-model floor for explicit Gemini context cache creation.""" + + @pytest.mark.parametrize( + "model, expected", + [ + ("gemini-2.5-flash", 2048), + ("gemini-2.5-pro", 2048), + ("gemini/gemini-2.5-pro", 2048), + ("vertex_ai/gemini-2.5-flash", 2048), + ("gemini-3.5-flash", 4096), + ("gemini-3.1-pro-preview", 4096), + ("gemini/gemini-3.5-flash", 4096), + ("gemini-1.5-pro", 4096), + ], + ) + def test_min_tokens_by_model(self, model, expected): + assert get_gemini_context_caching_min_tokens(model) == expected + + class TestTTLValidation: """Test TTL format validation""" @@ -37,6 +59,65 @@ def test_invalid_ttl_formats(self): assert not _is_valid_ttl_format(ttl), f"TTL {ttl} should be invalid" +class TestTTLNormalization: + """Normalization of anthropic-style TTL units into Gemini's seconds format.""" + + @pytest.mark.parametrize( + "ttl, expected", + [ + ("3600s", "3600s"), + ("1.5s", "1.5s"), + ("5m", "300s"), + ("90m", "5400s"), + ("1h", "3600s"), + ("2h", "7200s"), + ("0.5h", "1800s"), + ], + ) + def test_normalizes_units_to_seconds(self, ttl, expected): + assert _normalize_ttl_to_seconds(ttl) == expected + + @pytest.mark.parametrize( + "ttl", + ["invalid", "", "0m", "0h", "-1h", "5d", "1 h", "m", None, 123, 3600], + ) + def test_rejects_unparseable_ttl(self, ttl): + assert _normalize_ttl_to_seconds(ttl) is None + + def test_extract_ttl_normalizes_anthropic_hour_unit(self): + """Claude Code / Anthropic send "1h"; Gemini must receive "3600s".""" + messages = [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "cached", + "cache_control": {"type": "ephemeral", "ttl": "1h"}, + } + ], + } + ] + + assert extract_ttl_from_cached_messages(messages) == "3600s" + + def test_extract_ttl_normalizes_anthropic_minute_unit(self): + messages = [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "cached", + "cache_control": {"type": "ephemeral", "ttl": "5m"}, + } + ], + } + ] + + assert extract_ttl_from_cached_messages(messages) == "300s" + + class TestTTLExtraction: """Test TTL extraction from cached messages""" diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py index b34bdd812d6c..62bc61c1a635 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -2673,6 +2673,22 @@ def __init__(self): assert messages[0]["tool_calls"][0]["id"] == "call_xyz789" assert messages[0]["tool_calls"][0]["function"]["name"] == "get_weather" + def test_is_input_item_object_type_checks(self): + """Test _is_input_item_tool_call_output and _is_input_item_function_call with custom objects.""" + class MockObj: + def __init__(self, t): + self.type = t + + # Test tool call output + assert LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output(MockObj("function_call_output")) is True + assert LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output(MockObj("custom_tool_call_output")) is True + assert LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output(MockObj("text")) is False + + # Test function call + assert LiteLLMCompletionResponsesConfig._is_input_item_function_call(MockObj("function_call")) is True + assert LiteLLMCompletionResponsesConfig._is_input_item_function_call(MockObj("custom_tool_call")) is True + assert LiteLLMCompletionResponsesConfig._is_input_item_function_call(MockObj("text")) is False + def test_function_call_tool_id_falls_back_to_unique_id_for_degenerate_call_id(): """Bedrock Mantle returns a non-unique, index-based ``call_id`` (``call_0`` that From 0695f0702db4d27ce0471b8de00d5a5fb7bb130f Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Thu, 9 Jul 2026 22:53:04 -0400 Subject: [PATCH 05/11] feat(caching): enhance Gemini context caching by enforcing minimum token requirements to prevent 400s --- .../adapters/transformation.py | 12 ++-- .../vertex_ai_context_caching.py | 20 ++++--- litellm/utils.py | 2 + .../test_vertex_ai_context_caching.py | 58 +++++++++++++++++++ 4 files changed, 81 insertions(+), 11 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 97f98cbea7e1..6e69d011ff12 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -311,10 +311,14 @@ def _add_cache_control_if_applicable( cache_control = ( source.get("cache_control") if isinstance(source, dict) else getattr(source, "cache_control", None) ) - if cache_control and model and ( - self.is_anthropic_claude_model(model) - or self.is_bedrock_arn_model(model) - or _is_gemini_model(model, None) + if ( + cache_control + and model + and ( + self.is_anthropic_claude_model(model) + or self.is_bedrock_arn_model(model) + or _is_gemini_model(model, None) + ) ): # TypedDict objects support dict operations at runtime # Use type ignore consistent with codebase pattern (see anthropic/chat/transformation.py:432) diff --git a/litellm/llms/vertex_ai/context_caching/vertex_ai_context_caching.py b/litellm/llms/vertex_ai/context_caching/vertex_ai_context_caching.py index 0bf3715f7983..bfd862966a21 100644 --- a/litellm/llms/vertex_ai/context_caching/vertex_ai_context_caching.py +++ b/litellm/llms/vertex_ai/context_caching/vertex_ai_context_caching.py @@ -4,7 +4,6 @@ import litellm from litellm.caching.caching import Cache, LiteLLMCacheType -from litellm.constants import MINIMUM_PROMPT_CACHE_TOKEN_COUNT from litellm.litellm_core_utils.litellm_logging import Logging from litellm.llms.custom_httpx.http_handler import ( AsyncHTTPHandler, @@ -22,6 +21,7 @@ from ..common_utils import VertexAIError, get_vertex_base_url from ..vertex_llm_base import VertexBase from .transformation import ( + get_gemini_context_caching_min_tokens, separate_cached_messages, transform_openai_messages_to_gemini_context_caching, ) @@ -308,17 +308,20 @@ def check_and_create_cache( if len(cached_messages) == 0: return messages, optional_params, None - # Gemini requires a minimum of 1024 tokens for context caching. - # Skip caching if the cached content is too small to avoid API errors. + # Gemini's explicit context caching minimum varies by model; creating a + # cache below it returns a 400. Skip caching when the cached content is + # too small to avoid the error. + min_token_count = get_gemini_context_caching_min_tokens(model) if not is_prompt_caching_valid_prompt( model=model, messages=cached_messages, custom_llm_provider=custom_llm_provider, + min_token_count=min_token_count, ): verbose_logger.debug( "Vertex AI context caching: cached content is below minimum token " "count (%d). Skipping context caching.", - MINIMUM_PROMPT_CACHE_TOKEN_COUNT, + min_token_count, ) return messages, optional_params, None @@ -459,17 +462,20 @@ async def async_check_and_create_cache( if len(cached_messages) == 0: return messages, optional_params, None - # Gemini requires a minimum of 1024 tokens for context caching. - # Skip caching if the cached content is too small to avoid API errors. + # Gemini's explicit context caching minimum varies by model; creating a + # cache below it returns a 400. Skip caching when the cached content is + # too small to avoid the error. + min_token_count = get_gemini_context_caching_min_tokens(model) if not is_prompt_caching_valid_prompt( model=model, messages=cached_messages, custom_llm_provider=custom_llm_provider, + min_token_count=min_token_count, ): verbose_logger.debug( "Vertex AI context caching: cached content is below minimum token " "count (%d). Skipping context caching.", - MINIMUM_PROMPT_CACHE_TOKEN_COUNT, + min_token_count, ) return messages, optional_params, None diff --git a/litellm/utils.py b/litellm/utils.py index d1c7af7050dd..17c2b388847f 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -9097,6 +9097,8 @@ def is_prompt_caching_valid_prompt( nothing here and would silently fall back to the default. OpenAI's minimum is a flat 1024 across models, which the default already covers. + Pass min_token_count to override this for providers with a different floor + (e.g. Gemini, whose explicit context caching minimum varies by model). """ try: if messages is None and tools is None: diff --git a/tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py b/tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py index cf75964ddb73..8a3ef84a607d 100644 --- a/tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py +++ b/tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py @@ -1401,6 +1401,64 @@ def test_check_and_create_cache_skips_when_below_min_tokens( # Restart the patcher so teardown_method can stop it cleanly self._token_check_patcher.start() + @pytest.mark.parametrize( + "model, expected_min", + [ + ("gemini-3.5-flash", 4096), + ("gemini/gemini-3.5-flash", 4096), + ("gemini-3.1-pro-preview", 4096), + ("gemini-2.5-flash", 2048), + ("gemini-2.5-pro", 2048), + ], + ) + @patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.separate_cached_messages" + ) + def test_check_and_create_cache_uses_model_specific_min_tokens( + self, mock_separate, model, expected_min + ): + """The Gemini per-model floor must be forwarded to the token-count guard. + + A flat 1024 floor let content between 1024 and the real minimum (2048 for + 2.5, 4096 for 3.x) reach Gemini and 400. Assert the model-derived floor is + passed so the guard skips instead of erroring. + """ + self._token_check_patcher.stop() + + cached_messages = [ + { + "role": "system", + "content": "cached", + "cache_control": {"type": "ephemeral"}, + } + ] + non_cached_messages = [{"role": "user", "content": "Hello"}] + mock_separate.return_value = (cached_messages, non_cached_messages) + + with patch( + "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.is_prompt_caching_valid_prompt", + return_value=False, + ) as mock_valid: + self.context_caching.check_and_create_cache( + messages=cached_messages + non_cached_messages, + optional_params=self.sample_optional_params.copy(), + api_key="test_key", + api_base=None, + model=model, + client=self.mock_client, + timeout=30.0, + logging_obj=self.mock_logging, + cached_content=None, + custom_llm_provider="gemini", + vertex_project="test_project", + vertex_location="us-central1", + vertex_auth_header="test_token", + ) + + assert mock_valid.call_args.kwargs["min_token_count"] == expected_min + + self._token_check_patcher.start() + @pytest.mark.parametrize( "custom_llm_provider", ["gemini", "vertex_ai", "vertex_ai_beta"] ) From a244ad63af094cc838613e7756a92eba4943b942 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Thu, 9 Jul 2026 23:25:01 -0400 Subject: [PATCH 06/11] fix(caching): updating gemini-1.5 minimum token requirements --- .../adapters/transformation.py | 2 +- .../vertex_ai/context_caching/transformation.py | 12 ++++++++---- ...mental_pass_through_adapters_transformation.py | 15 +++++++++++++++ .../context_caching/test_context_caching_ttl.py | 5 ++++- .../test_vertex_ai_context_caching.py | 1 + 5 files changed, 29 insertions(+), 6 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 6e69d011ff12..789a434b5183 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -326,7 +326,7 @@ def _add_cache_control_if_applicable( target["cache_control"] = cache_control # type: ignore[typeddict-item] else: # Fallback for non-dict objects (shouldn't happen in practice) - cast(Dict[str, Any], target)["cache_control"] = cache_control + setattr(target, "cache_control", cache_control) def translatable_anthropic_params(self) -> List: """ diff --git a/litellm/llms/vertex_ai/context_caching/transformation.py b/litellm/llms/vertex_ai/context_caching/transformation.py index b325c61aeca5..30ad08054745 100644 --- a/litellm/llms/vertex_ai/context_caching/transformation.py +++ b/litellm/llms/vertex_ai/context_caching/transformation.py @@ -174,14 +174,18 @@ def get_gemini_context_caching_min_tokens(model: str) -> int: Gemini rejects a cachedContents create below a per-model floor with a 400, so the caller skips caching below this value. Figures from - https://ai.google.dev/gemini-api/docs/caching (Gemini 2.5 -> 2048, Gemini 3.x - -> 4096). Unknown Gemini models default to the highest known floor so a create - is never attempted below the real minimum. + https://ai.google.dev/gemini-api/docs/caching (Gemini 1.5 -> 32768, Gemini 2.5 + -> 2048, Gemini 3.x -> 4096). Unknown Gemini models default to the highest + known floor so a create is never attempted below the real minimum. """ model_lower = model.lower() + if "gemini-1.5" in model_lower or "gemini-1-5" in model_lower: + return 32768 if "gemini-2.5" in model_lower or "gemini-2-5" in model_lower: return 2048 - return 4096 + if "gemini-3" in model_lower: + return 4096 + return 32768 def separate_cached_messages( diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index e86010a00f0a..03e6f7c56b38 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -1431,6 +1431,21 @@ def test_should_add_cache_control_for_gemini_model(): assert target.get("cache_control") == cache_control +def test_cache_control_fallback_setattr(): + """Verify cache_control is safely assigned to non-dict target objects using setattr.""" + adapter = LiteLLMAnthropicMessagesAdapter() + cache_control = {"type": "ephemeral"} + + class MockTarget: + pass + + target = MockTarget() + adapter._add_cache_control_if_applicable( + {"cache_control": cache_control}, target, "claude-3-opus-20240229" + ) + assert getattr(target, "cache_control", None) == cache_control + + def test_cache_control_preserved_in_text_content_for_gemini(): """cache_control must survive message translation for a Gemini target.""" anthropic_messages = [ diff --git a/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py b/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py index ec193f8b9d80..9e31df9c27f6 100644 --- a/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py +++ b/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py @@ -14,6 +14,9 @@ class TestGeminiContextCachingMinTokens: @pytest.mark.parametrize( "model, expected", [ + ("gemini-1.5-pro", 32768), + ("gemini-1.5-flash", 32768), + ("vertex_ai/gemini-1.5-pro-001", 32768), ("gemini-2.5-flash", 2048), ("gemini-2.5-pro", 2048), ("gemini/gemini-2.5-pro", 2048), @@ -21,7 +24,7 @@ class TestGeminiContextCachingMinTokens: ("gemini-3.5-flash", 4096), ("gemini-3.1-pro-preview", 4096), ("gemini/gemini-3.5-flash", 4096), - ("gemini-1.5-pro", 4096), + ("gemini-unknown-future-model", 32768), ], ) def test_min_tokens_by_model(self, model, expected): diff --git a/tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py b/tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py index 8a3ef84a607d..aa873e984f76 100644 --- a/tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py +++ b/tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py @@ -1407,6 +1407,7 @@ def test_check_and_create_cache_skips_when_below_min_tokens( ("gemini-3.5-flash", 4096), ("gemini/gemini-3.5-flash", 4096), ("gemini-3.1-pro-preview", 4096), + ("gemini-1.5-pro", 32768), ("gemini-2.5-flash", 2048), ("gemini-2.5-pro", 2048), ], From d18409ef608f849cb794fc7804f1b84f014d5367 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Fri, 10 Jul 2026 22:01:56 -0400 Subject: [PATCH 07/11] feat(transformation): capping Gemini ttl for caching --- .../vertex_ai/context_caching/transformation.py | 17 ++++++++++------- .../context_caching/test_context_caching_ttl.py | 3 +++ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/litellm/llms/vertex_ai/context_caching/transformation.py b/litellm/llms/vertex_ai/context_caching/transformation.py index 30ad08054745..6f28427925be 100644 --- a/litellm/llms/vertex_ai/context_caching/transformation.py +++ b/litellm/llms/vertex_ai/context_caching/transformation.py @@ -146,16 +146,14 @@ def _normalize_ttl_to_seconds(ttl: object) -> Optional[str]: Accepts Gemini-native seconds (e.g. "3600s", "1.5s") and Anthropic-style minute/hour units (e.g. "5m", "1h") that Claude Code and the Anthropic - /v1/messages spec use. Returns None for missing or unparseable values so - Gemini falls back to its own default TTL. + /v1/messages spec use. Caps the requested TTL at 24 hours (86400s) to + prevent unbounded persistent storage costs. Returns None for missing or + unparseable values so Gemini falls back to its own default TTL. """ if not isinstance(ttl, str): return None - if _is_valid_ttl_format(ttl): - return ttl - - match = re.match(r"^([0-9]*\.?[0-9]+)(m|h)$", ttl) + match = re.match(r"^([0-9]*\.?[0-9]+)(s|m|h)$", ttl) if not match: return None @@ -164,7 +162,12 @@ def _normalize_ttl_to_seconds(ttl: object) -> Optional[str]: if value <= 0: return None - seconds = value * (60 if match.group(2) == "m" else 3600) + multiplier = {"s": 1, "m": 60, "h": 3600}[match.group(2)] + seconds = value * multiplier + + # Cap explicit caches to 24 hours to prevent unbounded billing costs + seconds = min(seconds, 86400.0) + return f"{int(seconds)}s" if seconds.is_integer() else f"{seconds}s" diff --git a/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py b/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py index 9e31df9c27f6..af2f0684e694 100644 --- a/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py +++ b/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py @@ -75,6 +75,9 @@ class TestTTLNormalization: ("1h", "3600s"), ("2h", "7200s"), ("0.5h", "1800s"), + ("48h", "86400s"), + ("1500m", "86400s"), + ("1000000s", "86400s"), ], ) def test_normalizes_units_to_seconds(self, ttl, expected): From 4b2a4363178449b4817e0906c2cb2f6bb459ac35 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Wed, 22 Jul 2026 12:23:54 -0400 Subject: [PATCH 08/11] feat(vertex_ai): look up Gemini minimum cache creation tokens from model info --- .../context_caching/transformation.py | 22 +- .../transformation.py | 11 +- model_prices_and_context_window.json | 287 ++++++++++++------ .../test_context_caching_ttl.py | 11 + tests/test_litellm/test_utils.py | 1 + 5 files changed, 226 insertions(+), 106 deletions(-) diff --git a/litellm/llms/vertex_ai/context_caching/transformation.py b/litellm/llms/vertex_ai/context_caching/transformation.py index 6f28427925be..7be01cd39e57 100644 --- a/litellm/llms/vertex_ai/context_caching/transformation.py +++ b/litellm/llms/vertex_ai/context_caching/transformation.py @@ -140,7 +140,7 @@ def _is_valid_ttl_format(ttl: str) -> bool: return False -def _normalize_ttl_to_seconds(ttl: object) -> Optional[str]: +def _normalize_ttl_to_seconds(ttl: object) -> str | None: """ Normalize a cache_control TTL into Gemini's "s" format. @@ -168,6 +168,8 @@ def _normalize_ttl_to_seconds(ttl: object) -> Optional[str]: # Cap explicit caches to 24 hours to prevent unbounded billing costs seconds = min(seconds, 86400.0) + # Google Protobuf Duration requires up to 9 fractional digits + seconds = round(seconds, 9) return f"{int(seconds)}s" if seconds.is_integer() else f"{seconds}s" @@ -175,15 +177,19 @@ def get_gemini_context_caching_min_tokens(model: str) -> int: """ Minimum input token count required to create an explicit Gemini context cache. - Gemini rejects a cachedContents create below a per-model floor with a 400, so - the caller skips caching below this value. Figures from - https://ai.google.dev/gemini-api/docs/caching (Gemini 1.5 -> 32768, Gemini 2.5 - -> 2048, Gemini 3.x -> 4096). Unknown Gemini models default to the highest - known floor so a create is never attempted below the real minimum. + Looks up the `cache_creation_min_tokens` property from model_prices_and_context_window.json. + Defaults to string-matching fallbacks for unknown models. """ + import litellm + + try: + model_info = litellm.get_model_info(model=model) + if model_info and "cache_creation_min_tokens" in model_info: + return int(model_info["cache_creation_min_tokens"]) + except Exception: # noqa: BLE001 # fallback to string-matching heuristic if model lookup fails + pass + model_lower = model.lower() - if "gemini-1.5" in model_lower or "gemini-1-5" in model_lower: - return 32768 if "gemini-2.5" in model_lower or "gemini-2-5" in model_lower: return 2048 if "gemini-3" in model_lower: diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 4105fc0bcf59..4e27b756b686 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -1181,9 +1181,16 @@ def _normalize_responses_api_object_to_dict(item: Any) -> dict[str, Any]: Normalize a Responses API object (Pydantic model or custom class) to a dictionary """ if hasattr(item, "model_dump"): - return item.model_dump() + if hasattr(item, "model_dump"): + try: + return item.model_dump(exclude_none=True) + except Exception: # noqa: BLE001 # fallback if custom model_dump does not accept exclude_none + return item.model_dump() elif hasattr(item, "dict"): - return item.dict() + try: + return item.dict(exclude_none=True) + except Exception: # noqa: BLE001 # fallback if custom dict does not accept exclude_none + return item.dict() target_attrs = ( "type", diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index c9d871fc41d5..4964a04844df 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -13493,7 +13493,8 @@ "output_dbu_cost_per_token": 3.5714e-05, "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", "supports_function_calling": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "cache_creation_min_tokens": 2048 }, "databricks/databricks-gemini-2-5-pro": { "input_cost_per_token": 1.24999e-06, @@ -13510,7 +13511,8 @@ "output_dbu_cost_per_token": 0.000142857, "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", "supports_function_calling": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "cache_creation_min_tokens": 2048 }, "databricks/databricks-gemma-3-12b": { "input_cost_per_token": 1.5000999999999998e-07, @@ -14682,7 +14684,8 @@ "mode": "chat", "supports_tool_choice": true, "supports_function_calling": true, - "supports_image_size": false + "supports_image_size": false, + "cache_creation_min_tokens": 2048 }, "deepinfra/google/gemini-2.5-pro": { "max_tokens": 1000000, @@ -14693,7 +14696,8 @@ "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true, - "supports_function_calling": true + "supports_function_calling": true, + "cache_creation_min_tokens": 2048 }, "deepinfra/google/gemma-3-12b-it": { "max_tokens": 131072, @@ -17338,7 +17342,8 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, - "supports_image_size": false + "supports_image_size": false, + "cache_creation_min_tokens": 2048 }, "gemini-2.5-flash-image": { "cache_read_input_token_cost": 3e-08, @@ -17382,7 +17387,8 @@ "supports_vision": true, "supports_web_search": false, "tpm": 8000000, - "supports_image_size": false + "supports_image_size": false, + "cache_creation_min_tokens": 2048 }, "gemini-3-pro-image": { "input_cost_per_image": 0.0011, @@ -17422,7 +17428,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "gemini-3-pro-image-preview": { "input_cost_per_image": 0.0011, @@ -17462,7 +17469,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "gemini-3.1-flash-image": { "input_cost_per_image": 0.00056, @@ -17500,7 +17508,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "gemini-3.1-flash-image-preview": { "input_cost_per_image": 0.00056, @@ -17538,7 +17547,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "gemini-3.1-flash-lite-preview": { "cache_read_input_token_cost": 2.5e-08, @@ -17586,7 +17596,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "gemini-3.1-flash-lite": { "cache_read_input_token_cost": 2.5e-08, @@ -17642,7 +17653,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "gemini-3.5-flash-lite": { "cache_read_input_token_cost": 3e-08, @@ -17697,7 +17709,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, @@ -17776,7 +17789,8 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, - "supports_image_size": false + "supports_image_size": false, + "cache_creation_min_tokens": 2048 }, "gemini-2.5-flash-lite-preview-09-2025": { "cache_read_input_token_cost": 1e-08, @@ -17821,7 +17835,8 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, - "supports_image_size": false + "supports_image_size": false, + "cache_creation_min_tokens": 2048 }, "gemini-2.5-flash-preview-09-2025": { "cache_read_input_token_cost": 7.5e-08, @@ -17866,7 +17881,8 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, - "supports_image_size": false + "supports_image_size": false, + "cache_creation_min_tokens": 2048 }, "gemini-live-2.5-flash-preview-native-audio-09-2025": { "cache_read_input_token_cost": 7.5e-08, @@ -18002,7 +18018,8 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, - "supports_image_size": false + "supports_image_size": false, + "cache_creation_min_tokens": 2048 }, "gemini-2.5-pro": { "cache_read_input_token_cost": 1.25e-07, @@ -18046,7 +18063,8 @@ "search_context_size_low": 0.035, "search_context_size_medium": 0.035, "search_context_size_high": 0.035 - } + }, + "cache_creation_min_tokens": 2048 }, "gemini-3-pro-preview": { "deprecation_date": "2026-03-26", @@ -18102,7 +18120,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -18159,7 +18178,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "gemini-3.1-pro-preview-customtools": { "cache_read_input_token_cost": 2e-07, @@ -18210,7 +18230,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "vertex_ai/gemini-3-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -18265,7 +18286,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "vertex_ai/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -18313,7 +18335,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "vertex_ai/gemini-3.5-flash": { "cache_read_input_token_cost": 1.5e-07, @@ -18364,7 +18387,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "vertex_ai/gemini-3.6-flash": { "cache_read_input_token_cost": 1.5e-07, @@ -18418,7 +18442,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "vertex_ai/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -18475,7 +18500,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "vertex_ai/gemini-3.1-pro-preview-customtools": { "cache_read_input_token_cost": 2e-07, @@ -18532,7 +18558,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "gemini-2.5-pro-preview-tts": { "cache_read_input_token_cost": 1.25e-07, @@ -18567,7 +18594,8 @@ "search_context_size_low": 0.035, "search_context_size_medium": 0.035, "search_context_size_high": 0.035 - } + }, + "cache_creation_min_tokens": 2048 }, "gemini-robotics-er-1.5-preview": { "cache_read_input_token_cost": 0, @@ -18671,7 +18699,8 @@ "supports_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_creation_min_tokens": 2048 }, "gemini-embedding-001": { "input_cost_per_token": 1.5e-07, @@ -18812,7 +18841,8 @@ "rpm": 10000, "source": "https://ai.google.dev/gemini-api/docs/embeddings#multimodal", "supports_multimodal": true, - "tpm": 10000000 + "tpm": 10000000, + "cache_creation_min_tokens": 32768 }, "gemini/gemini-2.0-flash": { "cache_read_input_token_cost": 2.5e-08, @@ -18973,7 +19003,8 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, - "supports_image_size": false + "supports_image_size": false, + "cache_creation_min_tokens": 2048 }, "gemini/gemini-2.5-flash-image": { "cache_read_input_token_cost": 3e-08, @@ -19023,7 +19054,8 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, - "supports_image_size": false + "supports_image_size": false, + "cache_creation_min_tokens": 2048 }, "gemini/gemini-3-pro-image": { "input_cost_per_image": 0.0011, @@ -19066,7 +19098,8 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "supports_reasoning": false + "supports_reasoning": false, + "cache_creation_min_tokens": 4096 }, "gemini/gemini-3-pro-image-preview": { "input_cost_per_image": 0.0011, @@ -19109,7 +19142,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "gemini/gemini-3.1-flash-image": { "input_cost_per_token": 2.5e-07, @@ -19151,7 +19185,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "gemini/gemini-3.1-flash-image-preview": { "input_cost_per_token": 2.5e-07, @@ -19193,7 +19228,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "gemini/deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, @@ -19281,7 +19317,8 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, - "supports_image_size": false + "supports_image_size": false, + "cache_creation_min_tokens": 2048 }, "gemini/gemini-2.5-flash-lite-preview-09-2025": { "cache_read_input_token_cost": 1e-08, @@ -19328,7 +19365,8 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, - "supports_image_size": false + "supports_image_size": false, + "cache_creation_min_tokens": 2048 }, "gemini/gemini-2.5-flash-preview-09-2025": { "cache_read_input_token_cost": 7.5e-08, @@ -19375,7 +19413,8 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, - "supports_image_size": false + "supports_image_size": false, + "cache_creation_min_tokens": 2048 }, "gemini/gemini-flash-latest": { "cache_read_input_token_cost": 7.5e-08, @@ -19515,7 +19554,8 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, - "supports_image_size": false + "supports_image_size": false, + "cache_creation_min_tokens": 2048 }, "gemini/gemini-2.5-flash-preview-tts": { "input_cost_per_token": 3e-07, @@ -19527,7 +19567,8 @@ "/v1/audio/speech" ], "tpm": 4000000, - "rpm": 10 + "rpm": 10, + "cache_creation_min_tokens": 2048 }, "gemini/gemini-2.5-pro": { "cache_read_input_token_cost": 1.25e-07, @@ -19576,7 +19617,8 @@ "search_context_size_low": 0.035, "search_context_size_medium": 0.035, "search_context_size_high": 0.035 - } + }, + "cache_creation_min_tokens": 2048 }, "gemini/gemini-2.5-computer-use-preview-10-2025": { "input_cost_per_token": 1.25e-06, @@ -19606,7 +19648,8 @@ "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, - "tpm": 800000 + "tpm": 800000, + "cache_creation_min_tokens": 2048 }, "gemini/gemini-3-pro-preview": { "deprecation_date": "2026-03-09", @@ -19662,7 +19705,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "gemini/gemini-3.1-flash-lite-preview": { "cache_read_input_token_cost": 2.5e-08, @@ -19712,7 +19756,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "gemini/gemini-3.1-flash-lite": { "cache_read_input_token_cost": 2.5e-08, @@ -19770,7 +19815,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "gemini/gemini-3.5-flash-lite": { "cache_read_input_token_cost": 3e-08, @@ -19827,7 +19873,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "gemini/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -19879,7 +19926,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "gemini/gemini-3.5-flash": { "cache_read_input_token_cost": 1.5e-07, @@ -19933,7 +19981,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "gemini/gemini-3.6-flash": { "cache_read_input_token_cost": 1.5e-07, @@ -19990,7 +20039,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "gemini/gemini-omni-flash-preview": { "input_cost_per_audio_token": 1.5e-06, @@ -20080,7 +20130,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "gemini/gemini-3.1-pro-preview-customtools": { "cache_read_input_token_cost": 2e-07, @@ -20137,7 +20188,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -20187,7 +20239,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "gemini-omni-flash-preview": { "input_cost_per_audio_token": 1.5e-06, @@ -20270,7 +20323,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "gemini-3.6-flash": { "cache_read_input_token_cost": 1.5e-07, @@ -20325,7 +20379,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "gemini/gemini-2.5-pro-preview-tts": { "cache_read_input_token_cost": 1.25e-07, @@ -20361,7 +20416,8 @@ "search_context_size_low": 0.035, "search_context_size_medium": 0.035, "search_context_size_high": 0.035 - } + }, + "cache_creation_min_tokens": 2048 }, "gemini/gemini-exp-1114": { "input_cost_per_token": 0, @@ -20750,7 +20806,8 @@ "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, - "supports_vision": true + "supports_vision": true, + "cache_creation_min_tokens": 2048 }, "github_copilot/gemini-3-pro-preview": { "litellm_provider": "github_copilot", @@ -20760,7 +20817,8 @@ "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, - "supports_vision": true + "supports_vision": true, + "cache_creation_min_tokens": 4096 }, "github_copilot/gpt-3.5-turbo": { "litellm_provider": "github_copilot", @@ -21352,7 +21410,8 @@ "mode": "chat", "output_cost_per_token": 1.2e-05, "supports_function_calling": true, - "supports_vision": true + "supports_vision": true, + "cache_creation_min_tokens": 4096 }, "gmi/google/gemini-3-flash-preview": { "input_cost_per_token": 5e-07, @@ -21363,7 +21422,8 @@ "mode": "chat", "output_cost_per_token": 3e-06, "supports_function_calling": true, - "supports_vision": true + "supports_vision": true, + "cache_creation_min_tokens": 4096 }, "gmi/moonshotai/Kimi-K2-Thinking": { "input_cost_per_token": 8e-07, @@ -29453,7 +29513,8 @@ "supports_response_schema": true, "supports_vision": true, "supports_native_streaming": true, - "supports_image_size": false + "supports_image_size": false, + "cache_creation_min_tokens": 2048 }, "oci/google.gemini-2.5-pro": { "input_cost_per_token": 1.25e-06, @@ -29467,7 +29528,8 @@ "supports_function_calling": true, "supports_response_schema": true, "supports_vision": true, - "supports_native_streaming": true + "supports_native_streaming": true, + "cache_creation_min_tokens": 2048 }, "oci/google.gemini-2.5-flash-lite": { "input_cost_per_token": 7.5e-08, @@ -29482,7 +29544,8 @@ "supports_response_schema": true, "supports_vision": true, "supports_native_streaming": true, - "supports_image_size": false + "supports_image_size": false, + "cache_creation_min_tokens": 2048 }, "oci/cohere.command-a-vision": { "input_cost_per_token": 1.56e-06, @@ -30510,7 +30573,8 @@ "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, - "supports_image_size": false + "supports_image_size": false, + "cache_creation_min_tokens": 2048 }, "openrouter/google/gemini-2.5-pro": { "input_cost_per_audio_token": 7e-07, @@ -30526,7 +30590,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_creation_min_tokens": 2048 }, "openrouter/google/gemini-3-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -30567,7 +30632,8 @@ "supports_tool_choice": true, "supports_video_input": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "cache_creation_min_tokens": 4096 }, "openrouter/google/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -30608,7 +30674,8 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 800000 + "tpm": 800000, + "cache_creation_min_tokens": 4096 }, "openrouter/google/gemini-3.1-flash-lite-preview": { "cache_read_input_token_cost": 2.5e-08, @@ -30651,7 +30718,8 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "tpm": 800000 + "tpm": 800000, + "cache_creation_min_tokens": 4096 }, "openrouter/google/gemini-3.1-flash-lite": { "cache_read_input_token_cost": 2.5e-08, @@ -30694,7 +30762,8 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "tpm": 800000 + "tpm": 800000, + "cache_creation_min_tokens": 4096 }, "openrouter/google/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -30727,7 +30796,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_creation_min_tokens": 4096 }, "openrouter/gryphe/mythomax-l2-13b": { "input_cost_per_token": 1.875e-06, @@ -32337,21 +32407,24 @@ "mode": "responses", "supports_web_search": true, "supports_reasoning": false, - "supports_function_calling": true + "supports_function_calling": true, + "cache_creation_min_tokens": 4096 }, "perplexity/google/gemini-3-flash-preview": { "litellm_provider": "perplexity", "mode": "responses", "supports_web_search": true, "supports_reasoning": false, - "supports_function_calling": true + "supports_function_calling": true, + "cache_creation_min_tokens": 4096 }, "perplexity/google/gemini-2.5-pro": { "litellm_provider": "perplexity", "mode": "responses", "supports_web_search": true, "supports_reasoning": false, - "supports_function_calling": true + "supports_function_calling": true, + "cache_creation_min_tokens": 2048 }, "perplexity/google/gemini-2.5-flash": { "litellm_provider": "perplexity", @@ -32359,7 +32432,8 @@ "supports_web_search": true, "supports_reasoning": false, "supports_function_calling": true, - "supports_image_size": false + "supports_image_size": false, + "cache_creation_min_tokens": 2048 }, "perplexity/xai/grok-4-1-fast-non-reasoning": { "litellm_provider": "perplexity", @@ -32864,7 +32938,8 @@ "supports_vision": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_response_schema": true + "supports_response_schema": true, + "cache_creation_min_tokens": 4096 }, "replicate/anthropic/claude-4.5-sonnet": { "input_cost_per_token": 3e-06, @@ -32942,7 +33017,8 @@ "supports_system_messages": true, "supports_tool_choice": true, "supports_response_schema": true, - "supports_image_size": false + "supports_image_size": false, + "cache_creation_min_tokens": 2048 }, "replicate/openai/gpt-oss-120b": { "input_cost_per_token": 1.8e-07, @@ -35730,7 +35806,8 @@ "supports_function_calling": true, "supports_tool_choice": true, "supports_response_schema": true, - "supports_image_size": false + "supports_image_size": false, + "cache_creation_min_tokens": 2048 }, "vercel_ai_gateway/google/gemini-2.5-pro": { "input_cost_per_token": 2.5e-06, @@ -35743,7 +35820,8 @@ "supports_vision": true, "supports_function_calling": true, "supports_tool_choice": true, - "supports_response_schema": true + "supports_response_schema": true, + "cache_creation_min_tokens": 2048 }, "vercel_ai_gateway/google/gemini-embedding-001": { "input_cost_per_token": 1.5e-07, @@ -37435,7 +37513,8 @@ "supports_vision": true, "supports_web_search": false, "tpm": 8000000, - "supports_image_size": false + "supports_image_size": false, + "cache_creation_min_tokens": 2048 }, "vertex_ai/gemini-3-pro-image": { "input_cost_per_image": 0.0011, @@ -37451,7 +37530,8 @@ "output_cost_per_token": 1.2e-05, "output_cost_per_token_batches": 6e-06, "supports_reasoning": false, - "source": "https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/3-pro-image" + "source": "https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/3-pro-image", + "cache_creation_min_tokens": 4096 }, "vertex_ai/gemini-3-pro-image-preview": { "input_cost_per_image": 0.0011, @@ -37467,7 +37547,8 @@ "output_cost_per_token": 1.2e-05, "output_cost_per_token_batches": 6e-06, "supports_reasoning": false, - "source": "https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/3-pro-image" + "source": "https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/3-pro-image", + "cache_creation_min_tokens": 4096 }, "vertex_ai/gemini-3.1-flash-image": { "input_cost_per_image": 0.00056, @@ -37481,7 +37562,8 @@ "output_cost_per_image_token": 6e-05, "output_cost_per_token": 3e-06, "supports_reasoning": false, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models" + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models", + "cache_creation_min_tokens": 4096 }, "vertex_ai/gemini-3.1-flash-image-preview": { "input_cost_per_image": 0.00056, @@ -37495,7 +37577,8 @@ "output_cost_per_image_token": 6e-05, "output_cost_per_token": 3e-06, "supports_reasoning": false, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models" + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models", + "cache_creation_min_tokens": 4096 }, "vertex_ai/gemini-3.1-flash-lite-preview": { "cache_read_input_token_cost": 2.5e-08, @@ -37543,7 +37626,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "vertex_ai/gemini-3.1-flash-lite": { "cache_read_input_token_cost": 2.5e-08, @@ -37599,7 +37683,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "vertex_ai/gemini-3.5-flash-lite": { "cache_read_input_token_cost": 3e-08, @@ -37654,7 +37739,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_creation_min_tokens": 4096 }, "vertex_ai/deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, @@ -44259,7 +44345,8 @@ ], "supports_audio_input": true, "supports_audio_output": true, - "gemini_native_audio": true + "gemini_native_audio": true, + "cache_creation_min_tokens": 2048 }, "gemini-2.5-flash-native-audio-preview-09-2025": { "input_cost_per_audio_token": 1e-06, @@ -44284,7 +44371,8 @@ ], "supports_audio_input": true, "supports_audio_output": true, - "gemini_native_audio": true + "gemini_native_audio": true, + "cache_creation_min_tokens": 2048 }, "gemini-2.5-flash-native-audio-preview-12-2025": { "input_cost_per_audio_token": 1e-06, @@ -44309,7 +44397,8 @@ ], "supports_audio_input": true, "supports_audio_output": true, - "gemini_native_audio": true + "gemini_native_audio": true, + "cache_creation_min_tokens": 2048 }, "gemini-3.1-flash-live-preview": { "input_cost_per_audio_token": 3e-06, @@ -44342,7 +44431,8 @@ "supports_function_calling": true, "supports_vision": true, "supports_web_search": true, - "gemini_audio_only_live": true + "gemini_audio_only_live": true, + "cache_creation_min_tokens": 4096 }, "gemini/gemini-2.5-flash-native-audio-latest": { "input_cost_per_audio_token": 1e-06, @@ -44369,7 +44459,8 @@ "supports_audio_output": true, "tpm": 250000, "rpm": 10, - "gemini_native_audio": true + "gemini_native_audio": true, + "cache_creation_min_tokens": 2048 }, "gemini/gemini-2.5-flash-native-audio-preview-09-2025": { "input_cost_per_audio_token": 1e-06, @@ -44396,7 +44487,8 @@ "supports_audio_output": true, "tpm": 250000, "rpm": 10, - "gemini_native_audio": true + "gemini_native_audio": true, + "cache_creation_min_tokens": 2048 }, "gemini/gemini-2.5-flash-native-audio-preview-12-2025": { "input_cost_per_audio_token": 1e-06, @@ -44423,7 +44515,8 @@ "supports_audio_output": true, "tpm": 250000, "rpm": 10, - "gemini_native_audio": true + "gemini_native_audio": true, + "cache_creation_min_tokens": 2048 }, "gemini/gemini-3.1-flash-live-preview": { "input_cost_per_audio_token": 3e-06, @@ -44458,7 +44551,8 @@ "supports_web_search": true, "tpm": 250000, "rpm": 10, - "gemini_audio_only_live": true + "gemini_audio_only_live": true, + "cache_creation_min_tokens": 4096 }, "gemini-2.5-flash-preview-tts": { "input_cost_per_token": 3e-07, @@ -44468,7 +44562,8 @@ "source": "https://ai.google.dev/pricing", "supported_endpoints": [ "/v1/audio/speech" - ] + ], + "cache_creation_min_tokens": 2048 }, "gemini-flash-latest": { "cache_read_input_token_cost": 3e-08, @@ -45963,4 +46058,4 @@ } ] } -} +} \ No newline at end of file diff --git a/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py b/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py index af2f0684e694..4896a75ade78 100644 --- a/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py +++ b/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py @@ -30,6 +30,16 @@ class TestGeminiContextCachingMinTokens: def test_min_tokens_by_model(self, model, expected): assert get_gemini_context_caching_min_tokens(model) == expected + def test_min_tokens_from_model_info(self, monkeypatch): + """Should prefer cache_creation_min_tokens from model_info if present.""" + import litellm + monkeypatch.setattr( + litellm, + "get_model_info", + lambda model, **kwargs: {"cache_creation_min_tokens": 12345} + ) + assert get_gemini_context_caching_min_tokens("gemini-1.5-pro") == 12345 + class TestTTLValidation: """Test TTL format validation""" @@ -70,6 +80,7 @@ class TestTTLNormalization: [ ("3600s", "3600s"), ("1.5s", "1.5s"), + ("1.3333333333333333s", "1.333333333s"), ("5m", "300s"), ("90m", "5400s"), ("1h", "3600s"), diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index a1a9448cc58d..1840f87360b8 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -712,6 +712,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "supports_computer_use": {"type": "boolean"}, "cache_creation_input_audio_token_cost": {"type": "number"}, "cache_creation_input_token_cost": {"type": "number"}, + "cache_creation_min_tokens": {"type": "number"}, "cache_creation_input_token_cost_above_1hr": {"type": "number"}, "cache_creation_input_token_cost_above_200k_tokens": {"type": "number"}, "cache_creation_input_token_cost_above_272k_tokens": {"type": "number"}, From 4a307e6759e539f7b010f6f4a94e6db964922a1a Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Wed, 22 Jul 2026 16:47:03 -0400 Subject: [PATCH 09/11] fix(responses): guard against None cache_control on content blocks --- .../transformation.py | 19 ++++++++----- .../test_litellm_completion_responses.py | 27 +++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 4e27b756b686..7e1260993914 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -1158,7 +1158,7 @@ def _transform_input_file_item_to_file_item(item: dict[str, Any]) -> dict[str, A file_dict["file_data"] = item["file_data"] new_item: dict[str, Any] = {"type": "file", "file": file_dict} - if "cache_control" in item: + if item.get("cache_control") is not None: new_item["cache_control"] = item["cache_control"] return new_item @@ -1180,16 +1180,15 @@ def _normalize_responses_api_object_to_dict(item: Any) -> dict[str, Any]: """ Normalize a Responses API object (Pydantic model or custom class) to a dictionary """ - if hasattr(item, "model_dump"): if hasattr(item, "model_dump"): try: return item.model_dump(exclude_none=True) - except Exception: # noqa: BLE001 # fallback if custom model_dump does not accept exclude_none + except TypeError: return item.model_dump() elif hasattr(item, "dict"): try: return item.dict(exclude_none=True) - except Exception: # noqa: BLE001 # fallback if custom dict does not accept exclude_none + except TypeError: return item.dict() target_attrs = ( @@ -1247,7 +1246,11 @@ def _transform_responses_api_content_to_chat_completion_content( elif item.get("type") == "input_image": image_block = { **LiteLLMCompletionResponsesConfig._transform_input_image_item_to_image_item(item), - **({"cache_control": item["cache_control"]} if "cache_control" in item else {}), + **( + {"cache_control": item["cache_control"]} + if item.get("cache_control") is not None + else {} + ), } content_list.append(image_block) else: @@ -1260,7 +1263,11 @@ def _transform_responses_api_content_to_chat_completion_content( item.get("type") or "text" ), "text": text_value, - **({"cache_control": item["cache_control"]} if "cache_control" in item else {}), + **( + {"cache_control": item["cache_control"]} + if item.get("cache_control") is not None + else {} + ), } content_list.append(content_block) return content_list diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 62bc61c1a635..ae2e624ef700 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -2635,6 +2635,33 @@ class RealInputItem(BaseModel): ) assert messages == [{"role": "user", "content": "hello", "cache_control": {"type": "ephemeral"}}] + def test_none_cache_control_omitted_from_content_blocks(self): + from pydantic import BaseModel + from typing import Optional + + class MockContentItem(BaseModel): + type: str = "text" + text: str = "hello world" + cache_control: Optional[dict] = None + + item = MockContentItem() + result = LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content([item]) + assert isinstance(result, list) + assert len(result) == 1 + assert "cache_control" not in result[0] + + dict_item = {"type": "text", "text": "hello", "cache_control": None} + result_dict = LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content([dict_item]) + assert isinstance(result_dict, list) + assert len(result_dict) == 1 + assert "cache_control" not in result_dict[0] + + image_item = {"type": "input_image", "image_url": "https://example.com/a.png", "cache_control": None} + result_img = LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content([image_item]) + assert isinstance(result_img, list) + assert len(result_img) == 1 + assert "cache_control" not in result_img[0] + def test_tool_call_output_as_custom_object(self): """Test _transform_responses_api_tool_call_output_to_chat_completion_message with a custom object.""" class MockToolCallOutput: From 3289e2283481ad1ff75c2430c7a181452bda4439 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:01:42 -0700 Subject: [PATCH 10/11] fix(anthropic): keep cache_control for Gemini targets on /v1/messages and normalize Anthropic ttl units --- .../adapters/transformation.py | 6 +- .../context_caching/transformation.py | 148 ++------- ...odel_prices_and_context_window_backup.json | 4 + model_prices_and_context_window.json | 4 + ...al_pass_through_adapters_transformation.py | 15 - .../test_context_caching_ttl.py | 283 +++--------------- .../test_vertex_ai_context_caching.py | 66 ++-- tests/test_litellm/test_utils.py | 1 - 8 files changed, 112 insertions(+), 415 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index e9235bc80a7e..7f78b16ec743 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -384,7 +384,7 @@ def _add_cache_control_if_applicable( cache_control: Final = ( source.get("cache_control") if isinstance(source, dict) else getattr(source, "cache_control", None) ) - if cache_control and model and (self.is_anthropic_claude_model(model) or self.is_bedrock_arn_model(model)): + if cache_control and model and self.target_consumes_cache_control(model): # TypedDict objects support dict operations at runtime # Use type ignore consistent with codebase pattern (see anthropic/chat/transformation.py:432) if isinstance(target, dict): @@ -677,6 +677,10 @@ def is_bedrock_arn_model(model: str) -> bool: model_lower: Final = model.lower() return "arn:" in model_lower and ":bedrock:" in model_lower + @classmethod + 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 translate_thinking_for_model( thinking: AnthropicThinkingParam, diff --git a/litellm/llms/vertex_ai/context_caching/transformation.py b/litellm/llms/vertex_ai/context_caching/transformation.py index 1563fb80d1b5..ef415dfa19ce 100644 --- a/litellm/llms/vertex_ai/context_caching/transformation.py +++ b/litellm/llms/vertex_ai/context_caching/transformation.py @@ -6,6 +6,7 @@ import re from collections.abc import Sequence +from types import MappingProxyType from typing import Final, Literal from litellm.types.llms.openai import AllMessageValues @@ -57,145 +58,56 @@ def extract_ttl_from_cached_messages(messages: list[AllMessageValues]) -> str | messages: List of messages to extract TTL from Returns: - Optional[str]: TTL string in format "3600s" or None if not found/invalid + Optional[str]: TTL normalized to Gemini's "s" form, or None if not found/invalid """ for message in messages: - # Check message-level cache_control first - msg_cache_control = ( - message.get("cache_control") if isinstance(message, dict) else getattr(message, "cache_control", None) - ) - if msg_cache_control is not None: - cc_type = ( - msg_cache_control.get("type") - if isinstance(msg_cache_control, dict) - else getattr(msg_cache_control, "type", None) - ) - if cc_type == "ephemeral": - ttl = ( - msg_cache_control.get("ttl") - if isinstance(msg_cache_control, dict) - else getattr(msg_cache_control, "ttl", None) - ) - normalized = _normalize_ttl_to_seconds(ttl) - if normalized is not None: - return normalized - - content = message.get("content") if isinstance(message, dict) else getattr(message, "content", None) - if not isinstance(content, list): + if not is_cached_message(message): continue - for content_item in content: - # Check if content_item is dict or object model - if isinstance(content_item, dict): - cache_control = content_item.get("cache_control") - item_type = content_item.get("type") - else: - cache_control = getattr(content_item, "cache_control", None) - item_type = getattr(content_item, "type", None) - - if item_type == "text" and cache_control is not None: - cc_type = ( - cache_control.get("type") - if isinstance(cache_control, dict) - else getattr(cache_control, "type", None) - ) - if cc_type == "ephemeral": - ttl = ( - cache_control.get("ttl") - if isinstance(cache_control, dict) - else getattr(cache_control, "ttl", None) - ) - normalized = _normalize_ttl_to_seconds(ttl) - if normalized is not None: - return normalized - - return None + content = message.get("content") + if not content or isinstance(content, str): + continue + for content_item in content: + # Type check to ensure content_item is a dictionary before calling .get() + if not isinstance(content_item, dict): + continue -def _is_valid_ttl_format(ttl: str) -> bool: - """ - Validate TTL format. Should be a string ending with 's' for seconds. - Examples: "3600s", "7200s", "1.5s" + cache_control = content_item.get("cache_control") + if not cache_control or not isinstance(cache_control, dict): + continue - Args: - ttl: TTL string to validate + if cache_control.get("type") != "ephemeral": + continue - Returns: - bool: True if valid format, False otherwise - """ - if not isinstance(ttl, str): - return False + normalized_ttl = _normalize_ttl_to_seconds(cache_control.get("ttl")) + if normalized_ttl is not None: + return normalized_ttl - # TTL should end with 's' and contain a valid number before it - pattern: Final = r"^([0-9]*\.?[0-9]+)s$" - match: Final = re.match(pattern, ttl) + return None - if not match: - return False - try: - # Ensure the numeric part is valid and positive - numeric_part: Final = float(match.group(1)) - return numeric_part > 0 - except ValueError: - return False +_TTL_PATTERN: Final = re.compile(r"^([0-9]*\.?[0-9]+)([smh])$") +_TTL_UNIT_SECONDS: Final = MappingProxyType({"s": 1, "m": 60, "h": 3600}) def _normalize_ttl_to_seconds(ttl: object) -> str | None: """ - Normalize a cache_control TTL into Gemini's "s" format. - - Accepts Gemini-native seconds (e.g. "3600s", "1.5s") and Anthropic-style - minute/hour units (e.g. "5m", "1h") that Claude Code and the Anthropic - /v1/messages spec use. Caps the requested TTL at 24 hours (86400s) to - prevent unbounded persistent storage costs. Returns None for missing or - unparseable values so Gemini falls back to its own default TTL. + Gemini's cachedContents API only takes a TTL as "s", while Anthropic clients + (Claude Code among them) send the minute and hour units the Anthropic API defines, "5m" + and "1h". Returns the Gemini form for any of the three units, or None for a missing, + non-positive, or unparseable value so the cache falls back to Gemini's default TTL. """ if not isinstance(ttl, str): return None - - match = re.match(r"^([0-9]*\.?[0-9]+)(s|m|h)$", ttl) - if not match: + match: Final = _TTL_PATTERN.match(ttl) + if match is None: return None - - value = float(match.group(1)) - + value: Final = float(match.group(1)) if value <= 0: return None - - multiplier = {"s": 1, "m": 60, "h": 3600}[match.group(2)] - seconds = value * multiplier - - # Cap explicit caches to 24 hours to prevent unbounded billing costs - seconds = min(seconds, 86400.0) - - # Google Protobuf Duration requires up to 9 fractional digits - seconds = round(seconds, 9) - return f"{int(seconds)}s" if seconds.is_integer() else f"{seconds}s" - - -def get_gemini_context_caching_min_tokens(model: str) -> int: - """ - Minimum input token count required to create an explicit Gemini context cache. - - Looks up the `cache_creation_min_tokens` property from model_prices_and_context_window.json. - Defaults to string-matching fallbacks for unknown models. - """ - import litellm - - try: - model_info = litellm.get_model_info(model=model) - if model_info and "cache_creation_min_tokens" in model_info: - return int(model_info["cache_creation_min_tokens"]) - except Exception: # noqa: BLE001 # fallback to string-matching heuristic if model lookup fails - pass - - model_lower = model.lower() - if "gemini-2.5" in model_lower or "gemini-2-5" in model_lower: - return 2048 - if "gemini-3" in model_lower: - return 4096 - return 32768 + seconds: Final = round(value * _TTL_UNIT_SECONDS[match.group(2)], 9) + return f"{seconds:.9f}".rstrip("0").rstrip(".") + "s" def separate_cached_messages( diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index f8c585873de7..fcdf6c4baa35 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -25066,6 +25066,7 @@ "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, + "prompt_cache_min_tokens": 2048, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, @@ -25925,6 +25926,7 @@ "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, + "prompt_cache_min_tokens": 2048, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, @@ -27137,6 +27139,7 @@ "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, + "prompt_cache_min_tokens": 2048, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, @@ -27895,6 +27898,7 @@ "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, + "prompt_cache_min_tokens": 2048, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index f8c585873de7..fcdf6c4baa35 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -25066,6 +25066,7 @@ "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, + "prompt_cache_min_tokens": 2048, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, @@ -25925,6 +25926,7 @@ "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, + "prompt_cache_min_tokens": 2048, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, @@ -27137,6 +27139,7 @@ "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, + "prompt_cache_min_tokens": 2048, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, @@ -27895,6 +27898,7 @@ "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, + "prompt_cache_min_tokens": 2048, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index d0b92c3e0733..471c09153c0b 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -2145,21 +2145,6 @@ def test_should_add_cache_control_for_gemini_model(): assert target.get("cache_control") == cache_control -def test_cache_control_fallback_setattr(): - """Verify cache_control is safely assigned to non-dict target objects using setattr.""" - adapter = LiteLLMAnthropicMessagesAdapter() - cache_control = {"type": "ephemeral"} - - class MockTarget: - pass - - target = MockTarget() - adapter._add_cache_control_if_applicable( - {"cache_control": cache_control}, target, "claude-3-opus-20240229" - ) - assert getattr(target, "cache_control", None) == cache_control - - def test_cache_control_preserved_in_text_content_for_gemini(): """cache_control must survive message translation for a Gemini target.""" anthropic_messages = [ diff --git a/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py b/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py index 4896a75ade78..b2da8da4cc53 100644 --- a/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py +++ b/tests/test_litellm/llms/vertex_ai/context_caching/test_context_caching_ttl.py @@ -1,124 +1,62 @@ import pytest from litellm.llms.vertex_ai.context_caching.transformation import ( - extract_ttl_from_cached_messages, - get_gemini_context_caching_min_tokens, - _is_valid_ttl_format, _normalize_ttl_to_seconds, + extract_ttl_from_cached_messages, transform_openai_messages_to_gemini_context_caching, ) -class TestGeminiContextCachingMinTokens: - """Per-model floor for explicit Gemini context cache creation.""" - - @pytest.mark.parametrize( - "model, expected", - [ - ("gemini-1.5-pro", 32768), - ("gemini-1.5-flash", 32768), - ("vertex_ai/gemini-1.5-pro-001", 32768), - ("gemini-2.5-flash", 2048), - ("gemini-2.5-pro", 2048), - ("gemini/gemini-2.5-pro", 2048), - ("vertex_ai/gemini-2.5-flash", 2048), - ("gemini-3.5-flash", 4096), - ("gemini-3.1-pro-preview", 4096), - ("gemini/gemini-3.5-flash", 4096), - ("gemini-unknown-future-model", 32768), - ], - ) - def test_min_tokens_by_model(self, model, expected): - assert get_gemini_context_caching_min_tokens(model) == expected - - def test_min_tokens_from_model_info(self, monkeypatch): - """Should prefer cache_creation_min_tokens from model_info if present.""" - import litellm - monkeypatch.setattr( - litellm, - "get_model_info", - lambda model, **kwargs: {"cache_creation_min_tokens": 12345} - ) - assert get_gemini_context_caching_min_tokens("gemini-1.5-pro") == 12345 - - -class TestTTLValidation: - """Test TTL format validation""" - - def test_valid_ttl_formats(self): - """Test various valid TTL formats""" - valid_ttls = ["3600s", "1s", "7200s", "1.5s", "0.1s", "86400s", "123.456s"] - - for ttl in valid_ttls: - assert _is_valid_ttl_format(ttl), f"TTL {ttl} should be valid" - - def test_invalid_ttl_formats(self): - """Test various invalid TTL formats""" - invalid_ttls = [ - "3600", # missing 's' - "s", # missing number - "-1s", # negative number - "0s", # zero - "3600m", # wrong unit - "abc.s", # invalid number - "", # empty string - "3600.s", # invalid decimal - "3600 s", # space - "3600ss", # extra 's' - None, # None - 123, # not a string - ] - - for ttl in invalid_ttls: - assert not _is_valid_ttl_format(ttl), f"TTL {ttl} should be invalid" - - class TestTTLNormalization: - """Normalization of anthropic-style TTL units into Gemini's seconds format.""" + """Gemini only takes "s"; Anthropic clients send "5m" and "1h" too""" @pytest.mark.parametrize( "ttl, expected", [ ("3600s", "3600s"), + ("1s", "1s"), ("1.5s", "1.5s"), + ("0.1s", "0.1s"), + ("123.456s", "123.456s"), ("1.3333333333333333s", "1.333333333s"), ("5m", "300s"), ("90m", "5400s"), ("1h", "3600s"), - ("2h", "7200s"), ("0.5h", "1800s"), - ("48h", "86400s"), - ("1500m", "86400s"), - ("1000000s", "86400s"), + ("48h", "172800s"), ], ) - def test_normalizes_units_to_seconds(self, ttl, expected): + def test_normalizes_supported_units_to_seconds(self, ttl, expected): assert _normalize_ttl_to_seconds(ttl) == expected @pytest.mark.parametrize( "ttl", - ["invalid", "", "0m", "0h", "-1h", "5d", "1 h", "m", None, 123, 3600], + [ + "3600", + "s", + "-1s", + "0s", + "0m", + "0h", + "5d", + "abc.s", + "", + "3600.s", + "3600 s", + "3600ss", + "1 h", + None, + 123, + ], ) def test_rejects_unparseable_ttl(self, ttl): assert _normalize_ttl_to_seconds(ttl) is None - def test_extract_ttl_normalizes_anthropic_hour_unit(self): - """Claude Code / Anthropic send "1h"; Gemini must receive "3600s".""" - messages = [ - { - "role": "system", - "content": [ - { - "type": "text", - "text": "cached", - "cache_control": {"type": "ephemeral", "ttl": "1h"}, - } - ], - } - ] - assert extract_ttl_from_cached_messages(messages) == "3600s" +class TestTTLExtraction: + """Test TTL extraction from cached messages""" - def test_extract_ttl_normalizes_anthropic_minute_unit(self): + @pytest.mark.parametrize("ttl, expected", [("1h", "3600s"), ("5m", "300s")]) + def test_extract_ttl_normalizes_anthropic_units(self, ttl, expected): messages = [ { "role": "system", @@ -126,17 +64,13 @@ def test_extract_ttl_normalizes_anthropic_minute_unit(self): { "type": "text", "text": "cached", - "cache_control": {"type": "ephemeral", "ttl": "5m"}, + "cache_control": {"type": "ephemeral", "ttl": ttl}, } ], } ] - assert extract_ttl_from_cached_messages(messages) == "300s" - - -class TestTTLExtraction: - """Test TTL extraction from cached messages""" + assert extract_ttl_from_cached_messages(messages) == expected def test_extract_ttl_from_single_message(self): """Test extracting TTL from a single cached message""" @@ -189,7 +123,9 @@ def test_extract_ttl_no_cache_control(self): messages = [ { "role": "user", - "content": [{"type": "text", "text": "Regular message without cache control"}], + "content": [ + {"type": "text", "text": "Regular message without cache control"} + ], } ] @@ -271,7 +207,9 @@ def test_extract_ttl_string_content(self): class TestTransformationWithTTL: """Test the complete transformation with TTL support""" - @pytest.mark.parametrize("custom_llm_provider", ["gemini", "vertex_ai", "vertex_ai_beta"]) + @pytest.mark.parametrize( + "custom_llm_provider", ["gemini", "vertex_ai", "vertex_ai_beta"] + ) def test_transform_with_valid_ttl(self, custom_llm_provider): """Test transformation includes TTL when provided""" messages = [ @@ -312,7 +250,9 @@ def test_transform_with_valid_ttl(self, custom_llm_provider): assert result["displayName"] == "test-cache-key" - @pytest.mark.parametrize("custom_llm_provider", ["gemini", "vertex_ai", "vertex_ai_beta"]) + @pytest.mark.parametrize( + "custom_llm_provider", ["gemini", "vertex_ai", "vertex_ai_beta"] + ) def test_transform_without_ttl(self, custom_llm_provider): """Test transformation without TTL""" messages = [ @@ -352,7 +292,9 @@ def test_transform_without_ttl(self, custom_llm_provider): assert result["displayName"] == "test-cache-key" - @pytest.mark.parametrize("custom_llm_provider", ["gemini", "vertex_ai", "vertex_ai_beta"]) + @pytest.mark.parametrize( + "custom_llm_provider", ["gemini", "vertex_ai", "vertex_ai_beta"] + ) def test_transform_with_invalid_ttl(self, custom_llm_provider): """Test transformation with invalid TTL (should be ignored)""" messages = [ @@ -391,7 +333,9 @@ def test_transform_with_invalid_ttl(self, custom_llm_provider): assert result["displayName"] == "test-cache-key" - @pytest.mark.parametrize("custom_llm_provider", ["gemini", "vertex_ai", "vertex_ai_beta"]) + @pytest.mark.parametrize( + "custom_llm_provider", ["gemini", "vertex_ai", "vertex_ai_beta"] + ) def test_transform_with_system_message_and_ttl(self, custom_llm_provider): """Test transformation with system message and TTL""" messages = [ @@ -476,143 +420,6 @@ def test_ttl_validation_type_conversion(self): assert isinstance(ttl, str) assert ttl == "3600s" - def test_cache_control_preserved_for_object_content_items(self): - """Test that cache_control is preserved when content items are real Pydantic models.""" - from pydantic import BaseModel, Field - from litellm.responses.litellm_completion_transformation.transformation import ( - LiteLLMCompletionResponsesConfig, - ) - - class MockContentBlock: - def __init__(self): - self.type = "text" - self.text = "hello" - self.cache_control = {"type": "ephemeral"} - - class RealPydanticV2Block(BaseModel): - type: str = "text" - text: str = "hello v2" - cache_control: dict = Field(default_factory=lambda: {"type": "ephemeral"}) - - class MockBlockWithNoneCacheControl: - def __init__(self): - self.type = "text" - self.text = "hello none" - self.cache_control = None - - content = [ - MockContentBlock(), - RealPydanticV2Block(), - MockBlockWithNoneCacheControl(), - ] - result = LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content(content) - assert result == [ - {"type": "text", "text": "hello", "cache_control": {"type": "ephemeral"}}, - {"type": "text", "text": "hello v2", "cache_control": {"type": "ephemeral"}}, - {"type": "text", "text": "hello none"}, - ] - - def test_is_cached_message_for_object_message_and_content_item(self): - """Test is_cached_message on custom objects / models.""" - from litellm.utils import is_cached_message - - # Test message level cache_control object - class MockCacheControl: - def __init__(self): - self.type = "ephemeral" - - class MockMessageLevelObj: - def __init__(self): - self.role = "system" - self.content = "hello" - self.cache_control = MockCacheControl() - - msg = MockMessageLevelObj() - assert is_cached_message(msg) is True - - # Test content level cache_control object - class MockContentItem: - def __init__(self): - self.type = "text" - self.text = "hello" - self.cache_control = MockCacheControl() - - class MockContentLevelObj: - def __init__(self): - self.role = "system" - self.content = [MockContentItem()] - - msg = MockContentLevelObj() - assert is_cached_message(msg) is True - - def test_extract_ttl_from_cached_messages_for_object_models(self): - """Test extract_ttl_from_cached_messages with object-based messages and content items.""" - - class MockCacheControl: - def __init__(self): - self.type = "ephemeral" - self.ttl = "3600s" - - class MockContentItem: - def __init__(self): - self.type = "text" - self.text = "hello" - self.cache_control = MockCacheControl() - - class MockMessageObj: - def __init__(self): - self.role = "system" - self.content = [MockContentItem()] - - messages = [MockMessageObj()] - ttl = extract_ttl_from_cached_messages(messages) - assert ttl == "3600s" - - def test_extract_ttl_from_cached_messages_with_message_level_object_cache_control(self): - """Test extract_ttl_from_cached_messages with message-level object cache_control.""" - - class MockCacheControl: - def __init__(self): - self.type = "ephemeral" - self.ttl = "7200s" - - class MockMessageObj: - def __init__(self): - self.role = "system" - self.content = "hello" - self.cache_control = MockCacheControl() - - messages = [MockMessageObj()] - ttl = extract_ttl_from_cached_messages(messages) - assert ttl == "7200s" - - def test_is_cached_message_for_dict_message_with_dict_content_items(self): - """Test is_cached_message with dict message and dict content list items.""" - from litellm.utils import is_cached_message - - # Dictionary message without content should return False - assert is_cached_message({"role": "user"}) is False - - msg = { - "role": "user", - "content": [ - {"type": "text", "text": "hello", "cache_control": {"type": "ephemeral"}} - ], - } - assert is_cached_message(msg) is True - - def test_normalize_responses_api_object_to_dict_pydantic_v1(self): - """Test _normalize_responses_api_object_to_dict with Pydantic v1 dict fallback.""" - from litellm.responses.litellm_completion_transformation.transformation import LiteLLMCompletionResponsesConfig - - class MockPydanticV1Model: - def dict(self): - return {"type": "text", "text": "hello", "cache_control": {"type": "ephemeral"}} - - item = MockPydanticV1Model() - res = LiteLLMCompletionResponsesConfig._normalize_responses_api_object_to_dict(item) - assert res == {"type": "text", "text": "hello", "cache_control": {"type": "ephemeral"}} - if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py b/tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py index 226c6441516b..5c33b9a995b8 100644 --- a/tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py +++ b/tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py @@ -1396,62 +1396,44 @@ def test_check_and_create_cache_skips_when_below_min_tokens( # Restart the patcher so teardown_method can stop it cleanly self._token_check_patcher.start() - @pytest.mark.parametrize( - "model, expected_min", - [ - ("gemini-3.5-flash", 4096), - ("gemini/gemini-3.5-flash", 4096), - ("gemini-3.1-pro-preview", 4096), - ("gemini-1.5-pro", 32768), - ("gemini-2.5-flash", 2048), - ("gemini-2.5-pro", 2048), - ], - ) - @patch( - "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.separate_cached_messages" - ) - def test_check_and_create_cache_uses_model_specific_min_tokens( - self, mock_separate, model, expected_min + @pytest.mark.parametrize("model", ["gemini-2.5-flash", "gemini-2.5-pro"]) + def test_check_and_create_cache_skips_between_default_and_gemini_2_5_minimum( + self, model, local_model_cost_map ): - """The Gemini per-model floor must be forwarded to the token-count guard. + """Gemini 2.5 Flash and Pro need 2048 cached tokens, twice the provider-agnostic default. - A flat 1024 floor let content between 1024 and the real minimum (2048 for - 2.5, 4096 for 3.x) reach Gemini and 400. Assert the model-derived floor is - passed so the guard skips instead of erroring. + Content between the two used to reach Google's cachedContents endpoint and 400. """ self._token_check_patcher.stop() cached_messages = [ { "role": "system", - "content": "cached", + "content": " ".join(["word"] * 1500), "cache_control": {"type": "ephemeral"}, } ] non_cached_messages = [{"role": "user", "content": "Hello"}] - mock_separate.return_value = (cached_messages, non_cached_messages) - with patch( - "litellm.llms.vertex_ai.context_caching.vertex_ai_context_caching.is_prompt_caching_valid_prompt", - return_value=False, - ) as mock_valid: - self.context_caching.check_and_create_cache( - messages=cached_messages + non_cached_messages, - optional_params=self.sample_optional_params.copy(), - api_key="test_key", - api_base=None, - model=model, - client=self.mock_client, - timeout=30.0, - logging_obj=self.mock_logging, - cached_content=None, - custom_llm_provider="gemini", - vertex_project="test_project", - vertex_location="us-central1", - vertex_auth_header="test_token", - ) + messages, _, returned_cache = self.context_caching.check_and_create_cache( + messages=cached_messages + non_cached_messages, + optional_params=self.sample_optional_params.copy(), + api_key="test_key", + api_base=None, + model=model, + client=self.mock_client, + timeout=30.0, + logging_obj=self.mock_logging, + cached_content=None, + custom_llm_provider="gemini", + vertex_project="test_project", + vertex_location="us-central1", + vertex_auth_header="test_token", + ) - assert mock_valid.call_args.kwargs["min_token_count"] == expected_min + assert messages == cached_messages + non_cached_messages + assert returned_cache is None + self.mock_client.post.assert_not_called() self._token_check_patcher.start() diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index c1c4613f7a9a..2fda5dfc490a 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -686,7 +686,6 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "supports_computer_use": {"type": "boolean"}, "cache_creation_input_audio_token_cost": {"type": "number"}, "cache_creation_input_token_cost": {"type": "number"}, - "cache_creation_min_tokens": {"type": "number"}, "cache_creation_input_token_cost_above_1hr": {"type": "number"}, "cache_creation_input_token_cost_above_128k_tokens": {"type": "number"}, "cache_creation_input_token_cost_above_200k_tokens": {"type": "number"}, From 2303379c2080aaeca398a7a3d3b8f5ac2019cc60 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:07:22 -0700 Subject: [PATCH 11/11] fix(gemini): keep the 2048 cache minimum on Gemini 2.5 Pro only, per Google's live cachedContents API --- litellm/model_prices_and_context_window_backup.json | 2 -- model_prices_and_context_window.json | 2 -- .../context_caching/test_vertex_ai_context_caching.py | 11 ++++++----- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index fcdf6c4baa35..de387552a44d 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -25066,7 +25066,6 @@ "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, - "prompt_cache_min_tokens": 2048, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, @@ -27139,7 +27138,6 @@ "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, - "prompt_cache_min_tokens": 2048, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index fcdf6c4baa35..de387552a44d 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -25066,7 +25066,6 @@ "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, - "prompt_cache_min_tokens": 2048, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, @@ -27139,7 +27138,6 @@ "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, - "prompt_cache_min_tokens": 2048, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, diff --git a/tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py b/tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py index 5c33b9a995b8..7cbfacfc3387 100644 --- a/tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py +++ b/tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py @@ -1396,14 +1396,15 @@ def test_check_and_create_cache_skips_when_below_min_tokens( # Restart the patcher so teardown_method can stop it cleanly self._token_check_patcher.start() - @pytest.mark.parametrize("model", ["gemini-2.5-flash", "gemini-2.5-pro"]) - def test_check_and_create_cache_skips_between_default_and_gemini_2_5_minimum( - self, model, local_model_cost_map + def test_check_and_create_cache_skips_between_default_and_gemini_2_5_pro_minimum( + self, local_model_cost_map ): - """Gemini 2.5 Flash and Pro need 2048 cached tokens, twice the provider-agnostic default. + """Gemini 2.5 Pro needs 2048 cached tokens, twice the provider-agnostic default. - Content between the two used to reach Google's cachedContents endpoint and 400. + Content between the two used to reach Google's cachedContents endpoint and 400 + with "Cached content is too small". """ + model = "gemini-2.5-pro" self._token_check_patcher.stop() cached_messages = [