From 3f3f9bd451a124dfbc855f4c4e61f38e1151c22a Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:29:34 -0700 Subject: [PATCH] fix(anthropic): ignore a non-string attachment format and keep count-route 400s as 400s --- .../prompt_templates/factory.py | 8 +++-- .../llms/anthropic/count_tokens/handler.py | 2 ++ .../anthropic/count_tokens/handler.py | 2 ++ ...llm_core_utils_prompt_templates_factory.py | 28 +++++++++++++++++ ...t_anthropic_count_tokens_transformation.py | 30 +++++++++++++++++++ 5 files changed, 67 insertions(+), 3 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index bd22ce6ed91..e10d08fafba 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -909,7 +909,7 @@ def convert_to_anthropic_image_obj(openai_image_url: str, format: str | None) -> # Extract the media type and base64 data media_type, base64_data = openai_image_url.split("data:")[1].split(";base64,") - if format: + if isinstance(format, str) and format: media_type = format else: media_type = media_type.replace("\\/", "/") @@ -954,7 +954,7 @@ def create_anthropic_image_param( return anthropic_content_element_factory( convert_to_anthropic_image_obj(openai_image_url=base64_url, format=format) ) - elif format is not None and format.partition(";")[0].strip().lower() == "application/pdf": + elif isinstance(format, str) and format.partition(";")[0].strip().lower() == "application/pdf": return AnthropicMessagesDocumentParam( type="document", source=AnthropicContentParamSourceUrl(type="url", url=image_url), @@ -2365,8 +2365,10 @@ def anthropic_content_element_factory( def select_anthropic_content_block_type_for_file( - format: str, + format: object, ) -> Literal["document", "image", "container_upload"]: + if not isinstance(format, str): + return "container_upload" media_type: Final = format.strip().lower() if media_type == "application/pdf" or media_type == "text/plain": return "document" diff --git a/litellm/llms/anthropic/count_tokens/handler.py b/litellm/llms/anthropic/count_tokens/handler.py index f8bf7232d9c..4de88a287de 100644 --- a/litellm/llms/anthropic/count_tokens/handler.py +++ b/litellm/llms/anthropic/count_tokens/handler.py @@ -116,6 +116,8 @@ class AnthropicCountTokensHandler(AnthropicCountTokensConfig): except AnthropicError: # Re-raise Anthropic exceptions as-is raise + except litellm.BadRequestError as e: + raise AnthropicError(status_code=400, message=e.message) from e except httpx.HTTPStatusError as e: # HTTP errors - preserve the actual status code verbose_logger.error("HTTP error in CountTokens handler: %s", e) diff --git a/litellm/llms/azure_ai/anthropic/count_tokens/handler.py b/litellm/llms/azure_ai/anthropic/count_tokens/handler.py index 4b3f263201a..b443ccc7713 100644 --- a/litellm/llms/azure_ai/anthropic/count_tokens/handler.py +++ b/litellm/llms/azure_ai/anthropic/count_tokens/handler.py @@ -128,6 +128,8 @@ class AzureAIAnthropicCountTokensHandler(AzureAIAnthropicCountTokensConfig): except AnthropicError: # Re-raise Anthropic exceptions as-is raise + except litellm.BadRequestError as e: + raise AnthropicError(status_code=400, message=e.message) from e except httpx.HTTPStatusError as e: # HTTP errors - preserve the actual status code verbose_logger.error("HTTP error in CountTokens handler: %s", e) diff --git a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index 71946245034..78a123f49a1 100644 --- a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -9488,3 +9488,31 @@ def test_create_anthropic_image_param_links_an_https_pdf_as_a_document_only_when "type": "image", "source": {"type": "url", "url": url}, } + + +@pytest.mark.parametrize("bad_format", (7, ["image/png"]), ids=("int", "list")) +@pytest.mark.parametrize( + "block, expected", + ( + ( + {"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}}, + {"type": "image", "source": {"type": "url", "url": "https://example.com/cat.png"}}, + ), + ( + {"type": "image_url", "image_url": {"url": _PNG_DATA_URI}}, + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": _PNG_DATA_URI.split(",", 1)[1]}}, + ), + ( + {"type": "file", "file": {"file_id": "file_abc"}}, + {"type": "container_upload", "file_id": "file_abc"}, + ), + ), + ids=("https_link", "data_uri", "file_id"), +) +def test_anthropic_messages_pt_ignores_a_format_that_is_not_a_string(bad_format, block, expected): + payload_key = "image_url" if block["type"] == "image_url" else "file" + messages = [{"role": "user", "content": [{**block, payload_key: {**block[payload_key], "format": bad_format}}]}] + + result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") + + assert result[0]["content"] == [expected] diff --git a/tests/unit/llms/anthropic/test_anthropic_count_tokens_transformation.py b/tests/unit/llms/anthropic/test_anthropic_count_tokens_transformation.py index 0884969b0c6..80f760b5243 100644 --- a/tests/unit/llms/anthropic/test_anthropic_count_tokens_transformation.py +++ b/tests/unit/llms/anthropic/test_anthropic_count_tokens_transformation.py @@ -14,6 +14,7 @@ from pydantic import JsonValue, TypeAdapter import litellm from litellm.llms.anthropic.chat.transformation import AnthropicConfig +from litellm.llms.anthropic.common_utils import AnthropicError from litellm.llms.anthropic.count_tokens.handler import AnthropicCountTokensHandler from litellm.llms.anthropic.count_tokens.token_counter import AnthropicTokenCounter from litellm.llms.anthropic.count_tokens.transformation import ( @@ -525,6 +526,35 @@ def test_count_drops_a_leading_system_message_without_text() -> None: assert result == {"model": "claude-opus-5-5", "messages": [{"role": "user", "content": "hi"}]} +@pytest.mark.asyncio +@pytest.mark.usefixtures("httpx_transport_clients") +@pytest.mark.parametrize("handler_type", (AnthropicCountTokensHandler, AzureAIAnthropicCountTokensHandler)) +async def test_handler_answers_400_for_a_text_attachment_it_cannot_decode( + handler_type: type[AnthropicCountTokensHandler] | type[AzureAIAnthropicCountTokensHandler], +) -> None: + text_attachment: Final = "data:text/plain;charset=bogus-cs;base64," + b64encode(b"hello").decode() + messages: Final[list[dict[str, JsonValue]]] = [ + {"role": "user", "content": [{"type": "image_url", "image_url": {"url": text_attachment}}]} + ] + api_base: Final = "https://gateway.example" + handler: Final = handler_type() + with respx.mock: + route: Final = respx.post(f"{api_base}/v1/messages/count_tokens").mock( + return_value=httpx.Response(200, json={"input_tokens": 7}) + ) + with pytest.raises(AnthropicError) as exc_info: + await ( + handler.handle_count_tokens_request( + model="claude-opus-5-5", messages=messages, api_base=api_base, auth_header={"x-api-key": "test-key"} + ) if isinstance(handler, AnthropicCountTokensHandler) else handler.handle_count_tokens_request( + model="claude-opus-5-5", messages=messages, api_base=api_base, api_key="test-key" + ) + ) + + assert exc_info.value.status_code == 400 + assert "bogus-cs" in exc_info.value.message + assert not route.called + @pytest.mark.asyncio async def test_handler_sends_the_leading_system_run_as_system_not_as_a_message(httpx_transport_clients): """The wire body is what the provider judges: ``system`` carries the prompt and no message has