fix(anthropic): ignore a non-string attachment format and keep count-route 400s as 400s

This commit is contained in:
mateo-berri 2026-10-30 00:29:34 -07:00
parent dfcfa0b043
commit 3f3f9bd451
5 changed files with 67 additions and 3 deletions

View file

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

View file

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

View file

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

View file

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

View file

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