fix(anthropic): tolerate non-OpenAI file content blocks in file-id discovery (#26228)

`get_file_ids_from_messages` and `update_messages_with_model_file_ids`
assume every content block with `type: "file"` has a nested `file` dict in
the OpenAI Chat Completions shape. That assumption is too strong: `type:
"file"` is a public content-block discriminator and several real producers
emit blocks that use it without the OpenAI `file` sub-dict. For example,
LangChain v1's `_normalize_messages` rewrites OpenAI file blocks into
`{"type":"file","id":"...","base64":"...","mime_type":"...","extras":{}}`
before they reach LiteLLM.

`AnthropicConfig.validate_environment` calls both helpers unconditionally
on every Anthropic (and Anthropic-via-Vertex) request, so any such block
raises `KeyError: 'file'` which the Vertex partner layer then wraps as a
`500 InternalServerError` before the LLM is even contacted.

This patch switches both helpers from `c["file"]` to a defensive
`c.get("file")` + dict check. When the block does not match the OpenAI
shape there is no file_id to extract or remap, so we skip it and leave
the block untouched for the downstream provider transformer to handle.

Adds 5 regression tests covering the LangChain v1 shape, the OpenAI
happy path, mixed shapes in one message, `file` set to a non-dict value,
and the remap path for non-OpenAI blocks.

Related to #24503, which proposed raising `BadRequestError` in the same
spots. For these two discovery functions specifically, the skip semantics
is strictly more permissive: well-formed OpenAI blocks still yield their
file_id, and legitimate non-OpenAI blocks stop crashing the request.
This commit is contained in:
Anmol Jaiswal 2026-04-23 07:52:38 +05:30 committed by GitHub
parent b42b86df7a
commit 0e23aa7390
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 127 additions and 2 deletions

View file

@ -452,7 +452,14 @@ def update_messages_with_model_file_ids(
for c in content:
if c["type"] == "file":
file_object = cast(ChatCompletionFileObject, c)
file_object_file_field = file_object["file"]
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
file_id = file_object_file_field.get("file_id")
format = file_object_file_field.get(
"format", get_format_from_file_id(file_id)
@ -1060,7 +1067,12 @@ def get_file_ids_from_messages(messages: List[AllMessageValues]) -> List[str]:
for c in content:
if c["type"] == "file":
file_object = cast(ChatCompletionFileObject, c)
file_object_file_field = file_object["file"]
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
file_id = file_object_file_field.get("file_id")
if file_id:
file_ids.append(file_id)

View file

@ -11,6 +11,7 @@ sys.path.insert(
from litellm.litellm_core_utils.prompt_templates.common_utils import (
add_system_prompt_to_messages,
get_file_ids_from_messages,
get_format_from_file_id,
handle_any_messages_to_chat_completion_str_messages_conversion,
split_concatenated_json_objects,
@ -254,3 +255,115 @@ def test_split_concatenated_json_invalid_raises():
"""Completely invalid JSON raises JSONDecodeError."""
with pytest.raises(json.JSONDecodeError):
split_concatenated_json_objects("not json at all")
# ---------------------------------------------------------------------------
# Regression tests for non-OpenAI file content blocks.
#
# `type: "file"` is a public content-block discriminator. Several producers
# (LangChain v1, provider-native shapes, custom user code) emit blocks with
# `type: "file"` but without the OpenAI Chat Completions `file` sub-dict.
# The discovery helpers below are used unconditionally inside
# `AnthropicConfig.validate_environment`, so any crash there surfaces as a
# `500 InternalServerError` before the request is even dispatched.
# ---------------------------------------------------------------------------
def test_get_file_ids_from_messages_skips_langchain_v1_file_block():
"""A LangChain v1 standardized file block must not crash file-id discovery."""
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "summarise this PDF"},
# LangChain v1 shape produced by `_normalize_messages`.
# No `file` sub-dict: the discriminator is `type: "file"` but
# the payload lives on `base64`/`mime_type` siblings.
{
"type": "file",
"id": "lc_1",
"base64": "JVBERi0xLjQK",
"mime_type": "application/pdf",
"extras": {"file_format": "application/pdf"},
},
],
}
]
assert get_file_ids_from_messages(messages) == []
def test_get_file_ids_from_messages_still_extracts_from_openai_shape():
"""Well-formed OpenAI file blocks still yield their file_id."""
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "what is this?"},
{"type": "file", "file": {"file_id": "file-abc"}},
],
}
]
assert get_file_ids_from_messages(messages) == ["file-abc"]
def test_get_file_ids_from_messages_mixed_shapes():
"""Mixed OpenAI and non-OpenAI file blocks: extract from the former,
ignore the latter."""
messages = [
{
"role": "user",
"content": [
{"type": "file", "file": {"file_id": "file-keep"}},
{
"type": "file",
"id": "lc_2",
"base64": "AAA",
"mime_type": "application/pdf",
},
],
}
]
assert get_file_ids_from_messages(messages) == ["file-keep"]
def test_get_file_ids_from_messages_file_field_not_dict():
"""`file` set to a non-dict value (e.g. stringified payload) must not crash."""
messages = [
{
"role": "user",
"content": [
{"type": "file", "file": "unexpectedly-a-string"},
],
}
]
assert get_file_ids_from_messages(messages) == []
def test_update_messages_with_model_file_ids_skips_non_openai_file_blocks():
"""`update_messages_with_model_file_ids` is also called on user content
before provider dispatch. It must tolerate non-OpenAI file blocks the same
way."""
langchain_v1_block = {
"type": "file",
"id": "lc_3",
"base64": "AAA",
"mime_type": "application/pdf",
}
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "hello"},
langchain_v1_block,
],
}
]
updated = update_messages_with_model_file_ids(messages, "model-1", {})
# Messages pass through unchanged when there is no `file` sub-dict to remap.
assert updated == messages