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 1/3] 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 f417b4a5f61a..a8abc4f69a98 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 @@ 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 abe9e016e268..3d0a8e16fcf4 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 @@ def translate_thinking_blocks_to_reasoning_content_blocks( @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 @@ def _process_file_message(message: ChatCompletionFileObject) -> BedrockContentBl 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 72569e5c6cda..fb5239e61f6d 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 @@ def _transform_messages( 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 6b7ec4dfb1c2..5464b5bb7eea 100644 --- a/litellm/llms/openai/chat/gpt_transformation.py +++ b/litellm/llms/openai/chat/gpt_transformation.py @@ -287,7 +287,13 @@ def _apply_common_transform_content_item( 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 fa20386da79e..5102ed6c483d 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 000000000000..b403c8276494 --- /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" From 264df382d4b08f47fababb12ee50c7fbb14066d8 Mon Sep 17 00:00:00 2001 From: S0ngRu1 <1922909737@qq.com> Date: Wed, 13 May 2026 15:35:48 +0800 Subject: [PATCH 2/3] fix: skip non-OpenAI file blocks in message updates Updated the `update_messages_with_model_file_ids` and `get_file_ids_from_messages` functions to skip content blocks with type='file' that do not conform to the OpenAI Chat Completions shape. This prevents unhandled KeyErrors and allows for smoother processing of messages with varying file block structures. Added regression tests to ensure that non-compliant file blocks are handled gracefully without raising errors. --- .../prompt_templates/common_utils.py | 20 +- .../llms/test_file_content_block.py | 173 ++++++++++++++++-- 2 files changed, 163 insertions(+), 30 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index a8abc4f69a98..3ee56dfc5cae 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -470,11 +470,12 @@ 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): - raise litellm.BadRequestError( - message="Content block has type='file' but is missing the required 'file' field", - model=None, - llm_provider=None, - ) + # 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 file_id = file_object_file_field.get("file_id") format = file_object_file_field.get( "format", get_format_from_file_id(file_id) @@ -1109,11 +1110,10 @@ 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): - raise litellm.BadRequestError( - message="Content block has type='file' but is missing the required 'file' field", - model=None, - llm_provider=None, - ) + # Content block has `type: "file"` but not the + # OpenAI Chat Completions shape. No file_id to + # extract, so skip instead of raising KeyError. + continue file_id = file_object_file_field.get("file_id") if file_id: file_ids.append(file_id) diff --git a/tests/test_litellm/llms/test_file_content_block.py b/tests/test_litellm/llms/test_file_content_block.py index b403c8276494..d2401d912fce 100644 --- a/tests/test_litellm/llms/test_file_content_block.py +++ b/tests/test_litellm/llms/test_file_content_block.py @@ -1,14 +1,17 @@ """ -Tests for handling malformed 'file' content blocks (missing 'file' sub-field). +Tests for handling malformed or invalid 'file' content blocks (missing or null +`file` sub-field, HTTP file_id URLs for Google AI Studio). 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 + (migrate_file_to_image_url raises on missing `file`; file-id helpers skip non-OpenAI shapes) - litellm/litellm_core_utils/prompt_templates/factory.py (Bedrock + Anthropic) - litellm/llms/openai/chat/gpt_transformation.py """ +import asyncio import copy from typing import List, cast @@ -62,6 +65,11 @@ ChatCompletionFileObject, {"type": "file"} ) +EXPLICIT_NULL_FILE_OBJECT: ChatCompletionFileObject = cast( + ChatCompletionFileObject, + {"type": "file", "file": None}, +) + def _malformed() -> List[AllMessageValues]: return copy.deepcopy(cast(List[AllMessageValues], _MALFORMED_MESSAGES_RAW)) @@ -71,6 +79,23 @@ def _well_formed() -> List[AllMessageValues]: return copy.deepcopy(cast(List[AllMessageValues], _WELL_FORMED_MESSAGES_RAW)) +def _explicit_null_file_in_content() -> List[AllMessageValues]: + return copy.deepcopy( + cast( + List[AllMessageValues], + [ + { + "role": "user", + "content": [ + {"type": "text", "text": "hello"}, + {"type": "file", "file": None}, + ], + } + ], + ) + ) + + # --------------------------------------------------------------------------- # vertex_ai/gemini/transformation.py # --------------------------------------------------------------------------- @@ -86,6 +111,15 @@ def test_gemini_convert_messages_malformed_file_raises_bad_request(): ) +def test_gemini_convert_messages_explicit_null_file_field_raises_bad_request(): + """Explicit JSON null for `file` must be rejected like a missing `file` key.""" + with pytest.raises(litellm.BadRequestError, match="missing the required 'file' field"): + _gemini_convert_messages_with_history( + messages=_explicit_null_file_in_content(), + model="gemini-2.0-flash", + ) + + # --------------------------------------------------------------------------- # gemini/chat/transformation.py - GoogleAIStudioGeminiConfig # --------------------------------------------------------------------------- @@ -99,20 +133,78 @@ def test_google_ai_studio_transform_messages_malformed_file_raises_bad_request() config._transform_messages(messages=_malformed(), model="gemini-2.0-flash") +def test_google_ai_studio_transform_messages_explicit_null_file_field_raises_bad_request(): + """Explicit JSON null for `file` must be rejected like a missing `file` key.""" + config = GoogleAIStudioGeminiConfig() + with pytest.raises(litellm.BadRequestError, match="missing the required 'file' field"): + config._transform_messages( + messages=_explicit_null_file_in_content(), model="gemini-2.0-flash" + ) + + +def test_google_ai_studio_transform_messages_http_file_id_converts_to_base64(monkeypatch): + """Google AI Studio rejects raw HTTP(S) file URLs; _transform_messages should + fetch and replace them with base64 `file_data` before conversion.""" + # Data URL shape so downstream Gemini media parsing accepts the inlined bytes + # (mirrors real `convert_url_to_base64` output from `_process_image_response`). + fake_file_data = "data:application/pdf;base64,aGVsbG8=" + + def _fake_convert_url_to_base64(url: str) -> str: + assert url == "https://example.com/doc.pdf" + return fake_file_data + + monkeypatch.setattr( + "litellm.llms.gemini.chat.transformation.convert_url_to_base64", + _fake_convert_url_to_base64, + ) + messages = cast( + List[AllMessageValues], + [ + { + "role": "user", + "content": [ + {"type": "text", "text": "hello"}, + { + "type": "file", + "file": { + "file_id": "https://example.com/doc.pdf", + "format": "pdf", + }, + }, + ], + } + ], + ) + config = GoogleAIStudioGeminiConfig() + config._transform_messages(messages=messages, model="gemini-2.0-flash") + content = messages[0].get("content") + assert isinstance(content, list) + file_block = next(c for c in content if isinstance(c, dict) and c.get("type") == "file") + file_field = file_block.get("file") + assert isinstance(file_field, dict) + assert file_field.get("file_data") == fake_file_data + assert "file_id" not in file_field + + # --------------------------------------------------------------------------- # 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_malformed_skips_non_openai_file_block(): + """Non-OpenAI file blocks (e.g. missing nested `file` dict) are skipped so callers + relying on LangChain v1 / provider-native shapes are not rejected here.""" + messages = _malformed() + result = update_messages_with_model_file_ids( + messages=messages, + model_id="some-model", + model_file_id_mapping={}, + ) + assert result == messages + content = result[0].get("content") + assert isinstance(content, list) + file_block = next(c for c in content if isinstance(c, dict) and c.get("type") == "file") + assert "file" not in file_block def test_update_messages_with_model_file_ids_well_formed_updates(): @@ -134,10 +226,9 @@ def test_update_messages_with_model_file_ids_well_formed_updates(): # --------------------------------------------------------------------------- -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_malformed_skips_non_openai_file_block(): + """Blocks with type='file' but no OpenAI `file` sub-dict yield no extracted ids.""" + assert get_file_ids_from_messages(messages=_malformed()) == [] def test_get_file_ids_from_messages_well_formed_returns_ids(): @@ -170,14 +261,36 @@ def test_bedrock_process_file_message_malformed_raises_bad_request(): BedrockConverseMessagesProcessor._process_file_message(MALFORMED_FILE_OBJECT) -@pytest.mark.asyncio -async def test_bedrock_async_process_file_message_malformed_raises_bad_request(): +def test_bedrock_process_file_message_explicit_null_file_field_raises_bad_request(): + with pytest.raises(litellm.BadRequestError, match="missing the required 'file' field"): + BedrockConverseMessagesProcessor._process_file_message(EXPLICIT_NULL_FILE_OBJECT) + + +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 - ) + + async def _run() -> None: + with pytest.raises( + litellm.BadRequestError, match="missing the required 'file' field" + ): + await BedrockConverseMessagesProcessor._async_process_file_message( + MALFORMED_FILE_OBJECT + ) + + asyncio.run(_run()) + + +def test_bedrock_async_process_file_message_explicit_null_file_field_raises_bad_request(): + async def _run() -> None: + with pytest.raises( + litellm.BadRequestError, match="missing the required 'file' field" + ): + await BedrockConverseMessagesProcessor._async_process_file_message( + EXPLICIT_NULL_FILE_OBJECT + ) + + asyncio.run(_run()) # --------------------------------------------------------------------------- @@ -196,6 +309,16 @@ def test_openai_apply_common_transform_malformed_file_raises_bad_request(): config._apply_common_transform_content_item(malformed_block) +def test_openai_apply_common_transform_explicit_null_file_field_raises_bad_request(): + config = OpenAIGPTConfig() + explicit_null_block: OpenAIMessageContentListBlock = cast( + OpenAIMessageContentListBlock, + {"type": "file", "file": None}, + ) + with pytest.raises(litellm.BadRequestError, match="missing the required 'file' field"): + config._apply_common_transform_content_item(explicit_null_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() @@ -221,6 +344,11 @@ def test_anthropic_process_openai_file_message_malformed_raises_bad_request(): anthropic_process_openai_file_message(MALFORMED_FILE_OBJECT) +def test_anthropic_process_openai_file_message_explicit_null_file_field_raises_bad_request(): + with pytest.raises(litellm.BadRequestError, match="missing the required 'file' field"): + anthropic_process_openai_file_message(EXPLICIT_NULL_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( @@ -243,6 +371,11 @@ def test_migrate_file_to_image_url_malformed_raises_bad_request(): migrate_file_to_image_url(MALFORMED_FILE_OBJECT) +def test_migrate_file_to_image_url_explicit_null_file_field_raises_bad_request(): + with pytest.raises(litellm.BadRequestError, match="missing the required 'file' field"): + migrate_file_to_image_url(EXPLICIT_NULL_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( From 93d7e304aee2fee5781bb1d05d2460c0aafc62f3 Mon Sep 17 00:00:00 2001 From: S0ngRu1 <1922909737@qq.com> Date: Wed, 13 May 2026 17:11:12 +0800 Subject: [PATCH 3/3] test: cover Google AI Studio HTTP file_id convert failure path Co-authored-by: Cursor --- .../llms/test_file_content_block.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/test_litellm/llms/test_file_content_block.py b/tests/test_litellm/llms/test_file_content_block.py index d2401d912fce..5552c1a4d68e 100644 --- a/tests/test_litellm/llms/test_file_content_block.py +++ b/tests/test_litellm/llms/test_file_content_block.py @@ -186,6 +186,50 @@ def _fake_convert_url_to_base64(url: str) -> str: assert "file_id" not in file_field +def test_google_ai_studio_transform_messages_http_file_id_convert_failure_leaves_file_unchanged( + monkeypatch, +): + """If convert_url_to_base64 fails, the Studio prep step must not mutate the block + (see try/except in GoogleAIStudioGeminiConfig._transform_messages).""" + https_id = "https://example.com/missing.pdf" + + def _raise(_url: str) -> str: + raise litellm.ImageFetchError("simulated fetch failure") + + monkeypatch.setattr( + "litellm.llms.gemini.chat.transformation.convert_url_to_base64", + _raise, + ) + messages = cast( + List[AllMessageValues], + [ + { + "role": "user", + "content": [ + {"type": "text", "text": "hello"}, + { + "type": "file", + "file": { + "file_id": https_id, + "format": "application/pdf", + }, + }, + ], + } + ], + ) + config = GoogleAIStudioGeminiConfig() + config._transform_messages(messages=messages, model="gemini-2.0-flash") + content = messages[0].get("content") + assert isinstance(content, list) + file_block = next(c for c in content if isinstance(c, dict) and c.get("type") == "file") + file_field = file_block.get("file") + assert isinstance(file_field, dict) + assert file_field.get("file_id") == https_id + assert file_field.get("format") == "application/pdf" + assert "file_data" not in file_field + + # --------------------------------------------------------------------------- # common_utils.py - update_messages_with_model_file_ids # ---------------------------------------------------------------------------