From c2231b8af218de216fea1bb6e0c1643252c89c97 Mon Sep 17 00:00:00 2001 From: S0ngRu1 <1922909737@qq.com> Date: Mon, 23 Mar 2026 16:51:59 +0800 Subject: [PATCH] fix: raise BadRequestError for file content blocks missing 'file' sub-field Content blocks with type='file' but no nested 'file' dict caused an unhandled KeyError that surfaced as a 500 Internal Server Error. Fixed across all affected providers: - litellm/llms/vertex_ai/gemini/transformation.py - litellm/llms/gemini/chat/transformation.py - litellm/llms/openai/chat/gpt_transformation.py - litellm/litellm_core_utils/prompt_templates/factory.py (Bedrock sync + async) - litellm/litellm_core_utils/prompt_templates/common_utils.py (2 locations) All locations now raise BadRequestError (or skip gracefully) instead of letting KeyError propagate as a 500. Adds regression tests covering all fixed providers. --- .../prompt_templates/common_utils.py | 34 ++- .../prompt_templates/factory.py | 29 +- litellm/llms/gemini/chat/transformation.py | 15 +- .../llms/openai/chat/gpt_transformation.py | 8 +- .../llms/vertex_ai/gemini/transformation.py | 17 +- .../llms/test_file_content_block.py | 256 ++++++++++++++++++ 6 files changed, 332 insertions(+), 27 deletions(-) create mode 100644 tests/test_litellm/llms/test_file_content_block.py diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index f417b4a5f61..a8abc4f69a9 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -20,6 +20,7 @@ from typing import ( cast, ) +import litellm from litellm import verbose_logger from litellm.router_utils.batch_utils import InMemoryFile from litellm.types.llms.openai import ( @@ -469,12 +470,11 @@ def update_messages_with_model_file_ids( file_object = cast(ChatCompletionFileObject, c) file_object_file_field = file_object.get("file") if not isinstance(file_object_file_field, dict): - # Content block has `type: "file"` but not the - # OpenAI Chat Completions shape (e.g. a LangChain - # v1 standardized file block, or a provider-native - # shape that also uses `type: "file"`). Nothing to - # remap here, so skip instead of crashing. - continue + raise litellm.BadRequestError( + message="Content block has type='file' but is missing the required 'file' field", + model=None, + llm_provider=None, + ) file_id = file_object_file_field.get("file_id") format = file_object_file_field.get( "format", get_format_from_file_id(file_id) @@ -1109,10 +1109,11 @@ def get_file_ids_from_messages(messages: List[AllMessageValues]) -> List[str]: file_object = cast(ChatCompletionFileObject, c) file_object_file_field = file_object.get("file") if not isinstance(file_object_file_field, dict): - # Content block has `type: "file"` but not the - # OpenAI Chat Completions shape. No file_id to - # extract, so skip instead of raising KeyError. - continue + raise litellm.BadRequestError( + message="Content block has type='file' but is missing the required 'file' field", + model=None, + llm_provider=None, + ) file_id = file_object_file_field.get("file_id") if file_id: file_ids.append(file_id) @@ -1170,9 +1171,16 @@ def migrate_file_to_image_url( ChatCompletionImageUrlObject, ) - file_id = message["file"].get("file_id") - file_data = message["file"].get("file_data") - format = message["file"].get("format") + file_sub = message.get("file") + if file_sub is None: + raise litellm.BadRequestError( + message="Content block has type='file' but is missing the required 'file' field", + model=None, + llm_provider=None, + ) + file_id = file_sub.get("file_id") + file_data = file_sub.get("file_data") + format = file_sub.get("format") if not file_id and not file_data: raise ValueError("file_id and file_data are both None") image_url_object = ChatCompletionImageObject( diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index abe9e016e26..3d0a8e16fcf 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -2057,9 +2057,16 @@ def anthropic_process_openai_file_message( AnthropicMessagesContainerUploadParam, ]: file_message = cast(ChatCompletionFileObject, message) - file_data = file_message["file"].get("file_data") - file_id = file_message["file"].get("file_id") - format = file_message["file"].get("format") + file_sub = file_message.get("file") + if file_sub is None: + raise litellm.BadRequestError( + message="Content block has type='file' but is missing the required 'file' field", + model=None, + llm_provider="anthropic", + ) + file_data = file_sub.get("file_data") + file_id = file_sub.get("file_id") + format = file_sub.get("format") if file_data: image_chunk = convert_to_anthropic_image_obj( openai_image_url=file_data, @@ -4879,7 +4886,13 @@ class BedrockConverseMessagesProcessor: @staticmethod def _process_file_message(message: ChatCompletionFileObject) -> BedrockContentBlock: - file_message = message["file"] + file_message = message.get("file") + if file_message is None: + raise litellm.BadRequestError( + message="Content block has type='file' but is missing the required 'file' field", + model=None, + llm_provider="bedrock", + ) file_data = file_message.get("file_data") file_id = file_message.get("file_id") @@ -4900,7 +4913,13 @@ class BedrockConverseMessagesProcessor: async def _async_process_file_message( message: ChatCompletionFileObject, ) -> BedrockContentBlock: - file_message = message["file"] + file_message = message.get("file") + if file_message is None: + raise litellm.BadRequestError( + message="Content block has type='file' but is missing the required 'file' field", + model=None, + llm_provider="bedrock", + ) file_data = file_message.get("file_data") file_id = file_message.get("file_id") format = file_message.get("format") diff --git a/litellm/llms/gemini/chat/transformation.py b/litellm/llms/gemini/chat/transformation.py index 72569e5c6cd..fb5239e61f6 100644 --- a/litellm/llms/gemini/chat/transformation.py +++ b/litellm/llms/gemini/chat/transformation.py @@ -1,5 +1,7 @@ from typing import List, Optional, cast +import litellm + from litellm.litellm_core_utils.prompt_templates.factory import ( convert_generic_image_chunk_to_openai_image_obj, convert_to_anthropic_image_obj, @@ -141,13 +143,20 @@ class GoogleAIStudioGeminiConfig(VertexGeminiConfig): img_element["image_url"] = converted_image_url # type: ignore elif element.get("type") == "file": file_element = cast(ChatCompletionFileObject, element) - file_id = file_element["file"].get("file_id") + _file_field = file_element.get("file") + if _file_field is None: + raise litellm.BadRequestError( + message="Content block has type='file' but is missing the required 'file' field", + model=model, + llm_provider="gemini", + ) + file_id = _file_field.get("file_id") if file_id and ("http://" in file_id or "https://" in file_id): # Convert HTTP/HTTPS file URL to base64 data try: base64_data = convert_url_to_base64(file_id) - file_element["file"]["file_data"] = base64_data # type: ignore - file_element["file"].pop("file_id", None) # type: ignore + _file_field["file_data"] = base64_data # type: ignore + _file_field.pop("file_id", None) # type: ignore except Exception: # If conversion fails, leave as is and let the API handle it pass diff --git a/litellm/llms/openai/chat/gpt_transformation.py b/litellm/llms/openai/chat/gpt_transformation.py index 6b7ec4dfb1c..5464b5bb7ee 100644 --- a/litellm/llms/openai/chat/gpt_transformation.py +++ b/litellm/llms/openai/chat/gpt_transformation.py @@ -287,7 +287,13 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig): content_item["image_url"] = new_image_url_obj elif content_item.get("type") == "file": content_item = cast(ChatCompletionFileObject, content_item) - file_obj = content_item["file"] + file_obj = content_item.get("file") + if file_obj is None: + raise litellm.BadRequestError( + message="Content block has type='file' but is missing the required 'file' field", + model=None, + llm_provider="openai", + ) new_file_obj = ChatCompletionFileObjectFile( **{ # type: ignore k: v diff --git a/litellm/llms/vertex_ai/gemini/transformation.py b/litellm/llms/vertex_ai/gemini/transformation.py index fa20386da79..5102ed6c483 100644 --- a/litellm/llms/vertex_ai/gemini/transformation.py +++ b/litellm/llms/vertex_ai/gemini/transformation.py @@ -407,11 +407,18 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915 _parts.append(_part) elif element["type"] == "file": file_element = cast(ChatCompletionFileObject, element) - file_id = file_element["file"].get("file_id") - format = file_element["file"].get("format") - file_data = file_element["file"].get("file_data") - detail = file_element["file"].get("detail") - video_metadata = file_element["file"].get("video_metadata") + _file_field = file_element.get("file") + if _file_field is None: + raise litellm.BadRequestError( + message="Content block has type='file' but is missing the required 'file' field", + model=model, + llm_provider="vertex_ai", + ) + file_id = _file_field.get("file_id") + format = _file_field.get("format") + file_data = _file_field.get("file_data") + detail = _file_field.get("detail") + video_metadata = _file_field.get("video_metadata") passed_file = file_id or file_data if passed_file is None: raise Exception( diff --git a/tests/test_litellm/llms/test_file_content_block.py b/tests/test_litellm/llms/test_file_content_block.py new file mode 100644 index 00000000000..b403c827649 --- /dev/null +++ b/tests/test_litellm/llms/test_file_content_block.py @@ -0,0 +1,256 @@ +""" +Tests for handling malformed 'file' content blocks (missing 'file' sub-field). + +Regression tests for: +- litellm/llms/vertex_ai/gemini/transformation.py +- litellm/llms/gemini/chat/transformation.py +- litellm/litellm_core_utils/prompt_templates/common_utils.py +- litellm/litellm_core_utils/prompt_templates/factory.py (Bedrock + Anthropic) +- litellm/llms/openai/chat/gpt_transformation.py +""" + +import copy +from typing import List, cast + +import pytest + +import litellm +from litellm.litellm_core_utils.prompt_templates.common_utils import ( + get_file_ids_from_messages, + migrate_file_to_image_url, + update_messages_with_model_file_ids, +) +from litellm.litellm_core_utils.prompt_templates.factory import ( + BedrockConverseMessagesProcessor, + anthropic_process_openai_file_message, +) +from litellm.llms.gemini.chat.transformation import GoogleAIStudioGeminiConfig +from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig +from litellm.llms.vertex_ai.gemini.transformation import ( + _gemini_convert_messages_with_history, +) +from litellm.types.llms.openai import ( + AllMessageValues, + ChatCompletionFileObject, + OpenAIMessageContentListBlock, +) + +_MALFORMED_MESSAGES_RAW = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "hello"}, + {"type": "file"}, # Missing required "file" sub-field + ], + } +] + +_WELL_FORMED_MESSAGES_RAW = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "hello"}, + { + "type": "file", + "file": {"file_id": "file-abc123", "format": "pdf"}, + }, + ], + } +] + +MALFORMED_FILE_OBJECT: ChatCompletionFileObject = cast( + ChatCompletionFileObject, {"type": "file"} +) + + +def _malformed() -> List[AllMessageValues]: + return copy.deepcopy(cast(List[AllMessageValues], _MALFORMED_MESSAGES_RAW)) + + +def _well_formed() -> List[AllMessageValues]: + return copy.deepcopy(cast(List[AllMessageValues], _WELL_FORMED_MESSAGES_RAW)) + + +# --------------------------------------------------------------------------- +# vertex_ai/gemini/transformation.py +# --------------------------------------------------------------------------- + + +def test_gemini_convert_messages_malformed_file_raises_bad_request(): + """_gemini_convert_messages_with_history should raise BadRequestError (not KeyError) + when a content block has type='file' but no 'file' sub-field.""" + with pytest.raises(litellm.BadRequestError, match="missing the required 'file' field"): + _gemini_convert_messages_with_history( + messages=_malformed(), + model="gemini-2.0-flash", + ) + + +# --------------------------------------------------------------------------- +# gemini/chat/transformation.py - GoogleAIStudioGeminiConfig +# --------------------------------------------------------------------------- + + +def test_google_ai_studio_transform_messages_malformed_file_raises_bad_request(): + """GoogleAIStudioGeminiConfig._transform_messages should raise BadRequestError + when a content block has type='file' but no 'file' sub-field.""" + config = GoogleAIStudioGeminiConfig() + with pytest.raises(litellm.BadRequestError, match="missing the required 'file' field"): + config._transform_messages(messages=_malformed(), model="gemini-2.0-flash") + + +# --------------------------------------------------------------------------- +# common_utils.py - update_messages_with_model_file_ids +# --------------------------------------------------------------------------- + + +def test_update_messages_with_model_file_ids_malformed_raises_bad_request(): + """update_messages_with_model_file_ids should raise BadRequestError for content + blocks that have type='file' but no 'file' sub-field.""" + with pytest.raises(litellm.BadRequestError, match="missing the required 'file' field"): + update_messages_with_model_file_ids( + messages=_malformed(), + model_id="some-model", + model_file_id_mapping={}, + ) + + +def test_update_messages_with_model_file_ids_well_formed_updates(): + """update_messages_with_model_file_ids should update file_id for well-formed blocks.""" + mapping = {"file-abc123": {"some-model": "provider-file-xyz"}} + result = update_messages_with_model_file_ids( + messages=_well_formed(), + model_id="some-model", + model_file_id_mapping=mapping, + ) + content = result[0].get("content") + assert isinstance(content, list) + file_block = next(c for c in content if c.get("type") == "file") + assert file_block.get("file", {}).get("file_id") == "provider-file-xyz" + + +# --------------------------------------------------------------------------- +# common_utils.py - get_file_ids_from_messages +# --------------------------------------------------------------------------- + + +def test_get_file_ids_from_messages_malformed_raises_bad_request(): + """get_file_ids_from_messages should raise BadRequestError for malformed file blocks.""" + with pytest.raises(litellm.BadRequestError, match="missing the required 'file' field"): + get_file_ids_from_messages(messages=_malformed()) + + +def test_get_file_ids_from_messages_well_formed_returns_ids(): + """get_file_ids_from_messages should extract file_id from well-formed blocks.""" + messages: List[AllMessageValues] = cast( + List[AllMessageValues], + [ + { + "role": "user", + "content": [ + {"type": "text", "text": "hello"}, + {"type": "file", "file": {"file_id": "file-abc123", "format": "pdf"}}, + ], + } + ], + ) + result = get_file_ids_from_messages(messages=messages) + assert result == ["file-abc123"] + + +# --------------------------------------------------------------------------- +# factory.py - BedrockConverseMessagesProcessor (sync + async) +# --------------------------------------------------------------------------- + + +def test_bedrock_process_file_message_malformed_raises_bad_request(): + """_process_file_message should raise BadRequestError (not KeyError) + when the file object is missing the 'file' sub-field.""" + with pytest.raises(litellm.BadRequestError, match="missing the required 'file' field"): + BedrockConverseMessagesProcessor._process_file_message(MALFORMED_FILE_OBJECT) + + +@pytest.mark.asyncio +async def test_bedrock_async_process_file_message_malformed_raises_bad_request(): + """_async_process_file_message should raise BadRequestError (not KeyError) + when the file object is missing the 'file' sub-field.""" + with pytest.raises(litellm.BadRequestError, match="missing the required 'file' field"): + await BedrockConverseMessagesProcessor._async_process_file_message( + MALFORMED_FILE_OBJECT + ) + + +# --------------------------------------------------------------------------- +# openai/chat/gpt_transformation.py +# --------------------------------------------------------------------------- + + +def test_openai_apply_common_transform_malformed_file_raises_bad_request(): + """_apply_common_transform_content_item should raise BadRequestError (not KeyError) + when a content block has type='file' but no 'file' sub-field.""" + config = OpenAIGPTConfig() + malformed_block: OpenAIMessageContentListBlock = cast( + OpenAIMessageContentListBlock, {"type": "file"} + ) + with pytest.raises(litellm.BadRequestError, match="missing the required 'file' field"): + config._apply_common_transform_content_item(malformed_block) + + +def test_openai_apply_common_transform_well_formed_file_does_not_raise(): + """_apply_common_transform_content_item should not raise for well-formed file blocks.""" + config = OpenAIGPTConfig() + well_formed_block: OpenAIMessageContentListBlock = cast( + OpenAIMessageContentListBlock, + {"type": "file", "file": {"file_id": "file-abc123"}}, + ) + result = config._apply_common_transform_content_item(well_formed_block) + assert result.get("type") == "file" + file_field = cast(ChatCompletionFileObject, result).get("file", {}) + assert file_field.get("file_id") == "file-abc123" + + +# --------------------------------------------------------------------------- +# factory.py - anthropic_process_openai_file_message +# --------------------------------------------------------------------------- + + +def test_anthropic_process_openai_file_message_malformed_raises_bad_request(): + """anthropic_process_openai_file_message should raise BadRequestError (not KeyError) + when the file object is missing the 'file' sub-field.""" + with pytest.raises(litellm.BadRequestError, match="missing the required 'file' field"): + anthropic_process_openai_file_message(MALFORMED_FILE_OBJECT) + + +def test_anthropic_process_openai_file_message_well_formed_file_id_does_not_raise(): + """anthropic_process_openai_file_message should not raise for a well-formed file_id block.""" + well_formed: ChatCompletionFileObject = cast( + ChatCompletionFileObject, + {"type": "file", "file": {"file_id": "file-abc123"}}, + ) + result = anthropic_process_openai_file_message(well_formed) + assert result.get("type") in ("document", "image", "container_upload") + + +# --------------------------------------------------------------------------- +# common_utils.py - migrate_file_to_image_url +# --------------------------------------------------------------------------- + + +def test_migrate_file_to_image_url_malformed_raises_bad_request(): + """migrate_file_to_image_url should raise BadRequestError (not KeyError) + when the file object is missing the 'file' sub-field.""" + with pytest.raises(litellm.BadRequestError, match="missing the required 'file' field"): + migrate_file_to_image_url(MALFORMED_FILE_OBJECT) + + +def test_migrate_file_to_image_url_well_formed_returns_image_url(): + """migrate_file_to_image_url should return an image_url block for a well-formed file.""" + well_formed: ChatCompletionFileObject = cast( + ChatCompletionFileObject, + {"type": "file", "file": {"file_id": "file-abc123", "format": "png"}}, + ) + result = migrate_file_to_image_url(well_formed) + assert result.get("type") == "image_url" + image_url = result.get("image_url", {}) + assert isinstance(image_url, dict) + assert image_url.get("url") == "file-abc123"