mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
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:
parent
3db94b932e
commit
58a05a9eae
6 changed files with 116 additions and 5 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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() == ()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue