fix: raise BadRequestError for file content blocks missing 'file' sub… (#24503)

Squash-merged by litellm-agent from krisxia0506's PR.
This commit is contained in:
Kris Xia 2026-05-13 17:49:26 +08:00 • committed by Sameer Kankute
parent b3de50d277
commit 1ba02c0163
No known key found for this signature in database
6 changed files with 499 additions and 17 deletions

View file

@ -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(

View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -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"