fix(anthropic): return 400 instead of 500 when a content list holds a bare string (#42420)

* fix(anthropic): skip non-dict content items in beta-header and file-id helpers so malformed content lists return 400 instead of 500

Fixes #42094
Supersedes #42101

Co-authored-by: Pawan-Shahane <shahanepawan511@gmail.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(e2e): spawn the DB-less regression proxy with -P so the cwd cannot shadow the pinned checkout

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(e2e): launch the DB-less proxy via -I -c with an explicit sys.path so python 3.10 works, drop DIRECT_URL, remove restating docstrings

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(e2e): gate the self-booted DB-less proxy behind the owned_gateway opt-in the Buildkite container cannot satisfy

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(anthropic): move the bare string content item repro to tests/integration

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style(tests): wrap the anthropic bare string wire test to the 120 column limit

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style(tests): wrap anthropic common_utils test literals to the 120 column limit

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style: fix ruff findings in touched test files

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: kerry <kerry@berri.ai>
Co-authored-by: Pawan-Shahane <shahanepawan511@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-22 15:44:30 -07:00 committed by GitHub
parent 3db94b932e
commit 58a05a9eae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 116 additions and 5 deletions

View file

@ -1657,7 +1657,7 @@ def get_file_ids_from_messages(messages: list[AllMessageValues]) -> list[str]:
if isinstance(content, str):
continue
for c in content:
if c["type"] == "file":
if isinstance(c, dict) and c["type"] == "file":
file_object = cast(ChatCompletionFileObject, c)
file_object_file_field = file_object.get("file")
if not isinstance(file_object_file_field, dict):

View file

@ -314,7 +314,7 @@ class AnthropicModelInfo(BaseLLMModelInfo):
_message_content = message.get("content")
if _message_content is not None and isinstance(_message_content, list):
for content in _message_content:
if "cache_control" in content:
if isinstance(content, dict) and "cache_control" in content:
return True
return False
@ -359,7 +359,7 @@ class AnthropicModelInfo(BaseLLMModelInfo):
for message in messages:
if "content" in message and message["content"] is not None and isinstance(message["content"], list):
for content in message["content"]:
if "type" in content and content["type"] != "text":
if isinstance(content, dict) and "type" in content and content["type"] != "text":
return True
return False

View file

@ -159,6 +159,12 @@
"tests/integration/routing/test_redis_recovery.py::test_owned_redis_outage_recovers_requests_and_real_response_cache": [
"other.routing.redis.owned_outage_recovers_serving_and_response_cache"
],
"tests/integration/providers/test_anthropic_wire.py::test_anthropic_bare_string_content_item_is_rejected_as_client_error_before_the_wire[type_word]": [
"other.provider_wire.anthropic.bare_string_content_item_is_client_error"
],
"tests/integration/providers/test_anthropic_wire.py::test_anthropic_bare_string_content_item_is_rejected_as_client_error_before_the_wire[plain]": [
"other.provider_wire.anthropic.bare_string_content_item_is_client_error"
],
"tests/integration/providers/test_anthropic_wire.py::test_anthropic_tool_history_and_cache_tokens_keep_wire_and_accounting_contracts": [
"other.provider_wire.anthropic.tool_history_system_cache_and_internal_fields",
"quota_management.spend_tracking.cache_tokens.disjoint_classes_use_explicit_rates"

View file

@ -3,7 +3,6 @@ import uuid
from typing import Final
import pytest
from integration._support.client import Gateway, eventually, object_value
from integration._support.database import read_rows
from integration._support.wire import Reply, Request, wire_server
@ -59,3 +58,26 @@ def test_anthropic_tool_history_and_cache_tokens_keep_wire_and_accounting_contra
parsed: Final = json.loads(metadata) if isinstance(metadata, str) else object_value(metadata)
assert parsed["cost_breakdown"]["input_cost"] == pytest.approx(0.0245)
assert parsed["cost_breakdown"]["output_cost"] == pytest.approx(0.008)
@pytest.mark.covers("other.provider_wire.anthropic.bare_string_content_item_is_client_error")
@pytest.mark.parametrize(
"text", [pytest.param("what type of file is this?", id="type_word"), pytest.param("hello", id="plain")]
)
def test_anthropic_bare_string_content_item_is_rejected_as_client_error_before_the_wire(
gateway: Gateway, text: str
) -> None:
def respond(request: Request) -> Reply:
raise AssertionError(f"upstream must not be reached: {request.target}")
with wire_server(respond) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(
model="anthropic/claude-sonnet-4-5-20250929", api_base=wire.url, api_key="synthetic-anthropic-key"
)
response: Final = gateway.request(
"POST",
"/v1/chat/completions",
{"model": model, "max_tokens": 16, "timeout": 5, "messages": [{"role": "system", "content": [text]}]},
)
assert response.status_code == 400, response.text
assert wire.drain() == ()

View file

@ -4,7 +4,6 @@ import json
import os
import sys
from typing import Final
from unittest.mock import MagicMock, patch
import pytest
@ -356,6 +355,20 @@ def test_get_file_ids_from_messages_file_field_not_dict():
assert get_file_ids_from_messages(messages) == []
def test_get_file_ids_from_messages_skips_bare_string_content_items():
messages = [
{
"role": "user",
"content": [
"what type of file is this?",
{"type": "file", "file": {"file_id": "file-abc"}},
],
}
]
assert get_file_ids_from_messages(messages) == ["file-abc"]
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

View file

@ -14,6 +14,7 @@ import json
import os
import sys
from types import SimpleNamespace
from typing import Final
from unittest.mock import patch
import pytest
@ -2275,3 +2276,72 @@ def test_create_anthropic_model_list_response_lists_ids_as_told():
assert (gpt["id"], gpt["display_name"], gpt["max_input_tokens"]) == ("claude-router-gpt-4o[1m]", "GPT 4o", 1000000)
assert (haiku["id"], haiku["display_name"]) == ("claude-haiku-4-5", "claude-haiku-4-5")
assert (response["first_id"], response["last_id"]) == ("claude-router-gpt-4o[1m]", "claude-haiku-4-5")
class TestMalformedContentListItems:
@pytest.mark.parametrize(
"content",
[
pytest.param(["what type of file is this?"], id="string_containing_type"),
pytest.param(["how do I set cache_control?"], id="string_containing_cache_control"),
pytest.param([None], id="none_item"),
pytest.param([5], id="int_item"),
pytest.param([["nested"]], id="list_item"),
],
)
def test_beta_headers_resolve_for_non_dict_content_items(self, content: list[object]) -> None:
from litellm.llms.anthropic.common_utils import AnthropicModelInfo
config: Final = AnthropicModelInfo()
messages: Final = [{"role": "user", "content": content}]
headers: Final = config.validate_environment(
headers={},
model="claude-sonnet-4-5",
messages=messages,
optional_params={},
litellm_params={},
api_key=FAKE_REGULAR_KEY,
)
assert headers["x-api-key"] == FAKE_REGULAR_KEY
assert config.is_cache_control_set(messages) is False
assert config.is_pdf_used(messages) is False
def test_real_content_parts_still_set_their_beta_headers(self) -> None:
from litellm.llms.anthropic.common_utils import AnthropicModelInfo
config: Final = AnthropicModelInfo()
assert config.is_pdf_used([{"role": "user", "content": [{"type": "image", "source": {}}]}]) is True
assert config.is_pdf_used([{"role": "user", "content": [{"type": "text", "text": "hi"}]}]) is False
assert (
config.is_cache_control_set(
[
{
"role": "user",
"content": [{"type": "text", "text": "hi", "cache_control": {"type": "ephemeral"}}],
}
]
)
is True
)
def test_mixed_list_keeps_detecting_the_valid_part(self) -> None:
from litellm.llms.anthropic.common_utils import AnthropicModelInfo
config: Final = AnthropicModelInfo()
messages: Final = [{"role": "user", "content": ["what type of file is this?", {"type": "image", "source": {}}]}]
assert config.is_pdf_used(messages) is True
def test_litellm_completion_rejects_bare_string_content_item_as_bad_request(self) -> None:
import litellm
with pytest.raises(litellm.BadRequestError):
litellm.completion(
model="anthropic/claude-haiku-4-5-20251001",
messages=[{"role": "user", "content": ["what type of file is this?"]}],
api_key=FAKE_REGULAR_KEY,
max_tokens=5,
)