From 1ba02c0163d467b4327657586ed7f3db2e667bbf Mon Sep 17 00:00:00 2001 From: Kris Xia Date: Wed, 13 May 2026 17:49:26 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20raise=20BadRequestError=20for=20file=20c?= =?UTF-8?q?ontent=20blocks=20missing=20'file'=20sub=E2=80=A6=20(#24503)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squash-merged by litellm-agent from krisxia0506's PR. --- .../prompt_templates/common_utils.py | 14 +- .../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 | 433 ++++++++++++++++++ 6 files changed, 499 insertions(+), 17 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..3ee56dfc5ca 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 ( @@ -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 d40ca4e3597..79d527d1eb8 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..5552c1a4d68 --- /dev/null +++ b/tests/test_litellm/llms/test_file_content_block.py @@ -0,0 +1,433 @@ +""" +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 + +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"} +) + +EXPLICIT_NULL_FILE_OBJECT: ChatCompletionFileObject = cast( + ChatCompletionFileObject, + {"type": "file", "file": None}, +) + + +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)) + + +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 +# --------------------------------------------------------------------------- + + +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", + ) + + +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 +# --------------------------------------------------------------------------- + + +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") + + +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 + + +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 +# --------------------------------------------------------------------------- + + +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(): + """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_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(): + """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) + + +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.""" + + 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()) + + +# --------------------------------------------------------------------------- +# 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_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() + 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_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( + 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_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( + 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"