From a0d0f939e0deeb8cfbd53154326b7257375a6ad7 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:27:00 -0700 Subject: [PATCH 01/26] fix(anthropic): send pdf data URIs in image_url blocks as document blocks A data:application/pdf or data:text/plain URI arriving as a plain image_url block in user content was emitted as an Anthropic image block, which anthropic, bedrock invoke, and vertex all reject with a media_type error. The shared transform now routes those to document blocks the way tool results already did, and requires_inline_base64_media names the dialects that only accept inline base64 media, Snowflake included. Snowflake Cortex Claude requests now go through translate_system_message and anthropic_messages_pt instead of a private copy of the translation. The shared path accepts already-parsed tool-call arguments and dict tool results, and an unparseable tool-call JSON is a 400 SnowflakeException rather than an empty tool input. Snowflake shape assertions follow the shared transform: block-list user content and merged consecutive user turns. --- basedpyright-code-budget.json | 16 +- .../prompt_templates/common_utils.py | 6 +- .../prompt_templates/factory.py | 61 +++-- litellm/llms/snowflake/chat/transformation.py | 233 ++---------------- ...ore_utils_prompt_templates_common_utils.py | 19 ++ ...llm_core_utils_prompt_templates_factory.py | 87 +++++++ .../test_snowflake_chat_transformation.py | 9 +- .../test_snowflake_native_endpoints.py | 43 ++-- type-discipline-budget.json | 6 +- 9 files changed, 208 insertions(+), 272 deletions(-) diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index 0b0a61192e6..4c5042f8f31 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -1,9 +1,9 @@ { "reportAny": { - "limit": 13429 + "limit": 13425 }, "reportArgumentType": { - "limit": 2198 + "limit": 2197 }, "reportAssignmentType": { "limit": 319 @@ -24,7 +24,7 @@ "limit": 19 }, "reportExplicitAny": { - "limit": 3369 + "limit": 3368 }, "reportFunctionMemberAccess": { "limit": 7 @@ -57,7 +57,7 @@ "limit": 5570 }, "reportMissingTypeArgument": { - "limit": 15281 + "limit": 15277 }, "reportMissingTypeStubs": { "limit": 40 @@ -99,19 +99,19 @@ "limit": 0 }, "reportUnknownArgumentType": { - "limit": 44358 + "limit": 44351 }, "reportUnknownLambdaType": { "limit": 109 }, "reportUnknownMemberType": { - "limit": 38271 + "limit": 38245 }, "reportUnknownParameterType": { "limit": 19584 }, "reportUnknownVariableType": { - "limit": 29814 + "limit": 29793 }, "reportUnnecessaryCast": { "limit": 110 @@ -123,7 +123,7 @@ "limit": 4 }, "reportUnnecessaryIsInstance": { - "limit": 816 + "limit": 813 }, "reportUntypedBaseClass": { "limit": 0 diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 00b80839dde..10609181b38 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -2106,7 +2106,7 @@ def _attempt_json_repair(s: str) -> object | None: def parse_tool_call_arguments( - arguments: str | None, + arguments: str | Mapping[str, object] | None, tool_name: str | None = None, context: str | None = None, ) -> Any: @@ -2119,7 +2119,7 @@ def parse_tool_call_arguments( callers are aware the arguments were not perfectly formed. Args: - arguments: The JSON string containing tool arguments, or None. + arguments: The JSON string containing tool arguments, an already-parsed mapping, or None. tool_name: Optional name of the tool (for error messages). context: Optional context string (e.g., "Anthropic Messages API"). @@ -2133,6 +2133,8 @@ def parse_tool_call_arguments( """ import json + if isinstance(arguments, Mapping): + return dict(arguments) if not arguments or not arguments.strip(): return {} diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 56c1d605700..902d9ba5f8e 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1528,6 +1528,32 @@ def _is_anthropic_document_data_uri(url: str) -> bool: return match.group(1) in _ANTHROPIC_DOCUMENT_BASE64_MEDIA_TYPES +def _anthropic_document_block_from_data_uri( + url: str, + original_content_element: dict | AllMessageValues, +) -> AnthropicMessagesDocumentParam: + synth_file_message: Final[ChatCompletionFileObject] = {"type": "file", "file": {"file_data": url}} + document_block: Final = anthropic_process_openai_file_message(synth_file_message) + return cast( + AnthropicMessagesDocumentParam, + add_cache_control_to_content( + anthropic_content_element=cast(AnthropicMessagesDocumentParam, document_block), + original_content_element=original_content_element, + ), + ) + + +_INLINE_BASE64_MEDIA_PROVIDERS: Final = frozenset({"snowflake"}) + + +def requires_inline_base64_media(model: str, llm_provider: str | None) -> bool: + if model.lower().startswith("invoke/"): + return True + if not llm_provider: + return False + return llm_provider.startswith("vertex_ai") or llm_provider in _INLINE_BASE64_MEDIA_PROVIDERS + + def convert_to_anthropic_tool_result( message: ChatCompletionToolMessage | ChatCompletionFunctionMessage, force_base64: bool = False, @@ -1574,6 +1600,8 @@ def convert_to_anthropic_tool_result( ) = "" if isinstance(message["content"], str): anthropic_content = message["content"] + elif isinstance(message["content"], Mapping): + anthropic_content = json.dumps(message["content"]) elif isinstance(message["content"], list): content_list: Final = message["content"] anthropic_content_list: list[ @@ -1598,20 +1626,8 @@ def convert_to_anthropic_tool_result( image_url_value = content["image_url"] format = image_url_value.get("format") if isinstance(image_url_value, dict) else None url_str = image_url_value.get("url") if isinstance(image_url_value, dict) else image_url_value - # Data URIs with non-image mime types (e.g. application/pdf) must - # translate to Anthropic document blocks, not image blocks — - # wrapping a PDF in `type: "image"` is rejected by the API. if isinstance(url_str, str) and _is_anthropic_document_data_uri(url_str): - synth_file_message: ChatCompletionFileObject = { - "type": "file", - "file": {"file_data": url_str}, - } - _document_block = anthropic_process_openai_file_message(synth_file_message) - _document_block = add_cache_control_to_content( - anthropic_content_element=cast(AnthropicMessagesDocumentParam, _document_block), - original_content_element=content, - ) - anthropic_content_list.append(cast(AnthropicMessagesDocumentParam, _document_block)) + anthropic_content_list.append(_anthropic_document_block_from_data_uri(url_str, content)) else: _anthropic_image_param = create_anthropic_image_param( image_url_value, @@ -2362,11 +2378,7 @@ def anthropic_messages_pt( else: messages.append(DEFAULT_USER_CONTINUE_MESSAGE_TYPED) - # Bedrock invoke models have format: invoke/... - # Vertex AI Anthropic also doesn't support URL sources for images - is_bedrock_invoke = model.lower().startswith("invoke/") - is_vertex_ai = llm_provider.startswith("vertex_ai") if llm_provider else False - force_base64 = is_bedrock_invoke or is_vertex_ai + force_base64: Final = requires_inline_base64_media(model, llm_provider) msg_i = 0 while msg_i < len(messages): @@ -2384,22 +2396,19 @@ def anthropic_messages_pt( for m in user_message_types_block["content"]: if m.get("type", "") == "image_url": m = cast(ChatCompletionImageObject, m) - format = m["image_url"].get("format") if isinstance(m["image_url"], dict) else None - # Convert ChatCompletionImageUrlObject to dict if needed image_url_value = m["image_url"] + url_str = image_url_value if isinstance(image_url_value, str) else image_url_value["url"] + if _is_anthropic_document_data_uri(url_str): + user_content.append(_anthropic_document_block_from_data_uri(url_str, dict(m))) + continue + format = image_url_value.get("format") if isinstance(image_url_value, dict) else None if isinstance(image_url_value, str): image_url_input: str | dict[str, object] = image_url_value else: - # ChatCompletionImageUrlObject or dict case - convert to dict image_url_input = { "url": image_url_value["url"], "format": image_url_value.get("format"), } - # Bedrock invoke models have format: invoke/... - # Vertex AI Anthropic also doesn't support URL sources for images - is_bedrock_invoke = model.lower().startswith("invoke/") - is_vertex_ai = llm_provider.startswith("vertex_ai") if llm_provider else False - force_base64 = is_bedrock_invoke or is_vertex_ai _anthropic_content_element = create_anthropic_image_param( image_url_input, format=format, diff --git a/litellm/llms/snowflake/chat/transformation.py b/litellm/llms/snowflake/chat/transformation.py index c64fc583edc..0816b975f2f 100644 --- a/litellm/llms/snowflake/chat/transformation.py +++ b/litellm/llms/snowflake/chat/transformation.py @@ -8,25 +8,22 @@ Routes to native Cortex REST API endpoints based on model: Ref: https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-rest-api """ -import copy -import json -import re from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any, Final, Protocol, TypedDict import httpx from typing_extensions import ReadOnly -from litellm.litellm_core_utils.prompt_templates.factory import ( - anthropic_process_openai_file_message, - convert_to_anthropic_tool_result, - create_anthropic_image_param, - select_anthropic_content_block_type_for_file, -) +from litellm.litellm_core_utils.prompt_templates.factory import anthropic_messages_pt from litellm.llms.anthropic.chat.handler import ModelResponseIterator as AnthropicStreamParser from litellm.llms.anthropic.chat.transformation import AnthropicConfig from litellm.llms.anthropic.common_utils import normalize_cache_control_in_anthropic_payload -from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolCallChunk, ChatCompletionToolMessage +from litellm.types.llms.anthropic import ( + AnthopicMessagesAssistantMessageParam, + AnthropicMessagesUserMessageParam, + AnthropicSystemMessageContent, +) +from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolCallChunk from litellm.types.utils import ( Choices, GenericStreamingChunk, @@ -37,7 +34,7 @@ from litellm.types.utils import ( from ...base_llm.base_model_iterator import BaseModelResponseIterator from ...openai_like.chat.transformation import OpenAIGPTConfig -from ..utils import SnowflakeBaseConfig +from ..utils import SnowflakeBaseConfig, SnowflakeException if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj @@ -101,95 +98,6 @@ def _is_claude_model(model: str) -> bool: return any(name.startswith(p) for p in _CLAUDE_MODEL_PREFIXES) -def _convert_image_url_to_anthropic(block: Mapping[str, object]) -> object: - """One OpenAI ``image_url`` block in the native shape Cortex accepts. - - Cortex documents base64 sources only, so remote URLs are inlined the way every - other base64-only Anthropic dialect (Bedrock invoke, Vertex) inlines them, and - pdf/text data URIs become document blocks rather than malformed image blocks. - """ - image_url: Final = block.get("image_url") - url: Final = image_url if isinstance(image_url, str) else _image_url_field(image_url, "url") - if not url: - return block - - converted: Final = ( - anthropic_process_openai_file_message({"type": "file", "file": {"file_data": url}}) - if select_anthropic_content_block_type_for_file(_data_uri_media_type(url)) == "document" - else create_anthropic_image_param( - image_url if isinstance(image_url, dict) else url, # mutable-ok: caller's JSON block - format=_image_url_field(image_url, "format"), - is_bedrock_invoke=True, - ) - ) - cache_control: Final = block.get("cache_control") - if cache_control is None: - return converted - return {**converted, "cache_control": cache_control} # mutable-ok: JSON wire block - - -def _image_url_field(image_url: object, key: str) -> str | None: - value: Final = image_url.get(key) if isinstance(image_url, dict) else None - return value if isinstance(value, str) else None - - -def _data_uri_media_type(url: str) -> str: - match: Final = re.match(r"data:([^;,]+)", url) - return match.group(1) if match else "" - - -def _convert_image_url_blocks_to_anthropic(content: object) -> object: - if not isinstance(content, list): - return content - return [ # mutable-ok: JSON wire blocks - _convert_image_url_to_anthropic(block) - if isinstance(block, Mapping) and block.get("type") == "image_url" - else block - for block in content - ] - - -def _convert_tool_result_to_anthropic( - content: object, tool_call_id: str, cache_control: object -) -> Mapping[str, object]: - """The Anthropic ``tool_result`` block for one OpenAI tool message. - - Delegating to the shared converter keeps image, document and per-block cache - breakpoints identical to every other Anthropic dialect; only the plain-string - and non-list shapes it does not model are handled here. - """ - if not isinstance(content, list): - plain: Final[dict[str, object]] = { # mutable-ok: JSON wire block - "type": "tool_result", - "tool_use_id": tool_call_id, - "content": content if isinstance(content, str) else json.dumps(content), - } - return {**plain, "cache_control": cache_control} if cache_control is not None else plain - converted: Final = convert_to_anthropic_tool_result( - ChatCompletionToolMessage(role="tool", tool_call_id=tool_call_id, content=content), - force_base64=True, - ) - if cache_control is None: - return converted - return {**converted, "cache_control": cache_control} # mutable-ok: JSON wire block - - -def _signed_thinking_blocks(msg: object) -> list[dict[str, object]]: # mutable-ok: JSON wire blocks - """The assistant turn's thinking blocks that can legally be echoed back. - - Only signed blocks round-trip: Cortex rejects a thinking block whose signature is - missing, which is what an unsigned block from a non-thinking turn would produce. - """ - blocks: Final = msg.get("thinking_blocks") if isinstance(msg, dict) else getattr(msg, "thinking_blocks", None) - if not isinstance(blocks, list): - return [] # mutable-ok: JSON wire blocks - return [ # mutable-ok: JSON wire blocks - dict(block) - for block in blocks - if isinstance(block, Mapping) and (block.get("signature") or block.get("type") == "redacted_thinking") - ] - - def _clean_input_schema(schema: object) -> object: # mutable-ok: JSON schema copy return ( {key: value for key, value in schema.items() if key != "$schema"} @@ -298,113 +206,22 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig): ) return anthropic_tools - def _extract_system_and_messages( # mutable-ok: JSON wire messages - self, messages: list[AllMessageValues] - ) -> tuple[list[dict] | None, list[dict]]: - """ - Split messages into system prompt and conversation turns for Anthropic format. - - - system messages → collected and joined (preserves guardrail prompts) - - assistant messages with tool_calls → tool_use content blocks - - tool role messages → user role with tool_result content blocks - """ - system_parts: Final[list[dict]] = [] # mutable-ok: JSON wire messages - conversation: Final[list[dict]] = [] # mutable-ok: JSON wire messages - - for msg in messages: - if isinstance(msg, dict): - role = msg.get("role", "") - content: Any = msg.get("content", "") - msg_cache_control: object = msg.get("cache_control") - else: - role = getattr(msg, "role", "") - content = getattr(msg, "content", "") - msg_cache_control = getattr(msg, "cache_control", None) - - if role == "system": - if isinstance(content, str) and content: - system_parts.append({"type": "text", "text": content}) # mutable-ok: JSON wire system block - elif isinstance(content, list): - system_parts.extend( - { # mutable-ok: JSON wire system block - "type": "text", - "text": block.get("text", ""), - **( - {"cache_control": block["cache_control"]} if "cache_control" in block else {} - ), # mutable-ok: JSON wire block - } - for block in content - if isinstance(block, Mapping) and block.get("type") == "text" - ) - elif role == "assistant": - tool_calls = msg.get("tool_calls") if isinstance(msg, dict) else getattr(msg, "tool_calls", None) - thinking_blocks = _signed_thinking_blocks(msg) - if tool_calls: - content_blocks: list[dict[str, object]] = list(thinking_blocks) # mutable-ok: JSON wire blocks - if content: - content_blocks.append({"type": "text", "text": content}) - for tc in tool_calls: - func = tc.get("function", {}) if isinstance(tc, dict) else getattr(tc, "function", {}) - tc_id = tc.get("id", "") if isinstance(tc, dict) else getattr(tc, "id", "") - func_name = func.get("name", "") if isinstance(func, dict) else getattr(func, "name", "") - func_args = ( - func.get("arguments", "{}") if isinstance(func, dict) else getattr(func, "arguments", "{}") - ) - try: - input_data = json.loads(func_args) if isinstance(func_args, str) else func_args - except (json.JSONDecodeError, TypeError): - input_data = {} - content_blocks.append( - { - "type": "tool_use", - "id": tc_id, - "name": func_name, - "input": input_data, - } - ) - conversation.append({"role": "assistant", "content": content_blocks}) - elif thinking_blocks: - thinking_content = ( - [ - *thinking_blocks, - *copy.deepcopy(content), - ] - if isinstance(content, list) - else [*thinking_blocks, *([{"type": "text", "text": content}] if content else [])] - ) # rebind-ok: loop-local normalized content - conversation.append({"role": "assistant", "content": thinking_content}) - else: - conversation.append({"role": "assistant", "content": content}) - elif role == "tool": - tool_call_id_value = ( - msg.get("tool_call_id", "") if isinstance(msg, dict) else getattr(msg, "tool_call_id", "") - ) - tool_call_id = ( - tool_call_id_value if isinstance(tool_call_id_value, str) else "" - ) # rebind-ok: normalized loop value - tool_result_block = _convert_tool_result_to_anthropic(content, tool_call_id, msg_cache_control) - if ( - conversation - and conversation[-1]["role"] == "user" - and isinstance(conversation[-1]["content"], list) - and conversation[-1]["content"] - and conversation[-1]["content"][0].get("type") == "tool_result" - ): - conversation[-1]["content"].append(tool_result_block) - else: - conversation.append( - {"role": "user", "content": [tool_result_block]} # mutable-ok: JSON wire message - ) # mutable-ok: JSON wire message - else: - conversation.append( # mutable-ok: JSON wire message - { # mutable-ok: JSON wire message - "role": role, - "content": _convert_image_url_blocks_to_anthropic(content), - } # mutable-ok: JSON wire message - ) - - system: Final[list[dict] | None] = system_parts if system_parts else None # mutable-ok: JSON wire messages - return system, conversation + def _extract_system_and_messages( + self, model: str, messages: list[AllMessageValues] + ) -> tuple[ + list[AnthropicSystemMessageContent] | None, + list[AnthropicMessagesUserMessageParam | AnthopicMessagesAssistantMessageParam], + ]: + """Cortex's /messages endpoint speaks Anthropic's dialect, so the shared translators own the shape.""" + conversation_messages: Final = list(messages) + system: Final = AnthropicConfig().translate_system_message(messages=conversation_messages) + try: + conversation: Final = anthropic_messages_pt( + messages=conversation_messages, model=model, llm_provider="snowflake" + ) + except ValueError as e: + raise SnowflakeException(status_code=400, message=str(e)) from e + return (system or None), conversation def transform_request( self, @@ -479,7 +296,7 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig): ) -> dict: """Anthropic Messages format for /messages endpoint.""" passthrough_system: Final = optional_params.pop("system", None) - extracted_system, conversation = self._extract_system_and_messages(messages) + extracted_system, conversation = self._extract_system_and_messages(model, messages) system: Final = passthrough_system if passthrough_system is not None else extracted_system if "tools" in optional_params: diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py index c037f928593..b6876a211a8 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py @@ -1,6 +1,7 @@ import functools import json import os +from types import MappingProxyType from unittest.mock import MagicMock, patch import pytest @@ -14,6 +15,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import ( get_format_from_file_id, handle_any_messages_to_chat_completion_str_messages_conversion, hoist_images_from_tool_messages, + parse_tool_call_arguments, split_concatenated_json_objects, update_messages_with_model_file_ids, ) @@ -1554,3 +1556,20 @@ class TestRequestContainsImageContent: for _ in range(50): nested = {"type": "tool_result", "content": [nested]} assert request_contains_image_content([{"role": "user", "content": [nested]}]) is False + + +@pytest.mark.parametrize( + "arguments", + [{"already": "parsed"}, MappingProxyType({"already": "parsed"})], + ids=["dict", "mapping_proxy"], +) +def test_parse_tool_call_arguments_returns_already_parsed_mapping(arguments): + parsed = parse_tool_call_arguments(arguments, tool_name="dict_tool", context="test") + + assert parsed == {"already": "parsed"} + assert json.loads(json.dumps(parsed)) == {"already": "parsed"} + + +def test_parse_tool_call_arguments_rejects_unrepairable_json(): + with pytest.raises(ValueError, match="broken_tool"): + parse_tool_call_arguments("not valid json{{{", tool_name="broken_tool", context="test") diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index dd2d45f00c6..5686dee86a2 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -22,6 +22,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import ( convert_to_gemini_tool_call_result, make_valid_bedrock_tool_name, ollama_pt, + requires_inline_base64_media, sanitize_messages_for_tool_calling, ) from litellm.types.llms.openai import ChatCompletionToolMessage @@ -1739,6 +1740,19 @@ def test_convert_to_anthropic_tool_result_image_with_cache_control(): assert result["content"][1]["cache_control"]["type"] == "ephemeral" +def test_convert_to_anthropic_tool_result_serializes_mapping_content(): + from litellm.litellm_core_utils.prompt_templates.factory import ( + convert_to_anthropic_tool_result, + ) + + result = convert_to_anthropic_tool_result( + {"role": "tool", "tool_call_id": "call_dict", "content": {"result_key": "result_value"}} + ) + + assert result["type"] == "tool_result" + assert json.loads(result["content"]) == {"result_key": "result_value"} + + def test_convert_to_anthropic_tool_result_image_without_cache_control(): """ Test that images without cache_control in tool results work correctly. @@ -3500,6 +3514,79 @@ def test_bedrock_converse_pdf_only_user_message_gets_text_block(): assert _text_blocks(result[0]) == [BEDROCK_DOCUMENT_PLACEHOLDER_TEXT] +@pytest.mark.parametrize( + ("model", "llm_provider"), + [ + ("claude-sonnet-5", "anthropic"), + ("invoke/us.anthropic.claude-sonnet-5", "bedrock"), + ("claude-sonnet-5", "vertex_ai"), + ("claude-sonnet-5", "snowflake"), + ], +) +def test_anthropic_messages_pt_user_pdf_data_uri_becomes_document_block(model, llm_provider): + """ + Regression for LIT-6778: Claude Code sends a PDF as an image_url part with a + pdf data URI after the /v1/messages -> completion bridge. Every Anthropic-shaped + API rejects `{"type": "image", "media_type": "application/pdf"}`, so the user + content must carry a document block, with the block's cache breakpoint intact. + """ + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What word is in this document?"}, + { + "type": "image_url", + "image_url": {"url": _PDF_DATA_URI}, + "cache_control": {"type": "ephemeral"}, + }, + ], + } + ] + + result = anthropic_messages_pt(messages=messages, model=model, llm_provider=llm_provider) + + assert result[0]["content"][1] == { + "type": "document", + "source": { + "type": "base64", + "media_type": "application/pdf", + "data": _PDF_DATA_URI.split(",", 1)[1], + }, + "cache_control": {"type": "ephemeral"}, + } + assert all(block["type"] != "image" for block in result[0]["content"]) + + +def test_anthropic_messages_pt_user_png_data_uri_stays_an_image_block(): + messages = [{"role": "user", "content": [{"type": "image_url", "image_url": {"url": _PNG_DATA_URI}}]}] + + result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") + + assert result[0]["content"] == [ + { + "type": "image", + "source": {"type": "base64", "media_type": "image/png", "data": _PNG_DATA_URI.split(",", 1)[1]}, + } + ] + + +@pytest.mark.parametrize( + ("model", "llm_provider", "expected"), + [ + ("invoke/us.anthropic.claude-sonnet-5", "bedrock", True), + ("claude-sonnet-5", "vertex_ai", True), + ("claude-sonnet-5", "vertex_ai_beta", True), + ("claude-sonnet-5", "snowflake", True), + ("claude-sonnet-5", "anthropic", False), + ("us.anthropic.claude-sonnet-5", "bedrock", False), + ("claude-sonnet-5", None, False), + ], +) +def test_requires_inline_base64_media(model, llm_provider, expected): + assert requires_inline_base64_media(model, llm_provider) is expected + + def test_bedrock_converse_document_with_text_gets_no_extra_text_block(): messages = [ { diff --git a/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py b/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py index 25a961c3413..2b772101dd1 100644 --- a/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py +++ b/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py @@ -456,7 +456,7 @@ class TestSnowflakeCortexClaudeFixes: }, ] ) - tool_result = body["messages"][1]["content"][0] + tool_result = body["messages"][0]["content"][1] assert tool_result["cache_control"] == {"type": "ephemeral"}, tool_content def test_pdf_data_uri_becomes_a_document_block(self): @@ -493,7 +493,7 @@ class TestSnowflakeCortexClaudeFixes: }, ] ) - assert body["messages"][1]["content"][0]["content"] == [ + assert body["messages"][0]["content"][1]["content"] == [ {"type": "text", "text": "first"}, {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "ZmFrZQ=="}}, {"type": "text", "text": "last"}, @@ -503,7 +503,10 @@ class TestSnowflakeCortexClaudeFixes: body = self._transform( [{"role": "user", "content": "look"}, {"role": "tool", "tool_call_id": "call_1", "content": "done"}] ) - assert body["messages"][1]["content"][0]["content"] == "done" + assert body["messages"][0]["content"] == [ + {"type": "text", "text": "look"}, + {"type": "tool_result", "tool_use_id": "call_1", "content": "done"}, + ] def test_anthropic_tool_schema_strips_only_top_level_schema_key(self): tools = [ diff --git a/tests/test_litellm/llms/snowflake/test_snowflake_native_endpoints.py b/tests/test_litellm/llms/snowflake/test_snowflake_native_endpoints.py index 7970f7771fc..77dae3c9ccc 100644 --- a/tests/test_litellm/llms/snowflake/test_snowflake_native_endpoints.py +++ b/tests/test_litellm/llms/snowflake/test_snowflake_native_endpoints.py @@ -20,6 +20,7 @@ from litellm.llms.snowflake.chat.transformation import ( SnowflakeConfig, _is_claude_model, ) +from litellm.llms.snowflake.utils import SnowflakeException from litellm.types.utils import ModelResponse @@ -340,7 +341,7 @@ class TestAnthropicConfigRequest: ) assert body["system"] == [{"type": "text", "text": "You are helpful."}] assert all(m["role"] != "system" for m in body["messages"]) - assert body["messages"][0] == {"role": "user", "content": "Hello"} + assert body["messages"][0] == {"role": "user", "content": [{"type": "text", "text": "Hello"}]} def test_model_prefix_stripped(self): body = self.cfg.transform_request( @@ -633,7 +634,7 @@ class TestAnthropicMultiTurnToolMessages: headers={}, ) msgs = body["messages"] - assert msgs[0] == {"role": "user", "content": "What's the weather in Paris?"} + assert msgs[0] == {"role": "user", "content": [{"type": "text", "text": "What's the weather in Paris?"}]} assistant_msg = msgs[1] assert assistant_msg["role"] == "assistant" @@ -643,13 +644,14 @@ class TestAnthropicMultiTurnToolMessages: assert assistant_msg["content"][0]["name"] == "get_weather" assert assistant_msg["content"][0]["input"] == {"city": "Paris"} - tool_result_msg = msgs[2] - assert tool_result_msg["role"] == "user" - assert tool_result_msg["content"][0]["type"] == "tool_result" - assert tool_result_msg["content"][0]["tool_use_id"] == "call_123" - assert tool_result_msg["content"][0]["content"] == "Sunny, 22°C" - - assert msgs[3] == {"role": "user", "content": "Thanks!"} + assert msgs[2] == { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "call_123", "content": "Sunny, 22°C"}, + {"type": "text", "text": "Thanks!"}, + ], + } + assert len(msgs) == 3 def test_assistant_with_text_and_tool_calls(self): messages = [ @@ -701,7 +703,7 @@ class TestAnthropicMultiTurnToolMessages: for msg in body["messages"]: assert msg["role"] != "tool" - def test_malformed_json_in_tool_arguments_handled_gracefully(self): + def test_malformed_json_in_tool_arguments_is_rejected_as_bad_request(self): messages = [ {"role": "user", "content": "hi"}, { @@ -716,18 +718,15 @@ class TestAnthropicMultiTurnToolMessages: ], }, ] - body = self.cfg.transform_request( - model="snowflake/claude-sonnet-4-5", - messages=messages, - optional_params={}, - litellm_params={}, - headers={}, - ) - assistant_msg = body["messages"][1] - tool_use_block = assistant_msg["content"][0] - assert tool_use_block["type"] == "tool_use" - assert tool_use_block["name"] == "broken_tool" - assert tool_use_block["input"] == {} + with pytest.raises(SnowflakeException, match="broken_tool") as excinfo: + self.cfg.transform_request( + model="snowflake/claude-sonnet-4-5", + messages=messages, + optional_params={}, + litellm_params={}, + headers={}, + ) + assert excinfo.value.status_code == 400 def test_non_string_tool_arguments_pass_through(self): messages = [ diff --git a/type-discipline-budget.json b/type-discipline-budget.json index e7186dfe186..769c3b91406 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -1,9 +1,9 @@ { "LIT001": { - "limit": 22180 + "limit": 22179 }, "LIT002": { - "limit": 26729 + "limit": 26713 }, "LIT003": { "limit": 261 @@ -27,7 +27,7 @@ "limit": 0 }, "LIT010": { - "limit": 16426 + "limit": 16424 }, "LIT011": { "limit": 5506 From 399b39418378df371cb98ae66d91ff28a4f1f9c6 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:55:58 -0700 Subject: [PATCH 02/26] fix(anthropic): send text/plain documents as a text source and declare inline base64 from the Snowflake config Anthropic's base64 document source only accepts application/pdf, so a text/plain data URI (in a file block or in an image_url block) was built into a document the API rejects with a 400. The shared file-block builder now decodes text/plain into a `text` source, which every Anthropic-shaped provider accepts. The shared translator also no longer keeps a provider list for base64-only dialects. Snowflake passes force_base64 into anthropic_messages_pt from its own transformation, and requires_inline_base64_media keeps covering bedrock invoke and vertex the way the inline check did before. --- .../prompt_templates/factory.py | 27 +++++++++-------- litellm/llms/snowflake/chat/transformation.py | 2 +- ...llm_core_utils_prompt_templates_factory.py | 30 ++++++++++++++++++- .../test_snowflake_chat_transformation.py | 11 +++++++ 4 files changed, 55 insertions(+), 15 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 902d9ba5f8e..5936329a1bf 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1518,10 +1518,6 @@ _ANTHROPIC_DOCUMENT_BASE64_MEDIA_TYPES: Final = {"application/pdf", "text/plain" def _is_anthropic_document_data_uri(url: str) -> bool: - # Anthropic's base64 document source accepts only application/pdf and - # text/plain (see select_anthropic_content_block_type_for_file). Routing - # other mimes here would produce a document block the API rejects, so we - # leave them on the image code path. match: Final = re.match(r"data:([^;,]+)", url) if not match: return False @@ -1543,15 +1539,10 @@ def _anthropic_document_block_from_data_uri( ) -_INLINE_BASE64_MEDIA_PROVIDERS: Final = frozenset({"snowflake"}) - - def requires_inline_base64_media(model: str, llm_provider: str | None) -> bool: if model.lower().startswith("invoke/"): return True - if not llm_provider: - return False - return llm_provider.startswith("vertex_ai") or llm_provider in _INLINE_BASE64_MEDIA_PROVIDERS + return llm_provider is not None and llm_provider.startswith("vertex_ai") def convert_to_anthropic_tool_result( @@ -1913,6 +1904,15 @@ def anthropic_process_openai_file_message( openai_image_url=file_data, format=format, ) + if image_chunk["media_type"] == "text/plain": + return AnthropicMessagesDocumentParam( + type="document", + source=AnthropicContentParamSourceText( + type="text", + media_type="text/plain", + data=base64.b64decode(image_chunk["data"]).decode("utf-8"), + ), + ) anthropic_document_param: Final = AnthropicMessagesDocumentParam( type="document", source=AnthropicContentParamSource( @@ -2338,6 +2338,7 @@ def anthropic_messages_pt( messages: list[AllMessageValues], model: str, llm_provider: str, + force_base64: bool = False, ) -> list[AnthropicMessagesUserMessageParam | AnthopicMessagesAssistantMessageParam]: """ format messages for anthropic @@ -2378,7 +2379,7 @@ def anthropic_messages_pt( else: messages.append(DEFAULT_USER_CONTINUE_MESSAGE_TYPED) - force_base64: Final = requires_inline_base64_media(model, llm_provider) + inline_base64_media: Final = force_base64 or requires_inline_base64_media(model, llm_provider) msg_i = 0 while msg_i < len(messages): @@ -2412,7 +2413,7 @@ def anthropic_messages_pt( _anthropic_content_element = create_anthropic_image_param( image_url_input, format=format, - is_bedrock_invoke=force_base64, + is_bedrock_invoke=inline_base64_media, ) _content_element = add_cache_control_to_content( anthropic_content_element=_anthropic_content_element, @@ -2479,7 +2480,7 @@ def anthropic_messages_pt( elif user_message_types_block["role"] == "tool" or user_message_types_block["role"] == "function": # OpenAI's tool message content will always be a string user_content.append( - convert_to_anthropic_tool_result(user_message_types_block, force_base64=force_base64) + convert_to_anthropic_tool_result(user_message_types_block, force_base64=inline_base64_media) ) msg_i += 1 diff --git a/litellm/llms/snowflake/chat/transformation.py b/litellm/llms/snowflake/chat/transformation.py index 0816b975f2f..bb7999c1912 100644 --- a/litellm/llms/snowflake/chat/transformation.py +++ b/litellm/llms/snowflake/chat/transformation.py @@ -217,7 +217,7 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig): system: Final = AnthropicConfig().translate_system_message(messages=conversation_messages) try: conversation: Final = anthropic_messages_pt( - messages=conversation_messages, model=model, llm_provider="snowflake" + messages=conversation_messages, model=model, llm_provider="snowflake", force_base64=True ) except ValueError as e: raise SnowflakeException(status_code=400, message=str(e)) from e diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index 5686dee86a2..835124b33af 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -3558,6 +3558,34 @@ def test_anthropic_messages_pt_user_pdf_data_uri_becomes_document_block(model, l assert all(block["type"] != "image" for block in result[0]["content"]) +_TEXT_DATA_URI = "data:text/plain;base64," + base64.b64encode("the secret code word is MANGO\n".encode()).decode() +_TEXT_DOCUMENT_BLOCK = { + "type": "document", + "source": {"type": "text", "media_type": "text/plain", "data": "the secret code word is MANGO\n"}, +} + + +@pytest.mark.parametrize( + "block", + [ + {"type": "image_url", "image_url": {"url": _TEXT_DATA_URI}}, + {"type": "file", "file": {"file_data": _TEXT_DATA_URI}}, + ], + ids=["image_url", "file"], +) +def test_anthropic_messages_pt_text_plain_data_uri_becomes_text_source_document(block): + """ + Anthropic's base64 document source only takes application/pdf; a text/plain + document has to travel as a `text` source carrying the decoded text, or the + API answers 400 on every Anthropic-shaped provider. + """ + messages = [{"role": "user", "content": [{"type": "text", "text": "What is the code word?"}, block]}] + + result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") + + assert result[0]["content"][1] == _TEXT_DOCUMENT_BLOCK + + def test_anthropic_messages_pt_user_png_data_uri_stays_an_image_block(): messages = [{"role": "user", "content": [{"type": "image_url", "image_url": {"url": _PNG_DATA_URI}}]}] @@ -3577,7 +3605,7 @@ def test_anthropic_messages_pt_user_png_data_uri_stays_an_image_block(): ("invoke/us.anthropic.claude-sonnet-5", "bedrock", True), ("claude-sonnet-5", "vertex_ai", True), ("claude-sonnet-5", "vertex_ai_beta", True), - ("claude-sonnet-5", "snowflake", True), + ("claude-sonnet-5", "snowflake", False), ("claude-sonnet-5", "anthropic", False), ("us.anthropic.claude-sonnet-5", "bedrock", False), ("claude-sonnet-5", None, False), diff --git a/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py b/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py index 2b772101dd1..ebc8cd13039 100644 --- a/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py +++ b/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py @@ -13,6 +13,7 @@ from unittest.mock import AsyncMock, patch, Mock, MagicMock import httpx import pytest +import respx import litellm from litellm import completion, acompletion @@ -420,6 +421,16 @@ class TestSnowflakeCortexClaudeFixes: {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "ZmFrZQ=="}} ] + def test_remote_image_urls_are_inlined_as_base64(self): + with respx.mock(assert_all_called=True) as respx_mock: + respx_mock.get("https://example.com/cat.png").respond(content=b"fake", headers={"Content-Type": "image/png"}) + body = self._transform( + [{"role": "user", "content": [{"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}}]}] + ) + assert body["messages"][0]["content"] == [ + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "ZmFrZQ=="}} + ] + def test_tool_result_image_list_is_converted(self): body = self._transform( [ From c3a7ff22d3e737addb612c09ccf829d1e94fc237 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:17:13 -0700 Subject: [PATCH 03/26] fix(anthropic): keep the text source for text/plain data URIs that carry a charset parameter A data:text/plain;charset=utf-8;base64,... attachment passed the document detection but reached the file-block builder with the parameter still in its media type, so the text/plain comparison missed and the text went out as a base64 document that Anthropic rejects with a 400. The builder now compares the bare media type and decodes the bytes with the charset the URI names, defaulting to utf-8. --- .../prompt_templates/factory.py | 25 +++++++++----- ...llm_core_utils_prompt_templates_factory.py | 33 ++++++++++++------- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 5936329a1bf..653a6754a9a 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1524,6 +1524,19 @@ def _is_anthropic_document_data_uri(url: str) -> bool: return match.group(1) in _ANTHROPIC_DOCUMENT_BASE64_MEDIA_TYPES +def _anthropic_text_document_source(image_chunk: GenericImageParsingChunk) -> AnthropicContentParamSourceText | None: + media_type, _, _ = image_chunk["media_type"].partition(";") + if media_type.strip() != "text/plain": + return None + content_type_header: Final = Message() + content_type_header["content-type"] = image_chunk["media_type"] + return AnthropicContentParamSourceText( + type="text", + media_type="text/plain", + data=base64.b64decode(image_chunk["data"]).decode(content_type_header.get_content_charset("utf-8")), + ) + + def _anthropic_document_block_from_data_uri( url: str, original_content_element: dict | AllMessageValues, @@ -1904,15 +1917,9 @@ def anthropic_process_openai_file_message( openai_image_url=file_data, format=format, ) - if image_chunk["media_type"] == "text/plain": - return AnthropicMessagesDocumentParam( - type="document", - source=AnthropicContentParamSourceText( - type="text", - media_type="text/plain", - data=base64.b64decode(image_chunk["data"]).decode("utf-8"), - ), - ) + text_source: Final = _anthropic_text_document_source(image_chunk) + if text_source is not None: + return AnthropicMessagesDocumentParam(type="document", source=text_source) anthropic_document_param: Final = AnthropicMessagesDocumentParam( type="document", source=AnthropicContentParamSource( diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index 835124b33af..a22b12a718e 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -3558,32 +3558,41 @@ def test_anthropic_messages_pt_user_pdf_data_uri_becomes_document_block(model, l assert all(block["type"] != "image" for block in result[0]["content"]) -_TEXT_DATA_URI = "data:text/plain;base64," + base64.b64encode("the secret code word is MANGO\n".encode()).decode() -_TEXT_DOCUMENT_BLOCK = { - "type": "document", - "source": {"type": "text", "media_type": "text/plain", "data": "the secret code word is MANGO\n"}, -} +_TEXT_B64 = base64.b64encode("the secret code word is MANGO\n".encode()).decode() +_TEXT_DATA_URI = "data:text/plain;base64," + _TEXT_B64 +_TEXT_DATA_URI_WITH_CHARSET = "data:text/plain;charset=utf-8;base64," + _TEXT_B64 +_LATIN1_TEXT_DATA_URI = "data:text/plain;charset=iso-8859-1;base64," + base64.b64encode( + "the secret code word is MANGÓ\n".encode("latin-1") +).decode() @pytest.mark.parametrize( - "block", + "block, expected_text", [ - {"type": "image_url", "image_url": {"url": _TEXT_DATA_URI}}, - {"type": "file", "file": {"file_data": _TEXT_DATA_URI}}, + ({"type": "image_url", "image_url": {"url": _TEXT_DATA_URI}}, "the secret code word is MANGO\n"), + ({"type": "file", "file": {"file_data": _TEXT_DATA_URI}}, "the secret code word is MANGO\n"), + ({"type": "image_url", "image_url": {"url": _TEXT_DATA_URI_WITH_CHARSET}}, "the secret code word is MANGO\n"), + ({"type": "file", "file": {"file_data": _TEXT_DATA_URI_WITH_CHARSET}}, "the secret code word is MANGO\n"), + ({"type": "file", "file": {"file_data": _LATIN1_TEXT_DATA_URI}}, "the secret code word is MANGÓ\n"), ], - ids=["image_url", "file"], + ids=["image_url", "file", "image_url_charset", "file_charset", "file_latin1"], ) -def test_anthropic_messages_pt_text_plain_data_uri_becomes_text_source_document(block): +def test_anthropic_messages_pt_text_plain_data_uri_becomes_text_source_document(block, expected_text): """ Anthropic's base64 document source only takes application/pdf; a text/plain document has to travel as a `text` source carrying the decoded text, or the - API answers 400 on every Anthropic-shaped provider. + API answers 400 on every Anthropic-shaped provider. The data URI may carry a + charset parameter, which must neither hide the text/plain type nor be ignored + when decoding the bytes. """ messages = [{"role": "user", "content": [{"type": "text", "text": "What is the code word?"}, block]}] result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") - assert result[0]["content"][1] == _TEXT_DOCUMENT_BLOCK + assert result[0]["content"][1] == { + "type": "document", + "source": {"type": "text", "media_type": "text/plain", "data": expected_text}, + } def test_anthropic_messages_pt_user_png_data_uri_stays_an_image_block(): From c96b10dac402f54dcec9f5cdf0d0371c241fb998 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:22:36 -0700 Subject: [PATCH 04/26] fix(anthropic): keep native tool_use blocks in an assistant content list The shared transform only passed through server_tool_use and *_tool_result blocks, so replaying a stored conversation whose assistant turn carries a native tool_use block dropped the block and the tool_result that followed had no tool call to point at. Pass it through, deduped against tool_calls that repeat the same id --- .../prompt_templates/factory.py | 14 +++-- ...llm_core_utils_prompt_templates_factory.py | 51 +++++++++++++++++++ .../test_snowflake_chat_transformation.py | 25 +++++++++ 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 653a6754a9a..acae8c50f7f 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -2674,11 +2674,12 @@ def anthropic_messages_pt( for m in _content_list: if not isinstance(m, dict): continue + block_type: str = m.get("type", "") # handle thinking blocks thinking_block = cast(str, m.get("thinking", "")) text_block = cast(str, m.get("text", "")) if ( - m.get("type", "") == "thinking" + block_type == "thinking" and len(thinking_block) > 0 and not _is_unsignable_thinking_block(m) ): # don't pass empty text blocks. anthropic api raises errors. @@ -2688,7 +2689,7 @@ def anthropic_messages_pt( assistant_content.append(anthropic_message) # handle text elif ( - m.get("type", "") == "text" and len(text_block) > 0 + block_type == "text" and len(text_block) > 0 ): # don't pass empty text blocks. anthropic api raises errors. anthropic_message = AnthropicMessagesTextParam(type="text", text=text_block) _cached_message = add_cache_control_to_content( @@ -2699,7 +2700,14 @@ def anthropic_messages_pt( assistant_content.append(cast(AnthropicMessagesTextParam, _cached_message)) # handle server_tool_use blocks (tool search, web search, etc.) # Pass through as-is since these are Anthropic-native content types - elif m.get("type", "") == "server_tool_use" or m.get("type", "").endswith("_tool_result"): + elif block_type == "server_tool_use" or block_type.endswith("_tool_result"): + assistant_content.append(m) + elif block_type == "tool_use": # pyright: ignore[reportUnnecessaryComparison] # anthropic-native block absent from the declared content union + tool_use_id = m.get("id") + if tool_use_id: + if tool_use_id in unique_tool_ids: + continue + unique_tool_ids.add(tool_use_id) assistant_content.append(m) elif ( "content" in assistant_content_block diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index a22b12a718e..58543990501 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -3837,3 +3837,54 @@ def test_convert_to_anthropic_tool_invoke_keeps_paired_server_tool_use(): }, server_result, ] + + +def test_anthropic_messages_pt_keeps_assistant_tool_use_content_block(): + """ + A conversation replayed from an Anthropic-shaped client carries the tool call + as a `tool_use` block inside the assistant content list. Dropping it orphans + the `tool_result` that answers it, and the API rejects the whole request. + """ + messages = [ + {"role": "user", "content": "what is the weather in paris"}, + { + "role": "assistant", + "content": [ + {"type": "text", "text": "let me look that up"}, + {"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {"city": "paris"}}, + ], + }, + {"role": "tool", "tool_call_id": "toolu_01", "content": "sunny"}, + ] + + result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") + + assert result[1]["content"] == [ + {"type": "text", "text": "let me look that up"}, + {"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {"city": "paris"}}, + ] + assert result[2]["content"] == [{"type": "tool_result", "tool_use_id": "toolu_01", "content": "sunny"}] + + +def test_anthropic_messages_pt_does_not_duplicate_a_tool_use_present_in_both_shapes(): + messages = [ + {"role": "user", "content": "what is the weather in paris"}, + { + "role": "assistant", + "content": [{"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {"city": "paris"}}], + "tool_calls": [ + { + "id": "toolu_01", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"city": "paris"}'}, + } + ], + }, + {"role": "tool", "tool_call_id": "toolu_01", "content": "sunny"}, + ] + + result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") + + assert result[1]["content"] == [ + {"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {"city": "paris"}} + ] diff --git a/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py b/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py index ebc8cd13039..cda075f70a3 100644 --- a/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py +++ b/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py @@ -650,6 +650,31 @@ class TestSnowflakeCortexClaudeFixes: ], } + def test_assistant_tool_use_content_block_survives_the_transform(self): + """A client replaying an Anthropic-shaped history sends the tool call as a + tool_use block inside assistant content; dropping it orphans the tool_result + that answers it and Cortex rejects the request.""" + body = self._transform( + [ + {"role": "user", "content": "what is the weather in paris"}, + { + "role": "assistant", + "content": [ + {"type": "text", "text": "let me look that up"}, + {"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {"city": "paris"}}, + ], + }, + {"role": "tool", "tool_call_id": "toolu_01", "content": "sunny"}, + ] + ) + assert body["messages"][1]["content"] == [ + {"type": "text", "text": "let me look that up"}, + {"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {"city": "paris"}}, + ] + assert body["messages"][2]["content"] == [ + {"type": "tool_result", "tool_use_id": "toolu_01", "content": "sunny"} + ] + def test_thinking_only_assistant_turn_sends_no_empty_text_block(self): """Anthropic-shaped APIs reject empty text blocks, so a content-less thinking turn is thinking only.""" body = self._transform( From 5eac64c9e89aed8e8cd5fec9202a966aae3b9d0e Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:18:35 -0700 Subject: [PATCH 05/26] fix(anthropic): sanitize native tool_use ids and use the declared format A native `tool_use` block kept its raw id while the matching tool result was sanitized, so replaying a conversation with an id like `functions.Bash:0` still came back as a 400. Both sides go through the same sanitizer now, dedupe compares the sanitized ids, and the block is rebuilt from its own fields so stray keys and a string `input` do not reach the provider. An explicit `format` on an `image_url` or `file` block now decides both whether the attachment is a document and what media type it carries, so a PDF sent under a generic data URI mime reaches Claude as a document. Snowflake turns an attachment whose data URI names an unknown charset into a 400 naming that encoding instead of letting the lookup failure surface as a 500. --- basedpyright-code-budget.json | 4 +- .../prompt_templates/factory.py | 59 ++++++--- litellm/llms/snowflake/chat/transformation.py | 2 +- ...llm_core_utils_prompt_templates_factory.py | 116 +++++++++++++++++- .../test_snowflake_chat_transformation.py | 66 +++++++++- type-discipline-budget.json | 2 +- 6 files changed, 222 insertions(+), 27 deletions(-) diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index 4c5042f8f31..a7de440e632 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -105,7 +105,7 @@ "limit": 109 }, "reportUnknownMemberType": { - "limit": 38245 + "limit": 38243 }, "reportUnknownParameterType": { "limit": 19584 @@ -117,7 +117,7 @@ "limit": 110 }, "reportUnnecessaryComparison": { - "limit": 687 + "limit": 686 }, "reportUnnecessaryContains": { "limit": 4 diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index acae8c50f7f..2baf8fc1848 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -6,6 +6,7 @@ import mimetypes import re import xml.etree.ElementTree as ET from collections.abc import Iterator, Mapping, Sequence +from email.message import Message from enum import Enum from typing import Any, Final, TypedDict, cast, overload @@ -1517,7 +1518,11 @@ def _sanitize_anthropic_tool_use_id(tool_use_id: str) -> str: _ANTHROPIC_DOCUMENT_BASE64_MEDIA_TYPES: Final = {"application/pdf", "text/plain"} -def _is_anthropic_document_data_uri(url: str) -> bool: +def _is_anthropic_document_data_uri(url: str, format_override: str | None = None) -> bool: + if not url.startswith("data:"): + return False + if format_override: + return format_override in _ANTHROPIC_DOCUMENT_BASE64_MEDIA_TYPES match: Final = re.match(r"data:([^;,]+)", url) if not match: return False @@ -1540,8 +1545,12 @@ def _anthropic_text_document_source(image_chunk: GenericImageParsingChunk) -> An def _anthropic_document_block_from_data_uri( url: str, original_content_element: dict | AllMessageValues, + format_override: str | None = None, ) -> AnthropicMessagesDocumentParam: - synth_file_message: Final[ChatCompletionFileObject] = {"type": "file", "file": {"file_data": url}} + synth_file_message: Final[ChatCompletionFileObject] = { + "type": "file", + "file": {"file_data": url, "format": format_override} if format_override else {"file_data": url}, + } document_block: Final = anthropic_process_openai_file_message(synth_file_message) return cast( AnthropicMessagesDocumentParam, @@ -1630,8 +1639,8 @@ def convert_to_anthropic_tool_result( image_url_value = content["image_url"] format = image_url_value.get("format") if isinstance(image_url_value, dict) else None url_str = image_url_value.get("url") if isinstance(image_url_value, dict) else image_url_value - if isinstance(url_str, str) and _is_anthropic_document_data_uri(url_str): - anthropic_content_list.append(_anthropic_document_block_from_data_uri(url_str, content)) + if isinstance(url_str, str) and _is_anthropic_document_data_uri(url_str, format): + anthropic_content_list.append(_anthropic_document_block_from_data_uri(url_str, content, format)) else: _anthropic_image_param = create_anthropic_image_param( image_url_value, @@ -1845,6 +1854,25 @@ def add_cache_control_to_content( return anthropic_content_element +def _anthropic_native_tool_use_block(block: Mapping[str, object], tool_use_id: str) -> AnthropicMessagesToolUseParam: + tool_name: Final = str(block.get("name", "")) + tool_use_param: Final = AnthropicMessagesToolUseParam( + type="tool_use", + id=tool_use_id, + name=tool_name, + input=_parse_tool_call_arguments( + block.get("input"), + tool_name=tool_name, + context="Anthropic assistant tool_use block", + ), + ) + add_cache_control_to_content( + anthropic_content_element=tool_use_param, + original_content_element=dict(block), + ) + return tool_use_param + + def _anthropic_content_element_factory( image_chunk: GenericImageParsingChunk, ) -> AnthropicMessagesImageParam | AnthropicMessagesDocumentParam: @@ -2406,10 +2434,10 @@ def anthropic_messages_pt( m = cast(ChatCompletionImageObject, m) image_url_value = m["image_url"] url_str = image_url_value if isinstance(image_url_value, str) else image_url_value["url"] - if _is_anthropic_document_data_uri(url_str): - user_content.append(_anthropic_document_block_from_data_uri(url_str, dict(m))) - continue format = image_url_value.get("format") if isinstance(image_url_value, dict) else None + if _is_anthropic_document_data_uri(url_str, format): + user_content.append(_anthropic_document_block_from_data_uri(url_str, dict(m), format)) + continue if isinstance(image_url_value, str): image_url_input: str | dict[str, object] = image_url_value else: @@ -2674,7 +2702,7 @@ def anthropic_messages_pt( for m in _content_list: if not isinstance(m, dict): continue - block_type: str = m.get("type", "") + block_type: str = str(m.get("type", "")) # handle thinking blocks thinking_block = cast(str, m.get("thinking", "")) text_block = cast(str, m.get("text", "")) @@ -2702,13 +2730,12 @@ def anthropic_messages_pt( # Pass through as-is since these are Anthropic-native content types elif block_type == "server_tool_use" or block_type.endswith("_tool_result"): assistant_content.append(m) - elif block_type == "tool_use": # pyright: ignore[reportUnnecessaryComparison] # anthropic-native block absent from the declared content union - tool_use_id = m.get("id") - if tool_use_id: - if tool_use_id in unique_tool_ids: - continue - unique_tool_ids.add(tool_use_id) - assistant_content.append(m) + elif block_type == "tool_use": + tool_use_id = _sanitize_anthropic_tool_use_id(str(m.get("id", ""))) + if tool_use_id in unique_tool_ids: + continue + unique_tool_ids.add(tool_use_id) + assistant_content.append(_anthropic_native_tool_use_block(m, tool_use_id)) elif ( "content" in assistant_content_block and isinstance(assistant_content_block["content"], str) @@ -3343,8 +3370,6 @@ def stringify_json_tool_call_content(messages: list) -> list: ###### AMAZON BEDROCK ####### -from email.message import Message - import httpx from litellm.types.llms.bedrock import ( diff --git a/litellm/llms/snowflake/chat/transformation.py b/litellm/llms/snowflake/chat/transformation.py index bb7999c1912..86debc1de6b 100644 --- a/litellm/llms/snowflake/chat/transformation.py +++ b/litellm/llms/snowflake/chat/transformation.py @@ -219,7 +219,7 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig): conversation: Final = anthropic_messages_pt( messages=conversation_messages, model=model, llm_provider="snowflake", force_base64=True ) - except ValueError as e: + except (ValueError, LookupError) as e: raise SnowflakeException(status_code=400, message=str(e)) from e return (system or None), conversation diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index 58543990501..a48e3cc7f7f 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -3867,24 +3867,134 @@ def test_anthropic_messages_pt_keeps_assistant_tool_use_content_block(): def test_anthropic_messages_pt_does_not_duplicate_a_tool_use_present_in_both_shapes(): + """ + The same call can arrive as a `tool_use` block and as a `tool_calls` entry. + Both shapes sanitize the id the same way, so the deduplication has to compare + the sanitized ids, not the raw ones. + """ messages = [ {"role": "user", "content": "what is the weather in paris"}, { "role": "assistant", - "content": [{"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {"city": "paris"}}], + "content": [ + {"type": "tool_use", "id": "functions.Bash:0", "name": "get_weather", "input": {"city": "paris"}} + ], "tool_calls": [ { - "id": "toolu_01", + "id": "functions.Bash:0", "type": "function", "function": {"name": "get_weather", "arguments": '{"city": "paris"}'}, } ], }, + {"role": "tool", "tool_call_id": "functions.Bash:0", "content": "sunny"}, + ] + + result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") + + assert result[1]["content"] == [ + {"type": "tool_use", "id": "functions_Bash_0", "name": "get_weather", "input": {"city": "paris"}} + ] + + +def test_anthropic_messages_pt_sanitizes_a_native_tool_use_id_like_its_tool_result(): + """ + Anthropic only accepts tool ids matching ^[a-zA-Z0-9_-]+$, and the `tool_result` + side already sanitizes. A replayed `tool_use` block whose id came from another + client (`functions.Bash:0`) has to get the same treatment, or the two ids stop + matching and the request is rejected. + """ + messages = [ + {"role": "user", "content": "what is the weather in paris"}, + { + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "functions.Bash:0", "name": "get_weather", "input": {"city": "paris"}} + ], + }, + {"role": "tool", "tool_call_id": "functions.Bash:0", "content": "sunny"}, + ] + + result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") + + assert result[1]["content"] == [ + {"type": "tool_use", "id": "functions_Bash_0", "name": "get_weather", "input": {"city": "paris"}} + ] + assert result[2]["content"] == [ + {"type": "tool_result", "tool_use_id": "functions_Bash_0", "content": "sunny"} + ] + + +def test_anthropic_messages_pt_normalizes_a_native_tool_use_block(): + """ + A block accumulated from a stream carries a streaming `index` key and can hold + the arguments as a JSON string. Anthropic rejects both, so the block is rebuilt + from its id, name, and parsed input, keeping any cache_control the caller set. + """ + messages = [ + {"role": "user", "content": "what is the weather in paris"}, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "index": 0, + "id": "toolu_01", + "name": "get_weather", + "input": '{"city": "paris"}', + "cache_control": {"type": "ephemeral"}, + } + ], + }, {"role": "tool", "tool_call_id": "toolu_01", "content": "sunny"}, ] result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") assert result[1]["content"] == [ - {"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {"city": "paris"}} + { + "type": "tool_use", + "id": "toolu_01", + "name": "get_weather", + "input": {"city": "paris"}, + "cache_control": {"type": "ephemeral"}, + } ] + + +@pytest.mark.parametrize( + ("url", "format_override", "expected"), + [ + ( + _PDF_DATA_URI, + "image/png", + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": _PDF_DATA_URI.split(",", 1)[1], + }, + }, + ), + ( + "data:image/png;base64,JVBERi0=", + "application/pdf", + {"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": "JVBERi0="}}, + ), + ], + ids=["pdf_uri_declared_png", "png_uri_declared_pdf"], +) +def test_anthropic_messages_pt_honours_an_explicit_format_over_the_data_uri(url, format_override, expected): + """ + `format` is how a caller corrects a media type the data URI got wrong, so the + routing between image and document blocks has to read it before the URI's own + declared type. + """ + messages = [ + {"role": "user", "content": [{"type": "image_url", "image_url": {"url": url, "format": format_override}}]} + ] + + result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") + + assert result[0]["content"] == [expected] diff --git a/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py b/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py index cda075f70a3..a43e69610a2 100644 --- a/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py +++ b/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py @@ -18,7 +18,11 @@ import respx import litellm from litellm import completion, acompletion from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler -from litellm.llms.snowflake.chat.transformation import SnowflakeConfig, SnowflakeStreamingHandler +from litellm.llms.snowflake.chat.transformation import ( + SnowflakeConfig, + SnowflakeException, + SnowflakeStreamingHandler, +) from litellm.types.utils import ModelResponse @@ -423,9 +427,16 @@ class TestSnowflakeCortexClaudeFixes: def test_remote_image_urls_are_inlined_as_base64(self): with respx.mock(assert_all_called=True) as respx_mock: - respx_mock.get("https://example.com/cat.png").respond(content=b"fake", headers={"Content-Type": "image/png"}) + respx_mock.get("https://example.com/cat.png").respond( + content=b"fake", headers={"Content-Type": "image/png"} + ) body = self._transform( - [{"role": "user", "content": [{"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}}]}] + [ + { + "role": "user", + "content": [{"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}}], + } + ] ) assert body["messages"][0]["content"] == [ {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "ZmFrZQ=="}} @@ -675,6 +686,55 @@ class TestSnowflakeCortexClaudeFixes: {"type": "tool_result", "tool_use_id": "toolu_01", "content": "sunny"} ] + def test_native_tool_use_id_is_sanitized_like_its_tool_result(self): + """Cortex takes Anthropic's id pattern, and the tool_result side already sanitizes, + so a replayed id like functions.Bash:0 has to be sanitized on both sides or the two + stop matching.""" + body = self._transform( + [ + {"role": "user", "content": "what is the weather in paris"}, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "functions.Bash:0", + "name": "get_weather", + "input": {"city": "paris"}, + } + ], + }, + {"role": "tool", "tool_call_id": "functions.Bash:0", "content": "sunny"}, + ] + ) + assert body["messages"][1]["content"] == [ + {"type": "tool_use", "id": "functions_Bash_0", "name": "get_weather", "input": {"city": "paris"}} + ] + assert body["messages"][2]["content"] == [ + {"type": "tool_result", "tool_use_id": "functions_Bash_0", "content": "sunny"} + ] + + def test_unknown_charset_on_a_text_attachment_is_a_400(self): + """An unknown encoding label is a client mistake, so it has to come back as a 400 + naming the encoding instead of a 500 from the decode.""" + with pytest.raises(SnowflakeException) as exc_info: + self._transform( + [ + { + "role": "user", + "content": [ + { + "type": "file", + "file": {"file_data": "data:text/plain;charset=not-a-charset;base64,aGVsbG8="}, + } + ], + } + ] + ) + + assert exc_info.value.status_code == 400 + assert "not-a-charset" in str(exc_info.value) + def test_thinking_only_assistant_turn_sends_no_empty_text_block(self): """Anthropic-shaped APIs reject empty text blocks, so a content-less thinking turn is thinking only.""" body = self._transform( diff --git a/type-discipline-budget.json b/type-discipline-budget.json index 769c3b91406..b4a56816f57 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -3,7 +3,7 @@ "limit": 22179 }, "LIT002": { - "limit": 26713 + "limit": 26714 }, "LIT003": { "limit": 261 From 7bbd87b1bc27cde288909e883217e6ac7f7b8c0f Mon Sep 17 00:00:00 2001 From: mateo-berri Date: Sat, 5 Sep 2026 23:38:31 -0700 Subject: [PATCH 06/26] test(anthropic): cover tool_use id dedupe and tool_result pdf documents --- ...llm_core_utils_prompt_templates_factory.py | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index a48e3cc7f7f..a945eb44892 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -3998,3 +3998,67 @@ def test_anthropic_messages_pt_honours_an_explicit_format_over_the_data_uri(url, result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") assert result[0]["content"] == [expected] + + +def test_anthropic_messages_pt_collapses_native_tool_use_blocks_sharing_an_id(): + """ + A replayed history can carry the same call twice, once with the raw id and once + with the sanitized one, and both land on the same `tool_use_id`. Anthropic 400s + a request whose tool ids repeat, so only the first block may survive. + """ + messages = [ + {"role": "user", "content": "what is the weather in paris"}, + { + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "functions.Bash:0", "name": "get_weather", "input": {"city": "paris"}}, + {"type": "tool_use", "id": "functions_Bash_0", "name": "get_weather", "input": {"city": "london"}}, + ], + }, + {"role": "tool", "tool_call_id": "functions.Bash:0", "content": "sunny"}, + ] + + result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") + + assert result[1]["content"] == [ + {"type": "tool_use", "id": "functions_Bash_0", "name": "get_weather", "input": {"city": "paris"}} + ] + + +def test_anthropic_messages_pt_tool_result_pdf_data_uri_becomes_a_document_block(): + """ + A tool can answer with a PDF it just fetched, which arrives as an `image_url` + entry inside the tool message's content list. Anthropic only accepts a PDF in a + document block, so the tool_result has to route it the same way user content does. + """ + messages = [ + {"role": "user", "content": "fetch the invoice"}, + { + "role": "assistant", + "content": [{"type": "tool_use", "id": "toolu_01", "name": "fetch_invoice", "input": {}}], + }, + { + "role": "tool", + "tool_call_id": "toolu_01", + "content": [{"type": "image_url", "image_url": {"url": _PDF_DATA_URI}}], + }, + ] + + result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") + + assert result[2]["content"] == [ + { + "type": "tool_result", + "tool_use_id": "toolu_01", + "content": [ + { + "type": "document", + "source": { + "type": "base64", + "media_type": "application/pdf", + "data": _PDF_DATA_URI.split(",", 1)[1], + }, + } + ], + } + ] From 421fd60d5d2eba4d7bb90bb7bb2d319acdb4998c Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:08:57 -0700 Subject: [PATCH 07/26] fix(anthropic): keep data URI charset and reject unparseable tool_use input A data URI can name a charset that the caller's `format` field does not, and the transform used to overwrite the whole media type with `format`, so a latin-1 text attachment reached the provider labelled as utf-8. Keep the original parameters when `format` names the same base type. Assistant `tool_use` blocks with a non-JSON string `input` used to reach the provider verbatim, which every Anthropic-shaped API rejects. Parse them the way the tool-call paths already do, and treat a guardrail redaction as an empty input instead of a parse failure. --- .../prompt_templates/factory.py | 28 ++++++-- ...llm_core_utils_prompt_templates_factory.py | 68 +++++++++++++++++-- 2 files changed, 86 insertions(+), 10 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 2baf8fc1848..cca2c264e44 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -783,6 +783,13 @@ def convert_generic_image_chunk_to_openai_image_obj( return "data:{};{},{}".format(media_type, image_chunk["type"], image_chunk["data"]) +def _media_type_with_original_parameters(format_override: str, original_media_type: str) -> str: + base_type, separator, parameters = original_media_type.partition(";") + if not separator or base_type.strip() != format_override.strip(): + return format_override + return f"{format_override}{separator}{parameters}" + + def convert_to_anthropic_image_obj(openai_image_url: str, format: str | None) -> GenericImageParsingChunk: """ Input: @@ -802,7 +809,7 @@ def convert_to_anthropic_image_obj(openai_image_url: str, format: str | None) -> media_type, base64_data = openai_image_url.split("data:")[1].split(";base64,") if format: - media_type = format + media_type = _media_type_with_original_parameters(format, media_type) else: media_type = media_type.replace("\\/", "/") @@ -1854,17 +1861,26 @@ def add_cache_control_to_content( return anthropic_content_element +def _native_tool_use_input(raw: object, tool_name: str) -> dict[str, object]: + if isinstance(raw, Mapping): + return dict(raw) + if not isinstance(raw, str): + return {} + parsed: Final = parse_tool_call_arguments( + "{}" if raw == REDACTED_BY_LITELLM else raw, + tool_name=tool_name, + context="Anthropic assistant tool_use block", + ) + return parsed if isinstance(parsed, dict) else {} + + def _anthropic_native_tool_use_block(block: Mapping[str, object], tool_use_id: str) -> AnthropicMessagesToolUseParam: tool_name: Final = str(block.get("name", "")) tool_use_param: Final = AnthropicMessagesToolUseParam( type="tool_use", id=tool_use_id, name=tool_name, - input=_parse_tool_call_arguments( - block.get("input"), - tool_name=tool_name, - context="Anthropic assistant tool_use block", - ), + input=_native_tool_use_input(block.get("input"), tool_name), ) add_cache_control_to_content( anthropic_content_element=tool_use_param, diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index a945eb44892..b46099beb56 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch import pytest import litellm +from litellm.constants import REDACTED_BY_LITELLM from litellm.litellm_core_utils.prompt_templates.factory import ( BAD_MESSAGE_ERROR_STR, BEDROCK_DOCUMENT_PLACEHOLDER_TEXT, @@ -3525,10 +3526,10 @@ def test_bedrock_converse_pdf_only_user_message_gets_text_block(): ) def test_anthropic_messages_pt_user_pdf_data_uri_becomes_document_block(model, llm_provider): """ - Regression for LIT-6778: Claude Code sends a PDF as an image_url part with a - pdf data URI after the /v1/messages -> completion bridge. Every Anthropic-shaped - API rejects `{"type": "image", "media_type": "application/pdf"}`, so the user - content must carry a document block, with the block's cache breakpoint intact. + Regression for LIT-6778: an OpenAI client attaches a PDF as an image_url part + whose url is a pdf data URI. Every Anthropic-shaped API rejects + `{"type": "image", "media_type": "application/pdf"}`, so the user content must + carry a document block, with the block's cache breakpoint intact. """ messages = [ { @@ -4062,3 +4063,62 @@ def test_anthropic_messages_pt_tool_result_pdf_data_uri_becomes_a_document_block ], } ] + + +def test_anthropic_messages_pt_text_document_keeps_charset_named_by_the_data_uri(): + """ + `format` is meant to name the media type when the data URI is vague, so naming + the type the URI already declares must not throw away its `charset`. Decoding + latin-1 bytes as utf-8 raises before the request ever leaves litellm. + """ + latin1_data_uri: Final = "data:text/plain;charset=iso-8859-1;base64," + base64.b64encode( + "café".encode("iso-8859-1") + ).decode() + messages = [ + { + "role": "user", + "content": [{"type": "image_url", "image_url": {"url": latin1_data_uri, "format": "text/plain"}}], + } + ] + + result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") + + assert result[0]["content"] == [ + {"type": "document", "source": {"type": "text", "media_type": "text/plain", "data": "café"}} + ] + + +def test_anthropic_messages_pt_rejects_unparseable_native_tool_use_input(): + """ + The same broken arguments already fail the request when they arrive under + `tool_calls`, so a native `tool_use` block must not quietly send `{}` to the + model and get a confident answer built on arguments the caller never sent. + """ + messages = [ + {"role": "user", "content": "what is the weather"}, + { + "role": "assistant", + "content": [{"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": "{not json"}], + }, + ] + + with pytest.raises(ValueError, match="get_weather"): + anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") + + +def test_anthropic_messages_pt_native_tool_use_input_survives_guardrail_redaction(): + """ + A guardrail replaces tool input with the redaction sentinel, which is not JSON. + Rejecting it would turn every redacted replay into a 400. + """ + messages = [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": [{"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": REDACTED_BY_LITELLM}], + }, + ] + + result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") + + assert result[1]["content"] == [{"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {}}] From fd6d694d7c1e1c96d587609570dabe924c44aac1 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:01:26 -0700 Subject: [PATCH 08/26] fix(anthropic): only honour an explicit format anthropic accepts A `format` outside Anthropic's block media types, like the `application/octet-stream` a browser reports for a file it cannot identify, no longer routes a PDF data URI into an image block, and an explicit format drops the data URI's parameters unless it names the charset the text document path reads A text attachment litellm cannot decode now comes back as a 400 naming the encoding, mapped to a SnowflakeException on the Cortex route, instead of a decode error escaping the transform --- .../prompt_templates/factory.py | 40 +++++--- litellm/llms/snowflake/chat/transformation.py | 3 +- ...llm_core_utils_prompt_templates_factory.py | 91 +++++++++++++++++++ 3 files changed, 120 insertions(+), 14 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index cca2c264e44..a42fb4a5f83 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -783,7 +783,12 @@ def convert_generic_image_chunk_to_openai_image_obj( return "data:{};{},{}".format(media_type, image_chunk["type"], image_chunk["data"]) +_CHARSET_AWARE_MEDIA_TYPE: Final = "text/plain" + + def _media_type_with_original_parameters(format_override: str, original_media_type: str) -> str: + if format_override.strip() != _CHARSET_AWARE_MEDIA_TYPE: + return format_override base_type, separator, parameters = original_media_type.partition(";") if not separator or base_type.strip() != format_override.strip(): return format_override @@ -1525,15 +1530,17 @@ def _sanitize_anthropic_tool_use_id(tool_use_id: str) -> str: _ANTHROPIC_DOCUMENT_BASE64_MEDIA_TYPES: Final = {"application/pdf", "text/plain"} -def _is_anthropic_document_data_uri(url: str, format_override: str | None = None) -> bool: +def _anthropic_media_type_for_data_uri(url: str, format_override: str | None) -> str | None: if not url.startswith("data:"): - return False - if format_override: - return format_override in _ANTHROPIC_DOCUMENT_BASE64_MEDIA_TYPES + return None + if format_override and select_anthropic_content_block_type_for_file(format_override) != "container_upload": + return format_override match: Final = re.match(r"data:([^;,]+)", url) - if not match: - return False - return match.group(1) in _ANTHROPIC_DOCUMENT_BASE64_MEDIA_TYPES + return match.group(1) if match else None + + +def _is_anthropic_document_data_uri(url: str, format_override: str | None = None) -> bool: + return _anthropic_media_type_for_data_uri(url, format_override) in _ANTHROPIC_DOCUMENT_BASE64_MEDIA_TYPES def _anthropic_text_document_source(image_chunk: GenericImageParsingChunk) -> AnthropicContentParamSourceText | None: @@ -1542,11 +1549,17 @@ def _anthropic_text_document_source(image_chunk: GenericImageParsingChunk) -> An return None content_type_header: Final = Message() content_type_header["content-type"] = image_chunk["media_type"] - return AnthropicContentParamSourceText( - type="text", - media_type="text/plain", - data=base64.b64decode(image_chunk["data"]).decode(content_type_header.get_content_charset("utf-8")), - ) + charset: Final = content_type_header.get_content_charset("utf-8") + try: + text: Final = base64.b64decode(image_chunk["data"]).decode(charset) + except (LookupError, UnicodeDecodeError) as decode_error: + raise litellm.BadRequestError( + message=f"Text attachment could not be read as {charset!r}: {decode_error}. " + "Send it as utf-8, or name its encoding in the data URI (data:text/plain;charset=...).", + model=None, + llm_provider="anthropic", + ) from decode_error + return AnthropicContentParamSourceText(type="text", media_type="text/plain", data=text) def _anthropic_document_block_from_data_uri( @@ -1554,9 +1567,10 @@ def _anthropic_document_block_from_data_uri( original_content_element: dict | AllMessageValues, format_override: str | None = None, ) -> AnthropicMessagesDocumentParam: + document_media_type: Final = format_override if format_override in _ANTHROPIC_DOCUMENT_BASE64_MEDIA_TYPES else None synth_file_message: Final[ChatCompletionFileObject] = { "type": "file", - "file": {"file_data": url, "format": format_override} if format_override else {"file_data": url}, + "file": {"file_data": url, "format": document_media_type} if document_media_type else {"file_data": url}, } document_block: Final = anthropic_process_openai_file_message(synth_file_message) return cast( diff --git a/litellm/llms/snowflake/chat/transformation.py b/litellm/llms/snowflake/chat/transformation.py index 86debc1de6b..669e52905d2 100644 --- a/litellm/llms/snowflake/chat/transformation.py +++ b/litellm/llms/snowflake/chat/transformation.py @@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, Any, Final, Protocol, TypedDict import httpx from typing_extensions import ReadOnly +from litellm.exceptions import BadRequestError from litellm.litellm_core_utils.prompt_templates.factory import anthropic_messages_pt from litellm.llms.anthropic.chat.handler import ModelResponseIterator as AnthropicStreamParser from litellm.llms.anthropic.chat.transformation import AnthropicConfig @@ -219,7 +220,7 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig): conversation: Final = anthropic_messages_pt( messages=conversation_messages, model=model, llm_provider="snowflake", force_base64=True ) - except (ValueError, LookupError) as e: + except (ValueError, LookupError, BadRequestError) as e: raise SnowflakeException(status_code=400, message=str(e)) from e return (system or None), conversation diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index b46099beb56..5f0f3b1ca10 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -4122,3 +4122,94 @@ def test_anthropic_messages_pt_native_tool_use_input_survives_guardrail_redactio result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") assert result[1]["content"] == [{"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {}}] + + +def test_anthropic_messages_pt_keeps_a_pdf_a_document_when_the_format_is_generic(): + """ + A browser reports `application/octet-stream` for any file it cannot identify, so + that `format` says nothing about the bytes. Letting it override the data URI's own + `application/pdf` routes a real PDF into an image block Anthropic rejects. + """ + messages = [ + {"role": "user", "content": "fetch the invoice"}, + { + "role": "assistant", + "content": [{"type": "tool_use", "id": "toolu_01", "name": "fetch_invoice", "input": {}}], + }, + { + "role": "tool", + "tool_call_id": "toolu_01", + "content": [ + { + "type": "image_url", + "image_url": {"url": _PDF_DATA_URI, "format": "application/octet-stream"}, + } + ], + }, + ] + + result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") + + assert result[2]["content"] == [ + { + "type": "tool_result", + "tool_use_id": "toolu_01", + "content": [ + { + "type": "document", + "source": { + "type": "base64", + "media_type": "application/pdf", + "data": _PDF_DATA_URI.split(",", 1)[1], + }, + } + ], + } + ] + + +def test_anthropic_messages_pt_drops_data_uri_parameters_the_format_replaces(): + """ + A data URI built from a file picker carries the file name in its media type, which + Anthropic's media_type enum rejects. Naming the type in `format` is how a caller + cleans that up, so only the charset of a text attachment may survive it. + """ + png_data_uri: Final = "data:image/png;name=diagram.png;base64," + base64.b64encode(b"\x89PNG\r\n\x1a\n").decode() + messages = [ + { + "role": "user", + "content": [{"type": "image_url", "image_url": {"url": png_data_uri, "format": "image/png"}}], + } + ] + + result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") + + assert result[0]["content"] == [ + { + "type": "image", + "source": {"type": "base64", "media_type": "image/png", "data": png_data_uri.split(",", 1)[1]}, + } + ] + + +def test_anthropic_messages_pt_rejects_a_text_attachment_it_cannot_decode(): + """ + A text file a browser turned into a data URI names no charset, so bytes that are + not utf-8 cannot be read. The caller has to get a client error naming the problem, + never a decode failure carrying the whole attachment back in its message. + """ + undeclared_latin1: Final = "data:text/plain;base64," + base64.b64encode( + "the secret code word is caf\xe9".encode("iso-8859-1") * 200 + ).decode() + messages = [ + { + "role": "user", + "content": [{"type": "image_url", "image_url": {"url": undeclared_latin1, "format": "text/plain"}}], + } + ] + + with pytest.raises(litellm.BadRequestError) as raised: + anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") + + assert "utf-8" in str(raised.value) + assert undeclared_latin1.split(",", 1)[1] not in str(raised.value) From ade0e427ea53eb85e2eb216a595a49756de8b7b0 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:40:58 -0700 Subject: [PATCH 09/26] fix(anthropic): drop unanswered native tool_use, keep PDF bytes a document, and honor drop_params in the shared transform An assistant tool_use block with no tool result after it is left out again, as the merge base did, since Anthropic rejects it. PDF bytes stay a document block whatever image format the data URI or format field names, and a named PDF data URI goes out under its bare media type. Malformed tool-call JSON and a non-object native tool_use input send {} under drop_params and are a 400 without it, on Snowflake too. count_tokens counts a PDF image_url as the document the chat request sends. --- .../prompt_templates/common_utils.py | 13 ++ .../prompt_templates/factory.py | 134 +++++++++----- .../mid_conversation_system.py | 26 ++- .../anthropic/count_tokens/transformation.py | 20 +- litellm/llms/snowflake/chat/transformation.py | 17 +- ...llm_core_utils_prompt_templates_factory.py | 173 ++++++++++++++---- ...rompt_templates_mid_conversation_system.py | 17 ++ ...t_anthropic_count_tokens_transformation.py | 20 ++ .../test_snowflake_native_endpoints.py | 26 +++ 9 files changed, 351 insertions(+), 95 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index c8f1705718f..23fd256b669 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -2672,6 +2672,19 @@ def merge_consecutive_system_messages( ] +_TOOL_USE_ID_FALLBACK: Final = "tool_use_id" +_ANTHROPIC_TOOL_USE_ID_INVALID_CHARS: Final = re.compile(r"[^a-zA-Z0-9_-]") + + +def replace_invalid_tool_use_id_chars(tool_use_id: str, invalid_chars: re.Pattern[str]) -> str: + return invalid_chars.sub("_", tool_use_id) or _TOOL_USE_ID_FALLBACK + + +def sanitize_anthropic_tool_use_id(tool_use_id: str) -> str: + """Anthropic requires tool_use_id to match ^[a-zA-Z0-9_-]+$.""" + return replace_invalid_tool_use_id_chars(tool_use_id, _ANTHROPIC_TOOL_USE_ID_INVALID_CHARS) + + _DECODED_JSON: Final = TypeAdapter(object) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index c3604dac65b..b3b99d1c52d 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -23,13 +23,16 @@ from litellm import verbose_logger from litellm._uuid import uuid from litellm.constants import REDACTED_BY_LITELLM from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import ( + answered_ids_by_index, anthropic_system_messages, find_anthropic_server_tool_result, is_anthropic_tool_search_use, message_field, + native_tool_use_id, parts_of, place_mid_conversation_system, split_leading_system_run, + tool_use_is_answered, ) from litellm.litellm_core_utils.url_utils import async_safe_get, safe_get from litellm.llms.custom_httpx.http_handler import HTTPHandler, get_async_httpx_client, header_value @@ -69,7 +72,9 @@ from .common_utils import ( is_unsignable_thinking_block, native_redacted_thinking_data, parse_tool_call_arguments, + replace_invalid_tool_use_id_chars, salvage_concatenated_tool_arguments, + sanitize_anthropic_tool_use_id, ) from .image_handling import convert_url_to_base64 @@ -1672,29 +1677,18 @@ def convert_to_gemini_tool_call_result( return _part -_TOOL_USE_ID_FALLBACK: Final = "tool_use_id" -_ANTHROPIC_TOOL_USE_ID_INVALID_CHARS: Final = re.compile(r"[^a-zA-Z0-9_-]") _BEDROCK_TOOL_USE_ID_INVALID_CHARS: Final = re.compile(r"[^a-zA-Z0-9_.:-]") _BEDROCK_TOOL_USE_ID_MAX_LEN: Final = 64 _BEDROCK_TOOL_USE_ID_HASH_LEN: Final = 8 -def _replace_invalid_tool_use_id_chars(tool_use_id: str, invalid_chars: re.Pattern[str]) -> str: - return invalid_chars.sub("_", tool_use_id) or _TOOL_USE_ID_FALLBACK - - -def sanitize_anthropic_tool_use_id(tool_use_id: str) -> str: - """Anthropic requires tool_use_id to match ^[a-zA-Z0-9_-]+$.""" - return _replace_invalid_tool_use_id_chars(tool_use_id, _ANTHROPIC_TOOL_USE_ID_INVALID_CHARS) - - def _sanitize_bedrock_tool_use_id(tool_use_id: str) -> str: """ Bedrock Converse requires toolUseId to match [a-zA-Z0-9_.:-]+ and be at most 64 chars. Ids that need rewriting get a short hash of the original appended so two ids that only differ in a replaced char or past the cut still map to distinct values. """ - sanitized: Final = _replace_invalid_tool_use_id_chars(tool_use_id, _BEDROCK_TOOL_USE_ID_INVALID_CHARS) + sanitized: Final = replace_invalid_tool_use_id_chars(tool_use_id, _BEDROCK_TOOL_USE_ID_INVALID_CHARS) if sanitized == tool_use_id and len(sanitized) <= _BEDROCK_TOOL_USE_ID_MAX_LEN: return sanitized digest: Final = hashlib.sha256(tool_use_id.encode()).hexdigest()[:_BEDROCK_TOOL_USE_ID_HASH_LEN] @@ -1704,17 +1698,31 @@ def _sanitize_bedrock_tool_use_id(tool_use_id: str) -> str: _ANTHROPIC_DOCUMENT_BASE64_MEDIA_TYPES: Final = {"application/pdf", "text/plain"} -def _anthropic_media_type_for_data_uri(url: str, format_override: str | None) -> str | None: - if not url.startswith("data:"): - return None +_PDF_SIGNATURE: Final = b"%PDF-" + + +def _declared_media_type_for_data_uri(url: str, format_override: str | None) -> str | None: if format_override and select_anthropic_content_block_type_for_file(format_override) != "container_upload": return format_override match: Final = re.match(r"data:([^;,]+)", url) return match.group(1) if match else None -def _is_anthropic_document_data_uri(url: str, format_override: str | None = None) -> bool: - return _anthropic_media_type_for_data_uri(url, format_override) in _ANTHROPIC_DOCUMENT_BASE64_MEDIA_TYPES +def _data_uri_holds_a_pdf(url: str) -> bool: + _, _, payload = url.partition(",") + try: + return base64.b64decode(payload[:8]).startswith(_PDF_SIGNATURE) + except ValueError: + return False + + +def anthropic_document_media_type_for_data_uri(url: str, format_override: str | None = None) -> str | None: + if not url.startswith("data:"): + return None + declared: Final = _declared_media_type_for_data_uri(url, format_override) + if declared in _ANTHROPIC_DOCUMENT_BASE64_MEDIA_TYPES: + return declared + return "application/pdf" if _data_uri_holds_a_pdf(url) else None def _anthropic_text_document_source(image_chunk: GenericImageParsingChunk) -> AnthropicContentParamSourceText | None: @@ -1736,15 +1744,14 @@ def _anthropic_text_document_source(image_chunk: GenericImageParsingChunk) -> An return AnthropicContentParamSourceText(type="text", media_type="text/plain", data=text) -def _anthropic_document_block_from_data_uri( +def anthropic_document_block_from_data_uri( url: str, original_content_element: Mapping[str, object] | AllMessageValues, - format_override: str | None = None, + media_type: str, ) -> AnthropicMessagesDocumentParam: - document_media_type: Final = format_override if format_override in _ANTHROPIC_DOCUMENT_BASE64_MEDIA_TYPES else None synth_file_message: Final[ChatCompletionFileObject] = { "type": "file", - "file": {"file_data": url, "format": document_media_type} if document_media_type else {"file_data": url}, + "file": {"file_data": url, "format": media_type}, } document_block: Final = anthropic_process_openai_file_message(synth_file_message) return cast( @@ -1834,8 +1841,13 @@ def convert_to_anthropic_tool_result( image_url_value = content["image_url"] format = image_url_value.get("format") if isinstance(image_url_value, dict) else None url_str = image_url_value.get("url") if isinstance(image_url_value, dict) else image_url_value - if isinstance(url_str, str) and _is_anthropic_document_data_uri(url_str, format): - anthropic_content_list.append(_anthropic_document_block_from_data_uri(url_str, content, format)) + if ( + isinstance(url_str, str) + and (document_media_type := anthropic_document_media_type_for_data_uri(url_str, format)) is not None + ): + anthropic_content_list.append( + anthropic_document_block_from_data_uri(url_str, content, document_media_type) + ) else: _anthropic_image_param = create_anthropic_image_param( image_url_value, @@ -2010,6 +2022,7 @@ def convert_to_anthropic_tool_invoke( web_search_results: Sequence[object] | None = None, tool_results: Sequence[object] | None = None, answered_tool_ids: Container[str] = frozenset(), + drop_params: bool = False, ) -> list[_AnthropicToolInvokeItem]: """ OpenAI tool invokes: @@ -2064,10 +2077,8 @@ def convert_to_anthropic_tool_invoke( str, get_attribute_or_key(get_attribute_or_key(tool, "function"), "name"), ) - tool_input = parse_tool_call_arguments( - get_attribute_or_key(get_attribute_or_key(tool, "function"), "arguments"), - tool_name=tool_name, - context="Anthropic tool invoke", + tool_input = _tool_call_input( + get_attribute_or_key(get_attribute_or_key(tool, "function"), "arguments"), tool_name, drop_params ) server_tool_result = ( @@ -2130,32 +2141,53 @@ def add_cache_control_to_content( _TOOL_USE_INPUT: Final = TypeAdapter(dict[str, object]) -def _tool_use_input_object(value: object) -> dict[str, object]: +def _tool_call_input(arguments: str | Mapping[str, object] | None, tool_name: str, drop_params: bool) -> object: + try: + return parse_tool_call_arguments(arguments, tool_name=tool_name, context="Anthropic tool invoke") + except ValueError: + if drop_params: + return {} + raise + + +def _tool_use_input_object(value: object, tool_name: str) -> dict[str, object]: try: return _TOOL_USE_INPUT.validate_python(value) - except ValidationError: - return {} + except ValidationError as error: + raise ValueError( + f"Tool input for tool '{tool_name}' (Anthropic assistant tool_use block) must be a JSON object, " + f"got {type(value).__name__}. Pass drop_params=True to send an empty object in its place" + ) from error -def _native_tool_use_input(raw: object, tool_name: str) -> dict[str, object]: +def _parsed_native_tool_use_input(raw: object, tool_name: str) -> object: if not isinstance(raw, str): - return _tool_use_input_object(raw) - return _tool_use_input_object( - parse_tool_call_arguments( - "{}" if raw == REDACTED_BY_LITELLM else raw, - tool_name=tool_name, - context="Anthropic assistant tool_use block", - ) + return raw + return parse_tool_call_arguments( + "{}" if raw == REDACTED_BY_LITELLM else raw, + tool_name=tool_name, + context="Anthropic assistant tool_use block", ) -def _anthropic_native_tool_use_block(block: Mapping[str, object], tool_use_id: str) -> AnthropicMessagesToolUseParam: +def _native_tool_use_input(raw: object, tool_name: str, drop_params: bool) -> dict[str, object]: + try: + return _tool_use_input_object(_parsed_native_tool_use_input(raw, tool_name), tool_name) + except ValueError: + if drop_params: + return {} + raise + + +def _anthropic_native_tool_use_block( + block: Mapping[str, object], tool_use_id: str, drop_params: bool +) -> AnthropicMessagesToolUseParam: tool_name: Final = str(block.get("name", "")) tool_use_param: Final = AnthropicMessagesToolUseParam( type="tool_use", id=tool_use_id, name=tool_name, - input=_native_tool_use_input(block.get("input"), tool_name), + input=_native_tool_use_input(block.get("input"), tool_name, drop_params), ) add_cache_control_to_content( anthropic_content_element=tool_use_param, @@ -2239,11 +2271,12 @@ def anthropic_process_openai_file_message( text_source: Final = _anthropic_text_document_source(image_chunk) if text_source is not None: return AnthropicMessagesDocumentParam(type="document", source=text_source) + media_type, _, _ = image_chunk["media_type"].partition(";") anthropic_document_param: Final = AnthropicMessagesDocumentParam( type="document", source=AnthropicContentParamSource( type="base64", - media_type=image_chunk["media_type"], + media_type=media_type.strip(), data=image_chunk["data"], ), ) @@ -2872,6 +2905,7 @@ def anthropic_messages_pt( inline_base64_media: Final = force_base64 or requires_inline_base64_media(model, llm_provider) tool_result_later_in_turn: Final = _tool_result_later_in_turn(messages, replaced_server_tool_answers) + answered_by_assistant_index: Final = answered_ids_by_index(messages) msg_i = 0 while msg_i < len(messages): user_content: list[AnthropicMessagesUserMessageValues] = [] @@ -2895,8 +2929,12 @@ def anthropic_messages_pt( image_url_value = m["image_url"] url_str = image_url_value if isinstance(image_url_value, str) else image_url_value["url"] format = image_url_value.get("format") if isinstance(image_url_value, dict) else None - if _is_anthropic_document_data_uri(url_str, format): - user_content.append(_anthropic_document_block_from_data_uri(url_str, dict(m), format)) + if ( + document_media_type := anthropic_document_media_type_for_data_uri(url_str, format) + ) is not None: + user_content.append( + anthropic_document_block_from_data_uri(url_str, dict(m), document_media_type) + ) continue if isinstance(image_url_value, str): image_url_input: str | dict[str, object] = image_url_value @@ -3047,6 +3085,7 @@ def anthropic_messages_pt( web_search_results=_web_search_results_tc, tool_results=_tool_results_tc, answered_tool_ids=client_answered_tool_ids, + drop_params=drop_params, ) # Group tool invoke results into (server_tool_use, result) pairs @@ -3179,11 +3218,13 @@ def anthropic_messages_pt( elif block_type == "server_tool_use" or block_type.endswith("_tool_result"): assistant_content.append(m) elif block_type == "tool_use": - tool_use_id = sanitize_anthropic_tool_use_id(str(m.get("id", ""))) - if tool_use_id in unique_tool_ids: + tool_use_id = native_tool_use_id(m) + if tool_use_id in unique_tool_ids or not tool_use_is_answered( + tool_use_id, answered_by_assistant_index.get(msg_i, frozenset()) + ): continue unique_tool_ids.add(tool_use_id) - assistant_content.append(_anthropic_native_tool_use_block(m, tool_use_id)) + assistant_content.append(_anthropic_native_tool_use_block(m, tool_use_id, drop_params)) elif ( "content" in assistant_content_block and isinstance(assistant_content_block["content"], str) @@ -3219,6 +3260,7 @@ def anthropic_messages_pt( web_search_results=_web_search_results, tool_results=_tool_results, answered_tool_ids=client_answered_tool_ids, + drop_params=drop_params, ) # Prevent "tool_use ids must be unique" errors by filtering duplicates diff --git a/litellm/litellm_core_utils/prompt_templates/mid_conversation_system.py b/litellm/litellm_core_utils/prompt_templates/mid_conversation_system.py index ecf419695d0..da665ea744c 100644 --- a/litellm/litellm_core_utils/prompt_templates/mid_conversation_system.py +++ b/litellm/litellm_core_utils/prompt_templates/mid_conversation_system.py @@ -49,7 +49,7 @@ from litellm.types.llms.openai import ( ChatCompletionUserMessage, ) -from .common_utils import has_blank_redacted_data, is_unsignable_thinking_block +from .common_utils import has_blank_redacted_data, is_unsignable_thinking_block, sanitize_anthropic_tool_use_id CONVERTED_SYSTEM_NOTE: Final = ( "Operator note (not from the user): the following was originally a mid-conversation system-role reminder." @@ -272,11 +272,25 @@ def _thinking_block_renders(block: object) -> bool: ) -def _assistant_part_renders(part: object) -> bool: - """A text part always renders: the converter pads empty text with a placeholder.""" +def native_tool_use_id(block: object) -> str: + raw_id: Final = message_field(block, "id") + return sanitize_anthropic_tool_use_id(raw_id if isinstance(raw_id, str) else "") + + +def tool_use_is_answered(tool_use_id: str, answered: frozenset[str]) -> bool: + return any(sanitize_anthropic_tool_use_id(answer_id) == tool_use_id for answer_id in answered) + + +def _assistant_part_renders(part: object, answered: frozenset[str]) -> bool: + """A text part always renders: the converter pads empty text with a placeholder. + + A native ``tool_use`` renders only when the next turn answers it, since the converter drops an orphan. + """ part_type: Final = message_field(part, "type") if part_type in _THINKING_BLOCK_TYPES: return _thinking_block_renders(part) + if part_type == "tool_use": + return tool_use_is_answered(native_tool_use_id(part), answered) return part_type in _RENDERED_ASSISTANT_PART_TYPES or ( isinstance(part_type, str) and part_type.endswith("_tool_result") ) @@ -334,7 +348,7 @@ def _answered_by_assistant_index( return ((index, answered) for index, _ in groups[group_index][1]) -def _answered_ids_by_index(messages: Sequence[object]) -> Mapping[int, frozenset[str]]: +def answered_ids_by_index(messages: Sequence[object]) -> Mapping[int, frozenset[str]]: """``answered_tool_call_ids`` for every assistant message, computed in one pass over the messages.""" groups: Final = tuple( (role_group, tuple(group)) @@ -398,7 +412,7 @@ def _assistant_renders(message: object, answered: frozenset[str]) -> bool: return True parts: Final = parts_of(content) return ( - any(_assistant_part_renders(part) for part in parts) + any(_assistant_part_renders(part, answered) for part in parts) or _separate_thinking_blocks_render(message) or _tool_calls_render(message, answered) or bool(message_field(message, "function_call")) @@ -429,7 +443,7 @@ def _renders(message: object, answered: frozenset[str]) -> bool: def _rendered_indices(messages: Sequence[object]) -> frozenset[int]: - answered: Final = _answered_ids_by_index(messages) + answered: Final = answered_ids_by_index(messages) return frozenset(index for index, message in enumerate(messages) if _renders(message, answered.get(index, _NO_IDS))) diff --git a/litellm/llms/anthropic/count_tokens/transformation.py b/litellm/llms/anthropic/count_tokens/transformation.py index 592b5fb0f00..4435583ace7 100644 --- a/litellm/llms/anthropic/count_tokens/transformation.py +++ b/litellm/llms/anthropic/count_tokens/transformation.py @@ -40,16 +40,24 @@ _PLAIN_MESSAGE_KEYS: Final = frozenset({"role", "content"}) def _count_image(block: JsonValue) -> JsonValue: if not isinstance(block, dict) or block.get("type") != "image_url": return block - from litellm.litellm_core_utils.prompt_templates.factory import convert_to_anthropic_image_obj + from litellm.litellm_core_utils.prompt_templates.factory import ( + anthropic_document_block_from_data_uri, + anthropic_document_media_type_for_data_uri, + convert_to_anthropic_image_obj, + ) image_block: Final = _IMAGE_BLOCK.validate_python(block) image_url: Final = image_block["image_url"] - source: Final = convert_to_anthropic_image_obj( - openai_image_url=image_url if isinstance(image_url, str) else image_url["url"], - format=image_url.get("format") if isinstance(image_url, dict) else None, - ) + url: Final = image_url if isinstance(image_url, str) else image_url["url"] + format_override: Final = image_url.get("format") if isinstance(image_url, dict) else None + other_fields: Final = {key: value for key, value in block.items() if key not in {"type", "image_url"}} + document_media_type: Final = anthropic_document_media_type_for_data_uri(url, format_override) + if document_media_type is not None: + document: Final = anthropic_document_block_from_data_uri(url, image_block, document_media_type) + return {**other_fields, **_COUNT_REQUEST.validate_python(document)} + source: Final = convert_to_anthropic_image_obj(openai_image_url=url, format=format_override) image: Final = _COUNT_REQUEST.validate_python({"type": "image", "source": source}) - return {**{key: value for key, value in block.items() if key not in {"type", "image_url"}}, **image} + return {**other_fields, **image} def _count_block(block: JsonValue) -> JsonValue: diff --git a/litellm/llms/snowflake/chat/transformation.py b/litellm/llms/snowflake/chat/transformation.py index 05a92de9701..4257d568c00 100644 --- a/litellm/llms/snowflake/chat/transformation.py +++ b/litellm/llms/snowflake/chat/transformation.py @@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, Any, Final, Protocol, TypedDict import httpx from typing_extensions import ReadOnly +import litellm from litellm.exceptions import BadRequestError from litellm.litellm_core_utils.prompt_templates.factory import anthropic_messages_pt from litellm.litellm_core_utils.prompt_templates.image_handling import async_inline_remote_media @@ -201,16 +202,20 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig): return anthropic_tools def _extract_system_and_messages( - self, model: str, messages: list[AllMessageValues] + self, model: str, messages: list[AllMessageValues], drop_params: bool ) -> tuple[list[AnthropicSystemMessageContent] | None, list[AllAnthropicPassThroughMessageValues]]: """Cortex's /messages endpoint speaks Anthropic's dialect, so the shared translators own the shape.""" conversation_messages: Final = list(messages) system: Final = AnthropicConfig().translate_system_message(messages=conversation_messages) try: conversation: Final = anthropic_messages_pt( - messages=conversation_messages, model=model, llm_provider="snowflake", force_base64=True + messages=conversation_messages, + model=model, + llm_provider="snowflake", + drop_params=drop_params, + force_base64=True, ) - except (ValueError, LookupError, BadRequestError) as e: + except (ValueError, BadRequestError) as e: raise SnowflakeException(status_code=400, message=str(e)) from e return (system or None), conversation @@ -226,7 +231,8 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig): extra_body: Final = optional_params.pop("extra_body", {}) if is_snowflake_claude_model(model): - return self._transform_request_anthropic(model, messages, optional_params, stream, extra_body) + drop_params: Final = litellm.drop_params is True or litellm_params.get("drop_params") is True + return self._transform_request_anthropic(model, messages, optional_params, stream, extra_body, drop_params) return self._transform_request_openai(model, messages, optional_params, stream, extra_body) @property @@ -301,10 +307,11 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig): optional_params: dict, stream: bool, extra_body: dict, + drop_params: bool, ) -> dict: """Anthropic Messages format for /messages endpoint.""" passthrough_system: Final = optional_params.pop("system", None) - extracted_system, conversation = self._extract_system_and_messages(model, messages) + extracted_system, conversation = self._extract_system_and_messages(model, messages, drop_params) system: Final = passthrough_system if passthrough_system is not None else extracted_system if "tools" in optional_params: 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 5b1a99892cd..58e456ae457 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 @@ -5044,6 +5044,7 @@ def test_group_tool_exchanges_is_linear_in_message_count(): _PDF_DATA_URI = "data:application/pdf;base64," + base64.b64encode(b"%PDF-1.4 regression fixture").decode() +_PNG_B64 = base64.b64encode(b"\x89PNG\r\n\x1a\n regression fixture").decode() _PNG_DATA_URI = ( "data:image/png;base64," "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" @@ -8925,30 +8926,32 @@ def test_anthropic_messages_pt_normalizes_a_native_tool_use_block(): ("url", "format_override", "expected"), [ ( - _PDF_DATA_URI, + "data:application/pdf;base64," + _PNG_B64, "image/png", - { - "type": "image", - "source": { - "type": "base64", - "media_type": "image/png", - "data": _PDF_DATA_URI.split(",", 1)[1], - }, - }, + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": _PNG_B64}}, ), ( "data:image/png;base64,JVBERi0=", "application/pdf", {"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": "JVBERi0="}}, ), + ( + _PDF_DATA_URI, + "image/png", + { + "type": "document", + "source": {"type": "base64", "media_type": "application/pdf", "data": _PDF_DATA_URI.split(",", 1)[1]}, + }, + ), ], - ids=["pdf_uri_declared_png", "png_uri_declared_pdf"], + ids=["png_bytes_under_a_pdf_uri_declared_png", "png_uri_declared_pdf", "pdf_bytes_declared_png"], ) def test_anthropic_messages_pt_honours_an_explicit_format_over_the_data_uri(url, format_override, expected): """ `format` is how a caller corrects a media type the data URI got wrong, so the routing between image and document blocks has to read it before the URI's own - declared type. + declared type. Bytes that are a PDF stay a document whatever the labels say, + since Anthropic rejects a PDF sent as an image. """ messages = [ {"role": "user", "content": [{"type": "image_url", "image_url": {"url": url, "format": format_override}}]} @@ -8984,11 +8987,17 @@ def test_anthropic_messages_pt_collapses_native_tool_use_blocks_sharing_an_id(): ] -def test_anthropic_messages_pt_tool_result_pdf_data_uri_becomes_a_document_block(): +@pytest.mark.parametrize( + "image_url", + [{"url": _PDF_DATA_URI}, {"url": _PDF_DATA_URI, "format": "image/png"}], + ids=["no-format", "declared-png"], +) +def test_anthropic_messages_pt_tool_result_pdf_data_uri_becomes_a_document_block(image_url): """ A tool can answer with a PDF it just fetched, which arrives as an `image_url` entry inside the tool message's content list. Anthropic only accepts a PDF in a - document block, so the tool_result has to route it the same way user content does. + document block, so the tool_result has to route it the same way user content does, + whatever image format the tool labelled it with. """ messages = [ {"role": "user", "content": "fetch the invoice"}, @@ -8999,7 +9008,7 @@ def test_anthropic_messages_pt_tool_result_pdf_data_uri_becomes_a_document_block { "role": "tool", "tool_call_id": "toolu_01", - "content": [{"type": "image_url", "image_url": {"url": _PDF_DATA_URI}}], + "content": [{"type": "image_url", "image_url": image_url}], }, ] @@ -9046,24 +9055,6 @@ def test_anthropic_messages_pt_text_document_keeps_charset_named_by_the_data_uri ] -def test_anthropic_messages_pt_rejects_unparseable_native_tool_use_input(): - """ - The same broken arguments already fail the request when they arrive under - `tool_calls`, so a native `tool_use` block must not quietly send `{}` to the - model and get a confident answer built on arguments the caller never sent. - """ - messages = [ - {"role": "user", "content": "what is the weather"}, - { - "role": "assistant", - "content": [{"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": "{not json"}], - }, - ] - - with pytest.raises(ValueError, match="get_weather"): - anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") - - def test_anthropic_messages_pt_native_tool_use_input_survives_guardrail_redaction(): """ A guardrail replaces tool input with the redaction sentinel, which is not JSON. @@ -9075,6 +9066,7 @@ def test_anthropic_messages_pt_native_tool_use_input_survives_guardrail_redactio "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": REDACTED_BY_LITELLM}], }, + {"role": "tool", "tool_call_id": "toolu_01", "content": "sunny"}, ] result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") @@ -9082,6 +9074,123 @@ def test_anthropic_messages_pt_native_tool_use_input_survives_guardrail_redactio assert result[1]["content"] == [{"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {}}] +def test_anthropic_messages_pt_drops_a_native_tool_use_nobody_answered(): + """ + A stored conversation can leave a tool call without its result, the user moving + on with a plain message instead. Anthropic rejects a `tool_use` with no + `tool_result` right after it, and these replays were answered before native + blocks were kept, so an unanswered one is still left out. + """ + messages = [ + {"role": "user", "content": "what is the weather"}, + { + "role": "assistant", + "content": [ + {"type": "text", "text": "checking"}, + {"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {}}, + ], + }, + {"role": "user", "content": "never mind, tell me a joke"}, + ] + + result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") + + assert result[1]["content"] == [{"type": "text", "text": "checking"}] + + +def _answered_native_tool_use(tool_use: dict[str, object]) -> list[dict[str, object]]: + return [ + {"role": "user", "content": "what is the weather"}, + {"role": "assistant", "content": [tool_use]}, + {"role": "tool", "tool_call_id": "toolu_01", "content": "sunny"}, + ] + + +@pytest.mark.parametrize( + "tool_use", + [ + {"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": "[1, 2]"}, + {"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": [1, 2]}, + {"type": "tool_use", "id": "toolu_01", "name": "get_weather"}, + {"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": "{not json"}, + ], + ids=["json-array-string", "list", "missing", "unparseable"], +) +def test_anthropic_messages_pt_native_tool_use_input_that_is_not_an_object_follows_drop_params(tool_use): + """ + Anthropic only takes an object as tool input. Sending `{}` in place of what the + caller wrote changes the conversation, so that is only done when the caller asked + for unsupported values to be dropped, and is a client error otherwise. + """ + messages: Final = _answered_native_tool_use(tool_use) + + with pytest.raises(ValueError, match="get_weather"): + anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") + dropped: Final = anthropic_messages_pt( + messages=messages, model="claude-sonnet-5", llm_provider="anthropic", drop_params=True + ) + + assert dropped[1]["content"] == [{"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {}}] + + +def test_anthropic_messages_pt_unparseable_tool_call_arguments_follow_drop_params(): + """ + Broken `tool_calls` arguments are a client error, unless the caller asked for + unsupported values to be dropped, in which case the call goes out with no input. + """ + messages: Final = [ + {"role": "user", "content": "what is the weather"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "toolu_01", "type": "function", "function": {"name": "get_weather", "arguments": "{not json"}} + ], + }, + {"role": "tool", "tool_call_id": "toolu_01", "content": "sunny"}, + ] + + with pytest.raises(ValueError, match="get_weather"): + anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") + dropped: Final = anthropic_messages_pt( + messages=messages, model="claude-sonnet-5", llm_provider="anthropic", drop_params=True + ) + + assert dropped[1]["content"] == [{"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {}}] + + +_NAMED_PDF_DATA_URI = "data:application/pdf;name=report.pdf;base64," + base64.b64encode(b"%PDF-1.4 named").decode() + + +@pytest.mark.parametrize( + "block", + [ + {"type": "image_url", "image_url": {"url": _NAMED_PDF_DATA_URI}}, + {"type": "file", "file": {"file_data": _NAMED_PDF_DATA_URI}}, + ], + ids=["image_url", "file"], +) +def test_anthropic_messages_pt_sends_a_named_pdf_under_its_bare_media_type(block): + """ + A data URI built from a file picker carries the file name in its media type, and + Anthropic's document media_type only takes `application/pdf` itself. + """ + result = anthropic_messages_pt( + messages=[{"role": "user", "content": [block]}], model="claude-sonnet-5", llm_provider="anthropic" + ) + + assert result[0]["content"] == [ + { + "type": "document", + "source": { + "type": "base64", + "media_type": "application/pdf", + "data": _NAMED_PDF_DATA_URI.split(",", 1)[1], + }, + } + ] + + def test_anthropic_messages_pt_keeps_a_pdf_a_document_when_the_format_is_generic(): """ A browser reports `application/octet-stream` for any file it cannot identify, so diff --git a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_mid_conversation_system.py b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_mid_conversation_system.py index aab57b87a90..74c488abeb5 100644 --- a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_mid_conversation_system.py +++ b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_mid_conversation_system.py @@ -536,6 +536,23 @@ def test_flagged_placement_keeps_a_system_before_an_assistant_turn_whose_empty_t assert _roles(placed) == ["user", "system", "assistant", "user"] +def test_flagged_placement_keeps_a_system_before_an_answered_native_tool_use(): + placed = place_mid_conversation_system( + [ + {"role": "user", "content": "weather?"}, + {"role": "system", "content": "Be terse."}, + { + "role": "assistant", + "content": [{"type": "tool_use", "id": "functions.get_weather:0", "name": "get_weather", "input": {}}], + }, + {"role": "tool", "tool_call_id": "functions.get_weather:0", "content": "sunny"}, + ], + supports_mid_conversation_system=True, + ) + + assert _roles(placed) == ["user", "system", "assistant", "tool"] + + def test_anthropic_system_blocks_keeps_text_parts_with_their_cache_control_and_drops_the_rest(): run = [ {"role": "system", "content": "one", "cache_control": {"type": "ephemeral", "ttl": "1h"}}, 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 63f149fabe4..78bdf767ab3 100644 --- a/tests/unit/llms/anthropic/test_anthropic_count_tokens_transformation.py +++ b/tests/unit/llms/anthropic/test_anthropic_count_tokens_transformation.py @@ -71,6 +71,26 @@ def test_count_translates_openai_images_without_mutating_input( assert messages == original +@pytest.mark.parametrize( + "config_type", (AnthropicCountTokensConfig, AzureAIAnthropicCountTokensConfig) +) +def test_count_sends_a_pdf_image_url_as_the_document_the_chat_request_sends( + config_type: type[AnthropicCountTokensConfig], +) -> None: + pdf_url: Final = "data:application/pdf;base64," + b64encode(b"%PDF-1.4 count fixture").decode() + messages: Final[list[dict[str, JsonValue]]] = [ + {"role": "user", "content": [{"type": "image_url", "image_url": {"url": pdf_url}}]} + ] + + counted: Final = config_type().transform_request_to_count_tokens(model="claude-opus-5-5", messages=messages) + sent: Final = AnthropicConfig().transform_request( + model="claude-opus-5-5", messages=deepcopy(messages), optional_params={}, litellm_params={}, headers={} + ) + + assert counted["messages"] == sent["messages"] + assert counted["messages"][0]["content"][0]["type"] == "document" + + @pytest.mark.parametrize( "config_type", (AnthropicCountTokensConfig, AzureAIAnthropicCountTokensConfig) ) diff --git a/tests/unit/llms/snowflake/test_snowflake_native_endpoints.py b/tests/unit/llms/snowflake/test_snowflake_native_endpoints.py index 979d23c35a4..c70128d3225 100644 --- a/tests/unit/llms/snowflake/test_snowflake_native_endpoints.py +++ b/tests/unit/llms/snowflake/test_snowflake_native_endpoints.py @@ -732,6 +732,32 @@ class TestAnthropicMultiTurnToolMessages: ) assert excinfo.value.status_code == 400 + def test_malformed_json_in_tool_arguments_is_dropped_under_drop_params(self): + messages = [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_bad", + "type": "function", + "function": {"name": "broken_tool", "arguments": "not valid json{{{"}, + } + ], + }, + ] + body = self.cfg.transform_request( + model="snowflake/claude-sonnet-4-5", + messages=messages, + optional_params={}, + litellm_params={"drop_params": True}, + headers={}, + ) + assert body["messages"][1]["content"] == [ + {"type": "tool_use", "id": "call_bad", "name": "broken_tool", "input": {}} + ] + def test_non_string_tool_arguments_pass_through(self): messages = [ {"role": "user", "content": "hi"}, From 37d27baa589597a75c2304d036eb5b1669b75ac5 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:37:54 -0700 Subject: [PATCH 10/26] fix(anthropic): keep native user blocks and native tool_result pairs, and send only text system parts to Snowflake Native Anthropic image, document, and search_result blocks in a user turn are forwarded, with a URL image inlined for base64-only dialects. A native tool_result is kept when the previous assistant turn called its tool_use, and that pairing now also counts under modify_params and for mid-conversation system splits. A PDF downloaded from a URL becomes a document block. Snowflake keeps only the text parts of a system prompt. --- .../prompt_templates/factory.py | 68 ++++-- .../mid_conversation_system.py | 19 +- litellm/llms/snowflake/chat/transformation.py | 6 +- ...llm_core_utils_prompt_templates_factory.py | 231 +++++++++++------- .../test_snowflake_chat_transformation.py | 23 +- 5 files changed, 222 insertions(+), 125 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 9c4e1bd8767..1386b537948 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -931,7 +931,7 @@ def create_anthropic_image_param( image_url_input: str | dict, format: str | None = None, is_bedrock_invoke: bool = False, -) -> AnthropicMessagesImageParam: +) -> AnthropicMessagesImageParam | AnthropicMessagesDocumentParam: """ Create an AnthropicMessagesImageParam from an image URL input. @@ -951,14 +951,8 @@ def create_anthropic_image_param( # as these providers don't support URL sources for images if is_bedrock_invoke or image_url.startswith("http://"): base64_url: Final = convert_url_to_base64(url=image_url) - image_chunk = convert_to_anthropic_image_obj(openai_image_url=base64_url, format=format) - return AnthropicMessagesImageParam( - type="image", - source=AnthropicContentParamSource( - type="base64", - media_type=image_chunk["media_type"], - data=image_chunk["data"], - ), + return _anthropic_content_element_factory( + convert_to_anthropic_image_obj(openai_image_url=base64_url, format=format) ) else: # HTTPS URL - pass directly for regular Anthropic @@ -2738,8 +2732,13 @@ def _tool_call_ids_of_the_latest_assistant( if message.get("role") != "assistant": return latest_ids tool_calls: Final = message.get("tool_calls") or () + native_tool_uses: Final = ( + part for part in parts_of(message.get("content")) if message_field(part, "type") == "tool_use" + ) return frozenset( - tool_call_id for tool_call_id in map(_tool_call_id, cast(Iterable[object], tool_calls)) if tool_call_id + tool_call_id + for tool_call_id in map(_tool_call_id, (*cast(Iterable[object], tool_calls), *native_tool_uses)) + if tool_call_id ) @@ -3063,6 +3062,38 @@ def _cache_breakpoint_placeholder( return (_empty_text_placeholder(message),) +_NATIVE_ANTHROPIC_USER_BLOCK_TYPES: Final = frozenset({"document", "image", "search_result", "tool_result"}) + + +def _tool_use_ids_of_the_latest_turn(new_messages: Sequence[object]) -> frozenset[str]: + latest: Final = new_messages[-1] if new_messages else None + blocks: Final = parts_of(message_field(latest, "content")) if message_field(latest, "role") == "assistant" else () + return frozenset(native_tool_use_id(block) for block in blocks if message_field(block, "type") == "tool_use") + + +def _native_anthropic_user_block( + block: Mapping[str, object], previous_turn_tool_use_ids: frozenset[str], inline_base64_media: bool +) -> AnthropicMessagesUserMessageValues | None: + match block: + case {"type": "image", "source": {"type": "url", "url": str() as url}} if inline_base64_media: + inlined: Final = create_anthropic_image_param(url, is_bedrock_invoke=True) + add_cache_control_to_content(anthropic_content_element=inlined, original_content_element=block) + return inlined + case {"type": "tool_result", "tool_use_id": str() as tool_use_id}: + sanitized_id: Final = sanitize_anthropic_tool_use_id(tool_use_id) + if sanitized_id not in previous_turn_tool_use_ids: + return None + return cast( # cast-ok: the client sent this tool_result in Anthropic's own wire shape + AnthropicMessagesToolResultParam, {**block, "tool_use_id": sanitized_id} + ) + case {"type": "document" | "image" | "search_result"}: + return cast( # cast-ok: the client sent this block in Anthropic's own wire shape + AnthropicMessagesUserMessageValues, dict(block) + ) + case _: + return None + + def _empty_user_turn_placeholder(span: Sequence[AllMessageValues]) -> AnthropicMessagesTextParam | None: empty_user_message: Final = next( (message for message in reversed(span) if message["role"] == "user" and not message.get("content")), None @@ -3128,6 +3159,7 @@ def anthropic_messages_pt( while msg_i < len(messages): user_content: list[AnthropicMessagesUserMessageValues] = [] init_msg_i = msg_i + previous_turn_tool_use_ids = _tool_use_ids_of_the_latest_turn(new_messages) if isinstance(messages[msg_i], BaseModel): messages[msg_i] = dict(messages[msg_i]) ## MERGE CONSECUTIVE USER CONTENT ## @@ -3187,15 +3219,13 @@ def anthropic_messages_pt( _content_element = cast(AnthropicMessagesTextParam, _content_element) user_content.append(_content_element) - elif m.get("type", "") == "document": - _document_content_element = cast( - AnthropicMessagesDocumentParam, - add_cache_control_to_content( - anthropic_content_element=cast(AnthropicMessagesDocumentParam, m), - original_content_element=dict(m), - ), - ) - user_content.append(_document_content_element) + elif m.get("type", "") in _NATIVE_ANTHROPIC_USER_BLOCK_TYPES: + if ( + native_block := _native_anthropic_user_block( + m, previous_turn_tool_use_ids, inline_base64_media + ) + ) is not None: + user_content.append(native_block) elif m.get("type", "") == "file": _file_content_element = anthropic_process_openai_file_message( cast(ChatCompletionFileObject, m) diff --git a/litellm/litellm_core_utils/prompt_templates/mid_conversation_system.py b/litellm/litellm_core_utils/prompt_templates/mid_conversation_system.py index 36a522c7e20..54494abb48e 100644 --- a/litellm/litellm_core_utils/prompt_templates/mid_conversation_system.py +++ b/litellm/litellm_core_utils/prompt_templates/mid_conversation_system.py @@ -58,7 +58,9 @@ CONVERTED_SYSTEM_NOTE: Final = ( _USER_TYPE_ROLES: Final = frozenset({"user", "tool", "function"}) _TOOL_ROLES: Final = frozenset({"tool", "function"}) _NO_IDS: Final[frozenset[str]] = frozenset() -_RENDERED_PART_TYPES: Final = frozenset({"text", "image_url", "document", "file"}) +_RENDERED_PART_TYPES: Final = frozenset( + {"text", "image_url", "document", "file", "image", "search_result", "tool_result"} +) _RENDERED_ASSISTANT_PART_TYPES: Final = frozenset({"text", "server_tool_use"}) _THINKING_BLOCK_TYPES: Final = frozenset({"thinking", "redacted_thinking"}) @@ -325,6 +327,16 @@ def _tool_result_ids(answers: Iterable[object]) -> Iterator[str]: yield sanitize_anthropic_tool_use_id(tool_call_id) +def _native_tool_result_ids(messages: Iterable[object]) -> Iterator[str]: + for message in messages: + for part in parts_of(message_field(message, "content")): + if message_field(part, "type") == "tool_result" and isinstance( + tool_use_id := message_field(part, "tool_use_id"), str + ): + yield tool_use_id + yield sanitize_anthropic_tool_use_id(tool_use_id) + + def answered_tool_call_ids(messages: Sequence[object], assistant_index: int) -> frozenset[str]: from_assistant: Final = (messages[index] for index in range(assistant_index, len(messages))) following: Final = dropwhile(lambda message: message_field(message, "role") == "assistant", from_assistant) @@ -343,8 +355,9 @@ def _answered_by_assistant_index( groups: Sequence[tuple[str, tuple[tuple[int, object], ...]]], group_index: int ) -> Iterator[tuple[int, frozenset[str]]]: following: Final = groups[group_index + 1] if group_index + 1 < len(groups) else ("other", ()) - answered: Final = ( - frozenset(_tool_result_ids(message for _, message in following[1])) if following[0] == "tool" else _NO_IDS + following_messages: Final = tuple(message for _, message in following[1]) + answered: Final = frozenset( + _tool_result_ids(following_messages) if following[0] == "tool" else _native_tool_result_ids(following_messages) ) return ((index, answered) for index, _ in groups[group_index][1]) diff --git a/litellm/llms/snowflake/chat/transformation.py b/litellm/llms/snowflake/chat/transformation.py index 7b0ea78ba96..6c11b658fe7 100644 --- a/litellm/llms/snowflake/chat/transformation.py +++ b/litellm/llms/snowflake/chat/transformation.py @@ -207,7 +207,11 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig): ) -> tuple[list[AnthropicSystemMessageContent] | None, list[AllAnthropicPassThroughMessageValues]]: """Cortex's /messages endpoint speaks Anthropic's dialect, so the shared translators own the shape.""" conversation_messages: Final = list(messages) - system: Final = AnthropicConfig().translate_system_message(messages=conversation_messages) + system: Final = [ + part + for part in AnthropicConfig().translate_system_message(messages=conversation_messages) + if part.get("type") == "text" + ] try: conversation: Final = anthropic_messages_pt( messages=conversation_messages, 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 42b185fa48c..e7a27f9870b 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 @@ -5187,12 +5187,6 @@ def test_bedrock_converse_pdf_only_user_message_gets_text_block(): ], ) def test_anthropic_messages_pt_user_pdf_data_uri_becomes_document_block(model, llm_provider): - """ - Regression for LIT-6778: an OpenAI client attaches a PDF as an image_url part - whose url is a pdf data URI. Every Anthropic-shaped API rejects - `{"type": "image", "media_type": "application/pdf"}`, so the user content must - carry a document block, with the block's cache breakpoint intact. - """ messages = [ { "role": "user", @@ -5241,13 +5235,6 @@ _LATIN1_TEXT_DATA_URI = "data:text/plain;charset=iso-8859-1;base64," + base64.b6 ids=["image_url", "file", "image_url_charset", "file_charset", "file_latin1"], ) def test_anthropic_messages_pt_text_plain_data_uri_becomes_text_source_document(block, expected_text): - """ - Anthropic's base64 document source only takes application/pdf; a text/plain - document has to travel as a `text` source carrying the decoded text, or the - API answers 400 on every Anthropic-shaped provider. The data URI may carry a - charset parameter, which must neither hide the text/plain type nor be ignored - when decoding the bytes. - """ messages = [{"role": "user", "content": [{"type": "text", "text": "What is the code word?"}, block]}] result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") @@ -8900,11 +8887,6 @@ def test_wrong_type_template_field_in_requests_leaves_the_deployments_in_rotatio def test_anthropic_messages_pt_keeps_assistant_tool_use_content_block(): - """ - A conversation replayed from an Anthropic-shaped client carries the tool call - as a `tool_use` block inside the assistant content list. Dropping it orphans - the `tool_result` that answers it, and the API rejects the whole request. - """ messages = [ {"role": "user", "content": "what is the weather in paris"}, { @@ -8927,11 +8909,6 @@ def test_anthropic_messages_pt_keeps_assistant_tool_use_content_block(): def test_anthropic_messages_pt_does_not_duplicate_a_tool_use_present_in_both_shapes(): - """ - The same call can arrive as a `tool_use` block and as a `tool_calls` entry. - Both shapes sanitize the id the same way, so the deduplication has to compare - the sanitized ids, not the raw ones. - """ messages = [ {"role": "user", "content": "what is the weather in paris"}, { @@ -8958,12 +8935,6 @@ def test_anthropic_messages_pt_does_not_duplicate_a_tool_use_present_in_both_sha def test_anthropic_messages_pt_sanitizes_a_native_tool_use_id_like_its_tool_result(): - """ - Anthropic only accepts tool ids matching ^[a-zA-Z0-9_-]+$, and the `tool_result` - side already sanitizes. A replayed `tool_use` block whose id came from another - client (`functions.Bash:0`) has to get the same treatment, or the two ids stop - matching and the request is rejected. - """ messages = [ {"role": "user", "content": "what is the weather in paris"}, { @@ -8986,11 +8957,6 @@ def test_anthropic_messages_pt_sanitizes_a_native_tool_use_id_like_its_tool_resu def test_anthropic_messages_pt_normalizes_a_native_tool_use_block(): - """ - A block accumulated from a stream carries a streaming `index` key and can hold - the arguments as a JSON string. Anthropic rejects both, so the block is rebuilt - from its id, name, and parsed input, keeping any cache_control the caller set. - """ messages = [ {"role": "user", "content": "what is the weather in paris"}, { @@ -9047,12 +9013,6 @@ def test_anthropic_messages_pt_normalizes_a_native_tool_use_block(): ids=["png_bytes_under_a_pdf_uri_declared_png", "png_uri_declared_pdf", "pdf_bytes_declared_png"], ) def test_anthropic_messages_pt_honours_an_explicit_format_over_the_data_uri(url, format_override, expected): - """ - `format` is how a caller corrects a media type the data URI got wrong, so the - routing between image and document blocks has to read it before the URI's own - declared type. Bytes that are a PDF stay a document whatever the labels say, - since Anthropic rejects a PDF sent as an image. - """ messages = [ {"role": "user", "content": [{"type": "image_url", "image_url": {"url": url, "format": format_override}}]} ] @@ -9063,11 +9023,6 @@ def test_anthropic_messages_pt_honours_an_explicit_format_over_the_data_uri(url, def test_anthropic_messages_pt_collapses_native_tool_use_blocks_sharing_an_id(): - """ - A replayed history can carry the same call twice, once with the raw id and once - with the sanitized one, and both land on the same `tool_use_id`. Anthropic 400s - a request whose tool ids repeat, so only the first block may survive. - """ messages = [ {"role": "user", "content": "what is the weather in paris"}, { @@ -9093,12 +9048,6 @@ def test_anthropic_messages_pt_collapses_native_tool_use_blocks_sharing_an_id(): ids=["no-format", "declared-png"], ) def test_anthropic_messages_pt_tool_result_pdf_data_uri_becomes_a_document_block(image_url): - """ - A tool can answer with a PDF it just fetched, which arrives as an `image_url` - entry inside the tool message's content list. Anthropic only accepts a PDF in a - document block, so the tool_result has to route it the same way user content does, - whatever image format the tool labelled it with. - """ messages = [ {"role": "user", "content": "fetch the invoice"}, { @@ -9133,11 +9082,6 @@ def test_anthropic_messages_pt_tool_result_pdf_data_uri_becomes_a_document_block def test_anthropic_messages_pt_text_document_keeps_charset_named_by_the_data_uri(): - """ - `format` is meant to name the media type when the data URI is vague, so naming - the type the URI already declares must not throw away its `charset`. Decoding - latin-1 bytes as utf-8 raises before the request ever leaves litellm. - """ latin1_data_uri: Final = "data:text/plain;charset=iso-8859-1;base64," + base64.b64encode( "café".encode("iso-8859-1") ).decode() @@ -9156,10 +9100,6 @@ def test_anthropic_messages_pt_text_document_keeps_charset_named_by_the_data_uri def test_anthropic_messages_pt_native_tool_use_input_survives_guardrail_redaction(): - """ - A guardrail replaces tool input with the redaction sentinel, which is not JSON. - Rejecting it would turn every redacted replay into a 400. - """ messages = [ {"role": "user", "content": "hi"}, { @@ -9175,12 +9115,6 @@ def test_anthropic_messages_pt_native_tool_use_input_survives_guardrail_redactio def test_anthropic_messages_pt_drops_a_native_tool_use_nobody_answered(): - """ - A stored conversation can leave a tool call without its result, the user moving - on with a plain message instead. Anthropic rejects a `tool_use` with no - `tool_result` right after it, and these replays were answered before native - blocks were kept, so an unanswered one is still left out. - """ messages = [ {"role": "user", "content": "what is the weather"}, { @@ -9217,11 +9151,6 @@ def _answered_native_tool_use(tool_use: dict[str, object]) -> list[dict[str, obj ids=["json-array-string", "list", "missing", "unparseable"], ) def test_anthropic_messages_pt_native_tool_use_input_that_is_not_an_object_follows_drop_params(tool_use): - """ - Anthropic only takes an object as tool input. Sending `{}` in place of what the - caller wrote changes the conversation, so that is only done when the caller asked - for unsupported values to be dropped, and is a client error otherwise. - """ messages: Final = _answered_native_tool_use(tool_use) with pytest.raises(ValueError, match="get_weather"): @@ -9234,10 +9163,6 @@ def test_anthropic_messages_pt_native_tool_use_input_that_is_not_an_object_follo def test_anthropic_messages_pt_unparseable_tool_call_arguments_follow_drop_params(): - """ - Broken `tool_calls` arguments are a client error, unless the caller asked for - unsupported values to be dropped, in which case the call goes out with no input. - """ messages: Final = [ {"role": "user", "content": "what is the weather"}, { @@ -9271,10 +9196,6 @@ _NAMED_PDF_DATA_URI = "data:application/pdf;name=report.pdf;base64," + base64.b6 ids=["image_url", "file"], ) def test_anthropic_messages_pt_sends_a_named_pdf_under_its_bare_media_type(block): - """ - A data URI built from a file picker carries the file name in its media type, and - Anthropic's document media_type only takes `application/pdf` itself. - """ result = anthropic_messages_pt( messages=[{"role": "user", "content": [block]}], model="claude-sonnet-5", llm_provider="anthropic" ) @@ -9292,11 +9213,6 @@ def test_anthropic_messages_pt_sends_a_named_pdf_under_its_bare_media_type(block def test_anthropic_messages_pt_keeps_a_pdf_a_document_when_the_format_is_generic(): - """ - A browser reports `application/octet-stream` for any file it cannot identify, so - that `format` says nothing about the bytes. Letting it override the data URI's own - `application/pdf` routes a real PDF into an image block Anthropic rejects. - """ messages = [ {"role": "user", "content": "fetch the invoice"}, { @@ -9336,11 +9252,6 @@ def test_anthropic_messages_pt_keeps_a_pdf_a_document_when_the_format_is_generic def test_anthropic_messages_pt_drops_data_uri_parameters_the_format_replaces(): - """ - A data URI built from a file picker carries the file name in its media type, which - Anthropic's media_type enum rejects. Naming the type in `format` is how a caller - cleans that up, so only the charset of a text attachment may survive it. - """ png_data_uri: Final = "data:image/png;name=diagram.png;base64," + base64.b64encode(b"\x89PNG\r\n\x1a\n").decode() messages = [ { @@ -9360,11 +9271,6 @@ def test_anthropic_messages_pt_drops_data_uri_parameters_the_format_replaces(): def test_anthropic_messages_pt_rejects_a_text_attachment_it_cannot_decode(): - """ - A text file a browser turned into a data URI names no charset, so bytes that are - not utf-8 cannot be read. The caller has to get a client error naming the problem, - never a decode failure carrying the whole attachment back in its message. - """ undeclared_latin1: Final = "data:text/plain;base64," + base64.b64encode( "the secret code word is caf\xe9".encode("iso-8859-1") * 200 ).decode() @@ -9496,3 +9402,140 @@ def test_anthropic_messages_pt_cost_grows_linearly_with_parallel_native_tool_use growth: Final = _parallel_native_tool_use_seconds(1600) / _parallel_native_tool_use_seconds(400) assert growth < 11.0, f"cost grew {growth:.1f}x for 4x the tool uses" + + +_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + + +@pytest.mark.parametrize( + "block", + [ + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": _PNG_BASE64}}, + {"type": "search_result", "source": "https://example.com", "title": "t", "content": [{"type": "text", "text": "abc"}]}, + {"type": "document", "source": {"type": "text", "media_type": "text/plain", "data": "hello"}}, + ], + ids=["image", "search_result", "document"], +) +@pytest.mark.parametrize("llm_provider", ["anthropic", "bedrock", "vertex_ai", "snowflake"]) +def test_anthropic_messages_pt_forwards_native_anthropic_user_blocks(block, llm_provider): + messages = [{"role": "user", "content": [{"type": "text", "text": "read this"}, block]}] + + result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider=llm_provider) + + assert result[0]["content"] == [{"type": "text", "text": "read this"}, block] + + +def test_anthropic_messages_pt_inlines_a_native_url_image_for_a_base64_only_dialect(monkeypatch): + from litellm.litellm_core_utils.prompt_templates.image_handling import in_memory_cache + + mock_client = MagicMock() + mock_client.get.return_value = httpx.Response( + 200, content=base64.b64decode(_PNG_BASE64), headers={"Content-Type": "image/png"} + ) + monkeypatch.setattr(litellm, "user_url_validation", False, raising=False) + monkeypatch.setattr(litellm, "module_level_client", mock_client, raising=False) + in_memory_cache.flush_cache() + block = { + "type": "image", + "source": {"type": "url", "url": "https://example.com/cat.png"}, + "cache_control": {"type": "ephemeral"}, + } + + result = anthropic_messages_pt( + messages=[{"role": "user", "content": [block]}], model="claude-sonnet-5", llm_provider="vertex_ai" + ) + + assert result[0]["content"] == [ + { + "type": "image", + "source": {"type": "base64", "media_type": "image/png", "data": _PNG_BASE64}, + "cache_control": {"type": "ephemeral"}, + } + ] + + +@pytest.mark.parametrize("modify_params", [False, True]) +@pytest.mark.parametrize("llm_provider", ["anthropic", "snowflake"]) +def test_anthropic_messages_pt_keeps_a_native_tool_use_answered_by_a_native_tool_result( + monkeypatch, modify_params, llm_provider +): + monkeypatch.setattr(litellm, "modify_params", modify_params) + messages = [ + {"role": "user", "content": "what is the weather"}, + { + "role": "assistant", + "content": [ + {"type": "text", "text": "checking"}, + {"type": "tool_use", "id": "functions.get_weather:0", "name": "get_weather", "input": {"city": "Paris"}}, + ], + }, + { + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": "functions.get_weather:0", "content": "sunny"}], + }, + ] + + result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider=llm_provider) + + assert result[1:] == [ + { + "role": "assistant", + "content": [ + {"type": "text", "text": "checking"}, + {"type": "tool_use", "id": "functions_get_weather_0", "name": "get_weather", "input": {"city": "Paris"}}, + ], + }, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "functions_get_weather_0", "content": "sunny"}]}, + ] + + +def test_anthropic_messages_pt_keeps_a_native_tool_use_answered_by_a_tool_message_under_modify_params(monkeypatch): + monkeypatch.setattr(litellm, "modify_params", True) + messages = [ + {"role": "user", "content": "what is the weather"}, + {"role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {}}]}, + {"role": "tool", "tool_call_id": "toolu_01", "content": "sunny"}, + ] + + result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") + + assert result[1:] == [ + {"role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_01", "content": "sunny"}]}, + ] + + +def test_anthropic_messages_pt_drops_a_native_tool_result_the_previous_turn_never_called(): + messages = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "toolu_missing", "content": "sunny"}, + {"type": "text", "text": "and now?"}, + ], + }, + ] + + result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") + + assert result[2] == {"role": "user", "content": [{"type": "text", "text": "and now?"}]} + + +def test_create_anthropic_image_param_sends_a_downloaded_pdf_as_a_document(monkeypatch): + from litellm.litellm_core_utils.prompt_templates.image_handling import in_memory_cache + + pdf_bytes = b"%PDF-1.4\n1 0 obj\n" + mock_client = MagicMock() + mock_client.get.return_value = httpx.Response(200, content=pdf_bytes, headers={"Content-Type": "application/pdf"}) + monkeypatch.setattr(litellm, "user_url_validation", False, raising=False) + monkeypatch.setattr(litellm, "module_level_client", mock_client, raising=False) + in_memory_cache.flush_cache() + + block = create_anthropic_image_param("https://example.com/report.pdf", is_bedrock_invoke=True) + + assert block == { + "type": "document", + "source": {"type": "base64", "media_type": "application/pdf", "data": base64.b64encode(pdf_bytes).decode()}, + } diff --git a/tests/unit/llms/snowflake/chat/test_snowflake_chat_transformation.py b/tests/unit/llms/snowflake/chat/test_snowflake_chat_transformation.py index 1b409ec8e4f..8a604768de0 100644 --- a/tests/unit/llms/snowflake/chat/test_snowflake_chat_transformation.py +++ b/tests/unit/llms/snowflake/chat/test_snowflake_chat_transformation.py @@ -683,9 +683,6 @@ class TestSnowflakeCortexClaudeFixes: } def test_assistant_tool_use_content_block_survives_the_transform(self): - """A client replaying an Anthropic-shaped history sends the tool call as a - tool_use block inside assistant content; dropping it orphans the tool_result - that answers it and Cortex rejects the request.""" body = self._transform( [ {"role": "user", "content": "what is the weather in paris"}, @@ -708,9 +705,6 @@ class TestSnowflakeCortexClaudeFixes: ] def test_native_tool_use_id_is_sanitized_like_its_tool_result(self): - """Cortex takes Anthropic's id pattern, and the tool_result side already sanitizes, - so a replayed id like functions.Bash:0 has to be sanitized on both sides or the two - stop matching.""" body = self._transform( [ {"role": "user", "content": "what is the weather in paris"}, @@ -735,9 +729,22 @@ class TestSnowflakeCortexClaudeFixes: {"type": "tool_result", "tool_use_id": "functions_Bash_0", "content": "sunny"} ] + def test_system_prompt_keeps_only_its_text_parts(self): + body = self._transform( + [ + { + "role": "system", + "content": [ + {"type": "text", "text": "be terse"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBORw0KGgo="}}, + ], + }, + {"role": "user", "content": "hi"}, + ] + ) + assert body["system"] == [{"type": "text", "text": "be terse"}] + def test_unknown_charset_on_a_text_attachment_is_a_400(self): - """An unknown encoding label is a client mistake, so it has to come back as a 400 - naming the encoding instead of a 500 from the decode.""" with pytest.raises(SnowflakeException) as exc_info: self._transform( [ From 98812f15ad68d9468ac1d013a81a43b2882c60d0 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:46:10 -0700 Subject: [PATCH 11/26] refactor(anthropic): drop native user block forwarding that chat validation never lets through --- .../prompt_templates/factory.py | 49 ++------- .../mid_conversation_system.py | 19 +--- ...llm_core_utils_prompt_templates_factory.py | 103 ------------------ 3 files changed, 12 insertions(+), 159 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 1386b537948..8bd163047dc 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -3062,38 +3062,6 @@ def _cache_breakpoint_placeholder( return (_empty_text_placeholder(message),) -_NATIVE_ANTHROPIC_USER_BLOCK_TYPES: Final = frozenset({"document", "image", "search_result", "tool_result"}) - - -def _tool_use_ids_of_the_latest_turn(new_messages: Sequence[object]) -> frozenset[str]: - latest: Final = new_messages[-1] if new_messages else None - blocks: Final = parts_of(message_field(latest, "content")) if message_field(latest, "role") == "assistant" else () - return frozenset(native_tool_use_id(block) for block in blocks if message_field(block, "type") == "tool_use") - - -def _native_anthropic_user_block( - block: Mapping[str, object], previous_turn_tool_use_ids: frozenset[str], inline_base64_media: bool -) -> AnthropicMessagesUserMessageValues | None: - match block: - case {"type": "image", "source": {"type": "url", "url": str() as url}} if inline_base64_media: - inlined: Final = create_anthropic_image_param(url, is_bedrock_invoke=True) - add_cache_control_to_content(anthropic_content_element=inlined, original_content_element=block) - return inlined - case {"type": "tool_result", "tool_use_id": str() as tool_use_id}: - sanitized_id: Final = sanitize_anthropic_tool_use_id(tool_use_id) - if sanitized_id not in previous_turn_tool_use_ids: - return None - return cast( # cast-ok: the client sent this tool_result in Anthropic's own wire shape - AnthropicMessagesToolResultParam, {**block, "tool_use_id": sanitized_id} - ) - case {"type": "document" | "image" | "search_result"}: - return cast( # cast-ok: the client sent this block in Anthropic's own wire shape - AnthropicMessagesUserMessageValues, dict(block) - ) - case _: - return None - - def _empty_user_turn_placeholder(span: Sequence[AllMessageValues]) -> AnthropicMessagesTextParam | None: empty_user_message: Final = next( (message for message in reversed(span) if message["role"] == "user" and not message.get("content")), None @@ -3159,7 +3127,6 @@ def anthropic_messages_pt( while msg_i < len(messages): user_content: list[AnthropicMessagesUserMessageValues] = [] init_msg_i = msg_i - previous_turn_tool_use_ids = _tool_use_ids_of_the_latest_turn(new_messages) if isinstance(messages[msg_i], BaseModel): messages[msg_i] = dict(messages[msg_i]) ## MERGE CONSECUTIVE USER CONTENT ## @@ -3219,13 +3186,15 @@ def anthropic_messages_pt( _content_element = cast(AnthropicMessagesTextParam, _content_element) user_content.append(_content_element) - elif m.get("type", "") in _NATIVE_ANTHROPIC_USER_BLOCK_TYPES: - if ( - native_block := _native_anthropic_user_block( - m, previous_turn_tool_use_ids, inline_base64_media - ) - ) is not None: - user_content.append(native_block) + elif m.get("type", "") == "document": + _document_content_element = cast( + AnthropicMessagesDocumentParam, + add_cache_control_to_content( + anthropic_content_element=cast(AnthropicMessagesDocumentParam, m), + original_content_element=dict(m), + ), + ) + user_content.append(_document_content_element) elif m.get("type", "") == "file": _file_content_element = anthropic_process_openai_file_message( cast(ChatCompletionFileObject, m) diff --git a/litellm/litellm_core_utils/prompt_templates/mid_conversation_system.py b/litellm/litellm_core_utils/prompt_templates/mid_conversation_system.py index 54494abb48e..36a522c7e20 100644 --- a/litellm/litellm_core_utils/prompt_templates/mid_conversation_system.py +++ b/litellm/litellm_core_utils/prompt_templates/mid_conversation_system.py @@ -58,9 +58,7 @@ CONVERTED_SYSTEM_NOTE: Final = ( _USER_TYPE_ROLES: Final = frozenset({"user", "tool", "function"}) _TOOL_ROLES: Final = frozenset({"tool", "function"}) _NO_IDS: Final[frozenset[str]] = frozenset() -_RENDERED_PART_TYPES: Final = frozenset( - {"text", "image_url", "document", "file", "image", "search_result", "tool_result"} -) +_RENDERED_PART_TYPES: Final = frozenset({"text", "image_url", "document", "file"}) _RENDERED_ASSISTANT_PART_TYPES: Final = frozenset({"text", "server_tool_use"}) _THINKING_BLOCK_TYPES: Final = frozenset({"thinking", "redacted_thinking"}) @@ -327,16 +325,6 @@ def _tool_result_ids(answers: Iterable[object]) -> Iterator[str]: yield sanitize_anthropic_tool_use_id(tool_call_id) -def _native_tool_result_ids(messages: Iterable[object]) -> Iterator[str]: - for message in messages: - for part in parts_of(message_field(message, "content")): - if message_field(part, "type") == "tool_result" and isinstance( - tool_use_id := message_field(part, "tool_use_id"), str - ): - yield tool_use_id - yield sanitize_anthropic_tool_use_id(tool_use_id) - - def answered_tool_call_ids(messages: Sequence[object], assistant_index: int) -> frozenset[str]: from_assistant: Final = (messages[index] for index in range(assistant_index, len(messages))) following: Final = dropwhile(lambda message: message_field(message, "role") == "assistant", from_assistant) @@ -355,9 +343,8 @@ def _answered_by_assistant_index( groups: Sequence[tuple[str, tuple[tuple[int, object], ...]]], group_index: int ) -> Iterator[tuple[int, frozenset[str]]]: following: Final = groups[group_index + 1] if group_index + 1 < len(groups) else ("other", ()) - following_messages: Final = tuple(message for _, message in following[1]) - answered: Final = frozenset( - _tool_result_ids(following_messages) if following[0] == "tool" else _native_tool_result_ids(following_messages) + answered: Final = ( + frozenset(_tool_result_ids(message for _, message in following[1])) if following[0] == "tool" else _NO_IDS ) return ((index, answered) for index, _ in groups[group_index][1]) 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 e7a27f9870b..5289dcea93b 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 @@ -9404,91 +9404,6 @@ def test_anthropic_messages_pt_cost_grows_linearly_with_parallel_native_tool_use assert growth < 11.0, f"cost grew {growth:.1f}x for 4x the tool uses" -_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" - - -@pytest.mark.parametrize( - "block", - [ - {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": _PNG_BASE64}}, - {"type": "search_result", "source": "https://example.com", "title": "t", "content": [{"type": "text", "text": "abc"}]}, - {"type": "document", "source": {"type": "text", "media_type": "text/plain", "data": "hello"}}, - ], - ids=["image", "search_result", "document"], -) -@pytest.mark.parametrize("llm_provider", ["anthropic", "bedrock", "vertex_ai", "snowflake"]) -def test_anthropic_messages_pt_forwards_native_anthropic_user_blocks(block, llm_provider): - messages = [{"role": "user", "content": [{"type": "text", "text": "read this"}, block]}] - - result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider=llm_provider) - - assert result[0]["content"] == [{"type": "text", "text": "read this"}, block] - - -def test_anthropic_messages_pt_inlines_a_native_url_image_for_a_base64_only_dialect(monkeypatch): - from litellm.litellm_core_utils.prompt_templates.image_handling import in_memory_cache - - mock_client = MagicMock() - mock_client.get.return_value = httpx.Response( - 200, content=base64.b64decode(_PNG_BASE64), headers={"Content-Type": "image/png"} - ) - monkeypatch.setattr(litellm, "user_url_validation", False, raising=False) - monkeypatch.setattr(litellm, "module_level_client", mock_client, raising=False) - in_memory_cache.flush_cache() - block = { - "type": "image", - "source": {"type": "url", "url": "https://example.com/cat.png"}, - "cache_control": {"type": "ephemeral"}, - } - - result = anthropic_messages_pt( - messages=[{"role": "user", "content": [block]}], model="claude-sonnet-5", llm_provider="vertex_ai" - ) - - assert result[0]["content"] == [ - { - "type": "image", - "source": {"type": "base64", "media_type": "image/png", "data": _PNG_BASE64}, - "cache_control": {"type": "ephemeral"}, - } - ] - - -@pytest.mark.parametrize("modify_params", [False, True]) -@pytest.mark.parametrize("llm_provider", ["anthropic", "snowflake"]) -def test_anthropic_messages_pt_keeps_a_native_tool_use_answered_by_a_native_tool_result( - monkeypatch, modify_params, llm_provider -): - monkeypatch.setattr(litellm, "modify_params", modify_params) - messages = [ - {"role": "user", "content": "what is the weather"}, - { - "role": "assistant", - "content": [ - {"type": "text", "text": "checking"}, - {"type": "tool_use", "id": "functions.get_weather:0", "name": "get_weather", "input": {"city": "Paris"}}, - ], - }, - { - "role": "user", - "content": [{"type": "tool_result", "tool_use_id": "functions.get_weather:0", "content": "sunny"}], - }, - ] - - result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider=llm_provider) - - assert result[1:] == [ - { - "role": "assistant", - "content": [ - {"type": "text", "text": "checking"}, - {"type": "tool_use", "id": "functions_get_weather_0", "name": "get_weather", "input": {"city": "Paris"}}, - ], - }, - {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "functions_get_weather_0", "content": "sunny"}]}, - ] - - def test_anthropic_messages_pt_keeps_a_native_tool_use_answered_by_a_tool_message_under_modify_params(monkeypatch): monkeypatch.setattr(litellm, "modify_params", True) messages = [ @@ -9505,24 +9420,6 @@ def test_anthropic_messages_pt_keeps_a_native_tool_use_answered_by_a_tool_messag ] -def test_anthropic_messages_pt_drops_a_native_tool_result_the_previous_turn_never_called(): - messages = [ - {"role": "user", "content": "hi"}, - {"role": "assistant", "content": "hello"}, - { - "role": "user", - "content": [ - {"type": "tool_result", "tool_use_id": "toolu_missing", "content": "sunny"}, - {"type": "text", "text": "and now?"}, - ], - }, - ] - - result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") - - assert result[2] == {"role": "user", "content": [{"type": "text", "text": "and now?"}]} - - def test_create_anthropic_image_param_sends_a_downloaded_pdf_as_a_document(monkeypatch): from litellm.litellm_core_utils.prompt_templates.image_handling import in_memory_cache From 490922d8a93b4c540bb5adc4b6335262cbd9c28d Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:12:54 -0700 Subject: [PATCH 12/26] fix(snowflake): answer 400 for user parts Cortex Claude cannot take, and link an https pdf with a pdf format as a document --- .../prompt_templates/factory.py | 5 ++++ litellm/llms/snowflake/chat/transformation.py | 23 ++++++++++++++++ ...llm_core_utils_prompt_templates_factory.py | 13 ++++++++++ .../test_snowflake_chat_transformation.py | 26 +++++++++++++++++++ 4 files changed, 67 insertions(+) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 8bd163047dc..b32f1c71c6a 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -954,6 +954,11 @@ 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() == "application/pdf": + return AnthropicMessagesDocumentParam( + type="document", + source=AnthropicContentParamSourceUrl(type="url", url=image_url), + ) else: # HTTPS URL - pass directly for regular Anthropic return AnthropicMessagesImageParam( diff --git a/litellm/llms/snowflake/chat/transformation.py b/litellm/llms/snowflake/chat/transformation.py index 6c11b658fe7..b1ba8c06d7d 100644 --- a/litellm/llms/snowflake/chat/transformation.py +++ b/litellm/llms/snowflake/chat/transformation.py @@ -9,6 +9,7 @@ Ref: https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-rest-api """ from collections.abc import Mapping, Sequence +from itertools import chain from typing import TYPE_CHECKING, Any, Final, Protocol, TypedDict import httpx @@ -18,6 +19,7 @@ import litellm from litellm.exceptions import BadRequestError from litellm.litellm_core_utils.prompt_templates.factory import anthropic_messages_pt from litellm.litellm_core_utils.prompt_templates.image_handling import async_inline_remote_media +from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import message_field, parts_of from litellm.litellm_core_utils.url_utils import join_api_base from litellm.llms.anthropic.chat.handler import ModelResponseIterator as AnthropicStreamParser from litellm.llms.anthropic.chat.transformation import AnthropicConfig @@ -98,6 +100,20 @@ def is_snowflake_claude_model(model: str) -> bool: return any(name.startswith(p) for p in _CLAUDE_MODEL_PREFIXES) +_CORTEX_CLAUDE_USER_PART_TYPES: Final = frozenset({"text", "image_url", "document", "file"}) + + +def _user_parts(message: AllMessageValues) -> tuple[object, ...]: + return parts_of(message.get("content")) if message["role"] == "user" else () + + +def _untranslatable_user_part_types(messages: Sequence[AllMessageValues]) -> frozenset[str]: + part_types: Final = frozenset( + str(message_field(part, "type")) for part in chain.from_iterable(map(_user_parts, messages)) + ) + return part_types - _CORTEX_CLAUDE_USER_PART_TYPES + + def _clean_input_schema(schema: object) -> object: return {key: value for key, value in schema.items() if key != "$schema"} if isinstance(schema, Mapping) else schema @@ -207,6 +223,13 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig): ) -> tuple[list[AnthropicSystemMessageContent] | None, list[AllAnthropicPassThroughMessageValues]]: """Cortex's /messages endpoint speaks Anthropic's dialect, so the shared translators own the shape.""" conversation_messages: Final = list(messages) + untranslatable: Final = _untranslatable_user_part_types(conversation_messages) + if untranslatable and not drop_params: + raise SnowflakeException( + status_code=400, + message=f"Snowflake Cortex Claude cannot take {', '.join(sorted(untranslatable))} parts in a user " + "message. Remove them, or pass drop_params=True to drop them", + ) system: Final = [ part for part in AnthropicConfig().translate_system_message(messages=conversation_messages) 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 5289dcea93b..d355bfb9d46 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 @@ -9436,3 +9436,16 @@ def test_create_anthropic_image_param_sends_a_downloaded_pdf_as_a_document(monke "type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": base64.b64encode(pdf_bytes).decode()}, } + + +def test_create_anthropic_image_param_links_an_https_pdf_as_a_document_only_when_format_says_pdf(): + url = "https://example.com/report.pdf" + + assert create_anthropic_image_param({"url": url, "format": "application/pdf"}) == { + "type": "document", + "source": {"type": "url", "url": url}, + } + assert create_anthropic_image_param({"url": url, "format": "image/png"}) == { + "type": "image", + "source": {"type": "url", "url": url}, + } diff --git a/tests/unit/llms/snowflake/chat/test_snowflake_chat_transformation.py b/tests/unit/llms/snowflake/chat/test_snowflake_chat_transformation.py index 8a604768de0..0f464b21ed9 100644 --- a/tests/unit/llms/snowflake/chat/test_snowflake_chat_transformation.py +++ b/tests/unit/llms/snowflake/chat/test_snowflake_chat_transformation.py @@ -744,6 +744,32 @@ class TestSnowflakeCortexClaudeFixes: ) assert body["system"] == [{"type": "text", "text": "be terse"}] + _AUDIO_TURN = ( + { + "role": "user", + "content": [ + {"type": "text", "text": "hear"}, + {"type": "input_audio", "input_audio": {"data": "UklGRg==", "format": "wav"}}, + ], + }, + ) + + def test_user_part_cortex_claude_cannot_take_is_a_400_naming_it(self): + with pytest.raises(SnowflakeException) as exc_info: + self._transform(list(self._AUDIO_TURN)) + assert exc_info.value.status_code == 400 + assert "input_audio" in str(exc_info.value) + + def test_drop_params_drops_a_user_part_cortex_claude_cannot_take(self): + body = SnowflakeConfig().transform_request( + model="snowflake/claude-sonnet-4-6", + messages=list(self._AUDIO_TURN), + optional_params={}, + litellm_params={"drop_params": True}, + headers={}, + ) + assert body["messages"] == [{"role": "user", "content": [{"type": "text", "text": "hear"}]}] + def test_unknown_charset_on_a_text_attachment_is_a_400(self): with pytest.raises(SnowflakeException) as exc_info: self._transform( From 53f69b483d39bfa0642c3758cec181507b27aff3 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:17:36 -0700 Subject: [PATCH 13/26] fix(anthropic): send linked and mixed-case PDFs as documents, count them that way, and 400 malformed Snowflake parts --- .../prompt_templates/factory.py | 12 ++++---- .../anthropic/count_tokens/transformation.py | 5 ++-- litellm/llms/snowflake/chat/transformation.py | 11 ++++--- ...llm_core_utils_prompt_templates_factory.py | 17 +++++++++-- ...t_anthropic_count_tokens_transformation.py | 30 +++++++++++++++++++ .../test_snowflake_chat_transformation.py | 10 +++++++ 6 files changed, 70 insertions(+), 15 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index b32f1c71c6a..9deb3fd0ab6 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -951,10 +951,10 @@ def create_anthropic_image_param( # as these providers don't support URL sources for images if is_bedrock_invoke or image_url.startswith("http://"): base64_url: Final = convert_url_to_base64(url=image_url) - return _anthropic_content_element_factory( + 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() == "application/pdf": + elif format is not None and format.partition(";")[0].strip().lower() == "application/pdf": return AnthropicMessagesDocumentParam( type="document", source=AnthropicContentParamSourceUrl(type="url", url=image_url), @@ -1844,7 +1844,7 @@ def anthropic_document_media_type_for_data_uri(url: str, format_override: str | return None if _data_uri_holds_a_pdf(url): return "application/pdf" - declared: Final = _declared_media_type_for_data_uri(url, format_override) + declared: Final = (_declared_media_type_for_data_uri(url, format_override) or "").lower() return declared if declared in _ANTHROPIC_DOCUMENT_BASE64_MEDIA_TYPES else None @@ -2335,16 +2335,16 @@ def _anthropic_native_tool_use_block( return tool_use_param -def _anthropic_content_element_factory( +def anthropic_content_element_factory( image_chunk: GenericImageParsingChunk, ) -> AnthropicMessagesImageParam | AnthropicMessagesDocumentParam: - if image_chunk["media_type"] == "application/pdf": + if image_chunk["media_type"].partition(";")[0].strip().lower() == "application/pdf": _anthropic_content_element: AnthropicMessagesDocumentParam | AnthropicMessagesImageParam = ( AnthropicMessagesDocumentParam( type="document", source=AnthropicContentParamSource( type="base64", - media_type=image_chunk["media_type"], + media_type="application/pdf", data=image_chunk["data"], ), ) diff --git a/litellm/llms/anthropic/count_tokens/transformation.py b/litellm/llms/anthropic/count_tokens/transformation.py index 7f892cd46d7..882b72fde28 100644 --- a/litellm/llms/anthropic/count_tokens/transformation.py +++ b/litellm/llms/anthropic/count_tokens/transformation.py @@ -42,6 +42,7 @@ def _count_image(block: JsonValue) -> JsonValue: if not isinstance(block, dict) or block.get("type") != "image_url": return block from litellm.litellm_core_utils.prompt_templates.factory import ( + anthropic_content_element_factory, anthropic_document_block_from_data_uri, anthropic_document_media_type_for_data_uri, convert_to_anthropic_image_obj, @@ -57,8 +58,8 @@ def _count_image(block: JsonValue) -> JsonValue: document: Final = anthropic_document_block_from_data_uri(url, image_block, document_media_type) return {**other_fields, **_COUNT_REQUEST.validate_python(document)} source: Final = convert_to_anthropic_image_obj(openai_image_url=url, format=format_override) - image: Final = _COUNT_REQUEST.validate_python({"type": "image", "source": source}) - return {**other_fields, **image} + media_block: Final = anthropic_content_element_factory(source) + return {**other_fields, **_COUNT_REQUEST.validate_python(media_block)} def _count_block(block: JsonValue) -> JsonValue: diff --git a/litellm/llms/snowflake/chat/transformation.py b/litellm/llms/snowflake/chat/transformation.py index b1ba8c06d7d..a300bd9d161 100644 --- a/litellm/llms/snowflake/chat/transformation.py +++ b/litellm/llms/snowflake/chat/transformation.py @@ -107,10 +107,13 @@ def _user_parts(message: AllMessageValues) -> tuple[object, ...]: return parts_of(message.get("content")) if message["role"] == "user" else () +def _part_type(part: object) -> str: + part_type: Final = message_field(part, "type") + return f"untyped {type(part).__name__}" if part_type is None else str(part_type) + + def _untranslatable_user_part_types(messages: Sequence[AllMessageValues]) -> frozenset[str]: - part_types: Final = frozenset( - str(message_field(part, "type")) for part in chain.from_iterable(map(_user_parts, messages)) - ) + part_types: Final = frozenset(map(_part_type, chain.from_iterable(map(_user_parts, messages)))) return part_types - _CORTEX_CLAUDE_USER_PART_TYPES @@ -243,7 +246,7 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig): drop_params=drop_params, force_base64=True, ) - except (ValueError, BadRequestError) as e: + except (AttributeError, LookupError, TypeError, ValueError, BadRequestError) as e: raise SnowflakeException(status_code=400, message=str(e)) from e return (system or None), conversation 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 d355bfb9d46..4179dafe570 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 @@ -5231,8 +5231,12 @@ _LATIN1_TEXT_DATA_URI = "data:text/plain;charset=iso-8859-1;base64," + base64.b6 ({"type": "image_url", "image_url": {"url": _TEXT_DATA_URI_WITH_CHARSET}}, "the secret code word is MANGO\n"), ({"type": "file", "file": {"file_data": _TEXT_DATA_URI_WITH_CHARSET}}, "the secret code word is MANGO\n"), ({"type": "file", "file": {"file_data": _LATIN1_TEXT_DATA_URI}}, "the secret code word is MANGÓ\n"), + ( + {"type": "image_url", "image_url": {"url": "data:TEXT/PLAIN;base64," + _TEXT_B64}}, + "the secret code word is MANGO\n", + ), ], - ids=["image_url", "file", "image_url_charset", "file_charset", "file_latin1"], + ids=["image_url", "file", "image_url_charset", "file_charset", "file_latin1", "image_url_uppercase"], ) def test_anthropic_messages_pt_text_plain_data_uri_becomes_text_source_document(block, expected_text): messages = [{"role": "user", "content": [{"type": "text", "text": "What is the code word?"}, block]}] @@ -9420,7 +9424,8 @@ def test_anthropic_messages_pt_keeps_a_native_tool_use_answered_by_a_tool_messag ] -def test_create_anthropic_image_param_sends_a_downloaded_pdf_as_a_document(monkeypatch): +@pytest.mark.parametrize("pdf_format", (None, "Application/PDF")) +def test_create_anthropic_image_param_sends_a_downloaded_pdf_as_a_document(monkeypatch, pdf_format): from litellm.litellm_core_utils.prompt_templates.image_handling import in_memory_cache pdf_bytes = b"%PDF-1.4\n1 0 obj\n" @@ -9430,7 +9435,9 @@ def test_create_anthropic_image_param_sends_a_downloaded_pdf_as_a_document(monke monkeypatch.setattr(litellm, "module_level_client", mock_client, raising=False) in_memory_cache.flush_cache() - block = create_anthropic_image_param("https://example.com/report.pdf", is_bedrock_invoke=True) + block = create_anthropic_image_param( + {"url": "https://example.com/report.pdf", "format": pdf_format}, is_bedrock_invoke=True + ) assert block == { "type": "document", @@ -9445,6 +9452,10 @@ def test_create_anthropic_image_param_links_an_https_pdf_as_a_document_only_when "type": "document", "source": {"type": "url", "url": url}, } + assert create_anthropic_image_param({"url": url, "format": "Application/PDF"}) == { + "type": "document", + "source": {"type": "url", "url": url}, + } assert create_anthropic_image_param({"url": url, "format": "image/png"}) == { "type": "image", "source": {"type": "url", "url": url}, 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 bd9a3da1357..013a595029c 100644 --- a/tests/unit/llms/anthropic/test_anthropic_count_tokens_transformation.py +++ b/tests/unit/llms/anthropic/test_anthropic_count_tokens_transformation.py @@ -91,6 +91,36 @@ def test_count_sends_a_pdf_image_url_as_the_document_the_chat_request_sends( assert counted["messages"][0]["content"][0]["type"] == "document" +@pytest.mark.usefixtures("httpx_transport_clients") +@pytest.mark.parametrize( + "config_type", (AnthropicCountTokensConfig, AzureAIAnthropicCountTokensConfig) +) +@pytest.mark.parametrize("pdf_format", (None, "application/pdf")) +def test_count_sends_a_linked_pdf_as_a_document( + config_type: type[AnthropicCountTokensConfig], + pdf_format: str | None, +) -> None: + pdf_bytes: Final = b"%PDF-1.4 linked count fixture" + pdf_url: Final = f"https://1.1.1.1/{config_type.__name__}-{pdf_format}.pdf" + image_url: Final[dict[str, JsonValue]] = {"url": pdf_url} if pdf_format is None else { + "url": pdf_url, "format": pdf_format + } + messages: Final[list[dict[str, JsonValue]]] = [ + {"role": "user", "content": [{"type": "image_url", "image_url": image_url}]} + ] + + with respx.mock: + respx.get(pdf_url).mock( + return_value=httpx.Response(200, content=pdf_bytes, headers={"Content-Type": "application/pdf"}) + ) + counted: Final = config_type().transform_request_to_count_tokens(model="claude-opus-5-5", messages=messages) + + assert counted["messages"][0]["content"][0] == { + "type": "document", + "source": {"type": "base64", "media_type": "application/pdf", "data": b64encode(pdf_bytes).decode()}, + } + + @pytest.mark.parametrize( "config_type", (AnthropicCountTokensConfig, AzureAIAnthropicCountTokensConfig) ) diff --git a/tests/unit/llms/snowflake/chat/test_snowflake_chat_transformation.py b/tests/unit/llms/snowflake/chat/test_snowflake_chat_transformation.py index 0f464b21ed9..849d9db3764 100644 --- a/tests/unit/llms/snowflake/chat/test_snowflake_chat_transformation.py +++ b/tests/unit/llms/snowflake/chat/test_snowflake_chat_transformation.py @@ -760,6 +760,16 @@ class TestSnowflakeCortexClaudeFixes: assert exc_info.value.status_code == 400 assert "input_audio" in str(exc_info.value) + @pytest.mark.parametrize( + ("part", "named"), + (("hello", "untyped str"), ({"type": "image_url", "image_url": {"detail": "low"}}, "url")), + ) + def test_malformed_user_part_is_a_400_naming_it(self, part, named): + with pytest.raises(SnowflakeException) as exc_info: + self._transform([{"role": "user", "content": [part]}]) + assert exc_info.value.status_code == 400 + assert named in str(exc_info.value) + def test_drop_params_drops_a_user_part_cortex_claude_cannot_take(self): body = SnowflakeConfig().transform_request( model="snowflake/claude-sonnet-4-6", From 3c7a77e3b08331ad49100e0ee3a323a36bc05b5d Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:14:48 -0700 Subject: [PATCH 14/26] fix(anthropic): keep the charset of a mixed-case text/plain data URI --- .../litellm_core_utils/prompt_templates/factory.py | 4 ++-- ..._litellm_core_utils_prompt_templates_factory.py | 14 +++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 9deb3fd0ab6..746745cc791 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -883,10 +883,10 @@ _CHARSET_AWARE_MEDIA_TYPE: Final = "text/plain" def _media_type_with_original_parameters(format_override: str, original_media_type: str) -> str: - if format_override.strip() != _CHARSET_AWARE_MEDIA_TYPE: + if format_override.strip().lower() != _CHARSET_AWARE_MEDIA_TYPE: return format_override base_type, separator, parameters = original_media_type.partition(";") - if not separator or base_type.strip() != format_override.strip(): + if not separator or base_type.strip().lower() != format_override.strip().lower(): return format_override return f"{format_override}{separator}{parameters}" 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 4179dafe570..9cb574bada5 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 @@ -5235,8 +5235,20 @@ _LATIN1_TEXT_DATA_URI = "data:text/plain;charset=iso-8859-1;base64," + base64.b6 {"type": "image_url", "image_url": {"url": "data:TEXT/PLAIN;base64," + _TEXT_B64}}, "the secret code word is MANGO\n", ), + ( + {"type": "image_url", "image_url": {"url": _LATIN1_TEXT_DATA_URI.replace("text/plain", "TEXT/PLAIN", 1)}}, + "the secret code word is MANGÓ\n", + ), + ], + ids=[ + "image_url", + "file", + "image_url_charset", + "file_charset", + "file_latin1", + "image_url_uppercase", + "uppercase_latin1", ], - ids=["image_url", "file", "image_url_charset", "file_charset", "file_latin1", "image_url_uppercase"], ) def test_anthropic_messages_pt_text_plain_data_uri_becomes_text_source_document(block, expected_text): messages = [{"role": "user", "content": [{"type": "text", "text": "What is the code word?"}, block]}] From dfcfa0b043cbc0dcef492adae53e048afd214098 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:40:39 -0700 Subject: [PATCH 15/26] fix(anthropic): match text and pdf media types case-insensitively and detect downloaded pdfs by signature --- .../prompt_templates/factory.py | 13 ++++++----- ...llm_core_utils_prompt_templates_factory.py | 22 ++++++++++++++++--- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 746745cc791..bd22ce6ed91 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1852,7 +1852,7 @@ def _anthropic_text_document_source( media_type_with_parameters: str, data: str ) -> AnthropicContentParamSourceText | None: media_type, _, _ = media_type_with_parameters.partition(";") - if media_type.strip() != "text/plain": + if media_type.strip().lower() != "text/plain": return None content_type_header: Final = Message() content_type_header["content-type"] = media_type_with_parameters @@ -2338,7 +2338,9 @@ def _anthropic_native_tool_use_block( def anthropic_content_element_factory( image_chunk: GenericImageParsingChunk, ) -> AnthropicMessagesImageParam | AnthropicMessagesDocumentParam: - if image_chunk["media_type"].partition(";")[0].strip().lower() == "application/pdf": + if image_chunk["media_type"].partition(";")[0].strip().lower() == "application/pdf" or _base64_holds_a_pdf( + image_chunk["data"] + ): _anthropic_content_element: AnthropicMessagesDocumentParam | AnthropicMessagesImageParam = ( AnthropicMessagesDocumentParam( type="document", @@ -2365,9 +2367,10 @@ def anthropic_content_element_factory( def select_anthropic_content_block_type_for_file( format: str, ) -> Literal["document", "image", "container_upload"]: - if format == "application/pdf" or format == "text/plain": + media_type: Final = format.strip().lower() + if media_type == "application/pdf" or media_type == "text/plain": return "document" - elif format in ["image/jpeg", "image/png", "image/gif", "image/webp"]: + elif media_type in ["image/jpeg", "image/png", "image/gif", "image/webp"]: return "image" else: return "container_upload" @@ -2427,7 +2430,7 @@ def anthropic_process_openai_file_message( type="document", source=AnthropicContentParamSource( type="base64", - media_type=media_type.strip(), + media_type=media_type.strip().lower(), data=image_chunk["data"], ), ) 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 9cb574bada5..71946245034 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 @@ -5221,6 +5221,7 @@ _TEXT_DATA_URI_WITH_CHARSET = "data:text/plain;charset=utf-8;base64," + _TEXT_B6 _LATIN1_TEXT_DATA_URI = "data:text/plain;charset=iso-8859-1;base64," + base64.b64encode( "the secret code word is MANGÓ\n".encode("latin-1") ).decode() +_OCTET_TEXT_DATA_URI = "data:application/octet-stream;base64," + _TEXT_B64 @pytest.mark.parametrize( @@ -5239,6 +5240,15 @@ _LATIN1_TEXT_DATA_URI = "data:text/plain;charset=iso-8859-1;base64," + base64.b6 {"type": "image_url", "image_url": {"url": _LATIN1_TEXT_DATA_URI.replace("text/plain", "TEXT/PLAIN", 1)}}, "the secret code word is MANGÓ\n", ), + ({"type": "file", "file": {"file_data": "data:TEXT/PLAIN;base64," + _TEXT_B64}}, "the secret code word is MANGO\n"), + ( + {"type": "file", "file": {"file_data": _OCTET_TEXT_DATA_URI, "format": "Text/Plain"}}, + "the secret code word is MANGO\n", + ), + ( + {"type": "image_url", "image_url": {"url": _OCTET_TEXT_DATA_URI, "format": "Text/Plain"}}, + "the secret code word is MANGO\n", + ), ], ids=[ "image_url", @@ -5248,6 +5258,9 @@ _LATIN1_TEXT_DATA_URI = "data:text/plain;charset=iso-8859-1;base64," + base64.b6 "file_latin1", "image_url_uppercase", "uppercase_latin1", + "file_uppercase", + "file_mixed_case_format", + "image_url_mixed_case_format", ], ) def test_anthropic_messages_pt_text_plain_data_uri_becomes_text_source_document(block, expected_text): @@ -9436,13 +9449,16 @@ def test_anthropic_messages_pt_keeps_a_native_tool_use_answered_by_a_tool_messag ] -@pytest.mark.parametrize("pdf_format", (None, "Application/PDF")) -def test_create_anthropic_image_param_sends_a_downloaded_pdf_as_a_document(monkeypatch, pdf_format): +@pytest.mark.parametrize( + "pdf_format, content_type", + ((None, "application/pdf"), ("Application/PDF", "application/pdf"), (None, "image/png"), ("image/png", "application/pdf")), +) +def test_create_anthropic_image_param_sends_a_downloaded_pdf_as_a_document(monkeypatch, pdf_format, content_type): from litellm.litellm_core_utils.prompt_templates.image_handling import in_memory_cache pdf_bytes = b"%PDF-1.4\n1 0 obj\n" mock_client = MagicMock() - mock_client.get.return_value = httpx.Response(200, content=pdf_bytes, headers={"Content-Type": "application/pdf"}) + mock_client.get.return_value = httpx.Response(200, content=pdf_bytes, headers={"Content-Type": content_type}) monkeypatch.setattr(litellm, "user_url_validation", False, raising=False) monkeypatch.setattr(litellm, "module_level_client", mock_client, raising=False) in_memory_cache.flush_cache() 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 16/26] 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 From f18056a85bfb100b312f95cf5b117acb61e38a90 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:57:00 -0700 Subject: [PATCH 17/26] fix(count_tokens): keep a local conversion 400 as a 400 on the Claude count routes The anthropic, azure_ai and Bedrock Mantle count handlers re-raise a BadRequestError raised before the provider call, and their token counters mark it rejected_before_provider_call, so a framing deployment answers the 400 instead of an estimate that leaves the attachment out, and Mantle no longer turns it into a 500 --- .../llms/anthropic/count_tokens/handler.py | 4 +- .../anthropic/count_tokens/token_counter.py | 11 +++++ .../anthropic/count_tokens/handler.py | 4 +- .../anthropic/count_tokens/token_counter.py | 11 +++++ .../count_tokens/bedrock_token_counter.py | 6 +++ .../bedrock/count_tokens/mantle_handler.py | 2 +- ...t_anthropic_count_tokens_transformation.py | 30 -------------- .../test_bedrock_token_counter.py | 18 ++++++++ tests/unit/proxy/test_proxy_token_counter.py | 41 +++++++++++++++++++ 9 files changed, 92 insertions(+), 35 deletions(-) diff --git a/litellm/llms/anthropic/count_tokens/handler.py b/litellm/llms/anthropic/count_tokens/handler.py index 4de88a287de..1b9b31d8ec8 100644 --- a/litellm/llms/anthropic/count_tokens/handler.py +++ b/litellm/llms/anthropic/count_tokens/handler.py @@ -116,8 +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 litellm.BadRequestError: + raise 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/anthropic/count_tokens/token_counter.py b/litellm/llms/anthropic/count_tokens/token_counter.py index 91fc77773d1..65b6f56454b 100644 --- a/litellm/llms/anthropic/count_tokens/token_counter.py +++ b/litellm/llms/anthropic/count_tokens/token_counter.py @@ -143,6 +143,17 @@ class AnthropicTokenCounter(BaseTokenCounter): tokenizer_type="anthropic_api", original_response=result, ) + except litellm.BadRequestError as e: + return TokenCountResponse( + total_tokens=0, + request_model=request_model, + model_used=model_to_use, + tokenizer_type="anthropic_api", + error=True, + error_message=e.message, + status_code=400, + rejected_before_provider_call=True, + ) except (AnthropicError, AuthenticationError) as e: verbose_logger.warning("Anthropic CountTokens error: status=%s, message=%s", e.status_code, e.message) return TokenCountResponse( diff --git a/litellm/llms/azure_ai/anthropic/count_tokens/handler.py b/litellm/llms/azure_ai/anthropic/count_tokens/handler.py index b443ccc7713..f4d426bc47b 100644 --- a/litellm/llms/azure_ai/anthropic/count_tokens/handler.py +++ b/litellm/llms/azure_ai/anthropic/count_tokens/handler.py @@ -128,8 +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 litellm.BadRequestError: + raise 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/token_counter.py b/litellm/llms/azure_ai/anthropic/count_tokens/token_counter.py index af7cf9d81ce..da5ecc6c5db 100644 --- a/litellm/llms/azure_ai/anthropic/count_tokens/token_counter.py +++ b/litellm/llms/azure_ai/anthropic/count_tokens/token_counter.py @@ -147,6 +147,17 @@ class AzureAIAnthropicTokenCounter(BaseTokenCounter): "original_response": result, } ) + except litellm.BadRequestError as e: + return TokenCountResponse( + total_tokens=0, + request_model=request_model, + model_used=model_to_use, + tokenizer_type="azure_ai_anthropic_api", + error=True, + error_message=e.message, + status_code=400, + rejected_before_provider_call=True, + ) except AnthropicError as e: verbose_logger.warning( "Azure AI Anthropic CountTokens API error: status=%s, message=%s", e.status_code, e.message diff --git a/litellm/llms/bedrock/count_tokens/bedrock_token_counter.py b/litellm/llms/bedrock/count_tokens/bedrock_token_counter.py index 26bc5904e06..743eeaa014f 100644 --- a/litellm/llms/bedrock/count_tokens/bedrock_token_counter.py +++ b/litellm/llms/bedrock/count_tokens/bedrock_token_counter.py @@ -49,6 +49,7 @@ class CountTokensFailure: status_code: int message: str tokenizer_type: str + rejected_before_provider_call: bool = False CountTokensOutcome = CountedTokens | CountTokensFailure @@ -120,6 +121,10 @@ class BedrockTokenCounter(BaseTokenCounter): client=self._client, ) reply: Final = _CountTokensReply.model_validate(result) + except litellm.BadRequestError as e: + return CountTokensFailure( + status_code=400, message=e.message, tokenizer_type=tokenizer_type, rejected_before_provider_call=True + ) except BedrockError as e: verbose_logger.debug( "%s CountTokens API error: status=%s, message=%s", tokenizer_type, e.status_code, e.message @@ -214,6 +219,7 @@ class BedrockTokenCounter(BaseTokenCounter): error=True, error_message=outcome.message, status_code=outcome.status_code, + rejected_before_provider_call=outcome.rejected_before_provider_call, ) case _: assert_never(outcome) diff --git a/litellm/llms/bedrock/count_tokens/mantle_handler.py b/litellm/llms/bedrock/count_tokens/mantle_handler.py index 5cd26235ba2..d321f6b394b 100644 --- a/litellm/llms/bedrock/count_tokens/mantle_handler.py +++ b/litellm/llms/bedrock/count_tokens/mantle_handler.py @@ -99,7 +99,7 @@ class BedrockMantleCountTokensHandler(BaseAWSLLM): response=response, ) return _COUNT_RESPONSE.validate_json(response.content) - except BedrockError: + except (BedrockError, litellm.BadRequestError): raise except httpx.HTTPStatusError as e: raise BedrockError( 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 80f760b5243..0884969b0c6 100644 --- a/tests/unit/llms/anthropic/test_anthropic_count_tokens_transformation.py +++ b/tests/unit/llms/anthropic/test_anthropic_count_tokens_transformation.py @@ -14,7 +14,6 @@ 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 ( @@ -526,35 +525,6 @@ 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 diff --git a/tests/unit/llms/bedrock/count_tokens/test_bedrock_token_counter.py b/tests/unit/llms/bedrock/count_tokens/test_bedrock_token_counter.py index cec7950b859..075e05c586b 100644 --- a/tests/unit/llms/bedrock/count_tokens/test_bedrock_token_counter.py +++ b/tests/unit/llms/bedrock/count_tokens/test_bedrock_token_counter.py @@ -293,6 +293,24 @@ async def test_claude_count_rejects_a_lone_responses_tool_without_calling_bedroc assert bedrock.posted_hosts() == () +@pytest.mark.asyncio +async def test_claude_count_rejects_a_text_attachment_it_cannot_decode_without_calling_bedrock() -> None: + bedrock: Final = _Bedrock(runtime=(200, {"inputTokens": 412}), mantle=(200, {"input_tokens": 2177})) + text_attachment: Final = "data:text/plain;charset=bogus-cs;base64," + base64.b64encode(b"hello").decode() + + result: Final = await _counter(bedrock).count_tokens( + model_to_use="bedrock_mantle/anthropic.claude-opus-5-5", + messages=[{"role": "user", "content": [{"type": "image_url", "image_url": {"url": text_attachment}}]}], + contents=None, + deployment=DEPLOYMENT, + ) + + assert result is not None + assert (result.error, result.status_code, result.rejected_before_provider_call) == (True, 400, True) + assert "bogus-cs" in (result.error_message or "") + assert bedrock.posted_hosts() == () + + _CLAUDE_REPLY: Final = { "id": "msg_1", "type": "message", diff --git a/tests/unit/proxy/test_proxy_token_counter.py b/tests/unit/proxy/test_proxy_token_counter.py index 43c017468ff..6caa41e9995 100644 --- a/tests/unit/proxy/test_proxy_token_counter.py +++ b/tests/unit/proxy/test_proxy_token_counter.py @@ -2040,6 +2040,47 @@ async def test_framing_keeps_the_400_for_blocks_the_provider_counter_cannot_conv assert "search_result" in raised.value.message +@pytest.mark.parametrize( + "litellm_params", + [ + {"model": "anthropic/claude-opus-5-5", "api_key": "sk-unused-framed"}, + { + "model": "azure_ai/claude-opus-5-5", + "api_key": "sk-unused-framed", + "api_base": "https://framed.services.ai.azure.com/anthropic", + }, + ], + ids=["anthropic", "azure_ai"], +) +@pytest.mark.asyncio +async def test_framing_keeps_the_400_for_a_text_attachment_the_claude_counter_cannot_decode( + monkeypatch: pytest.MonkeyPatch, litellm_params: dict[str, str] +): + monkeypatch.setattr( + litellm.proxy.proxy_server, + "llm_router", + Router( + model_list=[ + {"model_name": "framed", "litellm_params": litellm_params, "model_info": {"token_count_framing": 100}} + ] + ), + ) + text_attachment: Final = "data:text/plain;charset=bogus-cs;base64," + base64.b64encode(b"hello").decode() + + with pytest.raises(ProxyException) as raised: + await count_request_tokens( + request=TokenCountRequest( + model="framed", + messages=[{"role": "user", "content": [{"type": "image_url", "image_url": {"url": text_attachment}}]}], + ), + call_endpoint=True, + messages_format="anthropic", + ) + + assert raised.value.code == "400" + assert "bogus-cs" in raised.value.message + + @pytest.mark.asyncio @pytest.mark.parametrize( "unreadable_body", From d0bb701c34acb201a7012e0de3a89b89fc079860 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:24:04 -0700 Subject: [PATCH 18/26] fix(anthropic): point the text attachment decode 400 at a file block's format A downloaded text file reaches the decoder without its server's charset since main strips it, so the old hint to name the charset in the data URI could not help a URL caller. A file block's format takes text/plain;charset=... for both URLs and data URIs --- .../prompt_templates/factory.py | 3 ++- ...llm_core_utils_prompt_templates_factory.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 994ed905a94..8c4afddc944 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1856,7 +1856,8 @@ def _anthropic_text_document_source( except (LookupError, UnicodeDecodeError) as decode_error: raise litellm.BadRequestError( message=f"Text attachment could not be read as {charset!r}: {decode_error}. " - "Send it as utf-8, or name its encoding in the data URI (data:text/plain;charset=...).", + "Send it as utf-8, or name its encoding (text/plain;charset=...) in the data URI " + "or in a file block's format.", model=None, llm_provider="anthropic", ) from decode_error 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 db23b60da28..ef45fd03f19 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 @@ -9389,6 +9389,25 @@ def test_anthropic_messages_pt_rejects_a_text_attachment_it_cannot_decode(): assert "utf-8" in str(raised.value) assert undeclared_latin1.split(",", 1)[1] not in str(raised.value) + assert "file block's format" in str(raised.value) + + +def test_anthropic_messages_pt_reads_a_text_file_block_in_the_charset_its_format_names(): + undeclared_latin1: Final = "data:text/plain;base64," + base64.b64encode("caf\xe9".encode("iso-8859-1")).decode() + messages: Final = [ + { + "role": "user", + "content": [ + {"type": "file", "file": {"file_data": undeclared_latin1, "format": "text/plain;charset=iso-8859-1"}} + ], + } + ] + + result: Final = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") + + assert result[0]["content"] == [ + {"type": "document", "source": {"type": "text", "media_type": "text/plain", "data": "café"}} + ] @pytest.mark.parametrize("native_input", [{"city": "lyon"}, "[1, 2]"]) From 735ace429bdfe9f4f4289755302cf75b09ff1c02 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:05:26 -0700 Subject: [PATCH 19/26] fix(anthropic): send number and boolean tool results as JSON instead of an empty string Snowflake sent json.dumps of a non-string tool result before it moved onto the shared transform, which then sent an empty string for a number or boolean, so Claude lost the answer. Mapping results were already serialized; numbers and booleans now take the same path, and None stays empty as it was on Anthropic --- litellm/litellm_core_utils/prompt_templates/factory.py | 2 +- .../test_litellm_core_utils_prompt_templates_factory.py | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 8c4afddc944..4d4366e0dfe 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1935,7 +1935,7 @@ def convert_to_anthropic_tool_result( ) = "" if isinstance(message["content"], str): anthropic_content = message["content"] - elif isinstance(message["content"], Mapping): + elif isinstance(message["content"], (Mapping, int, float)): anthropic_content = json.dumps(message["content"]) elif isinstance(message["content"], list): content_list: Final = message["content"] 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 ef45fd03f19..c7134c18676 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 @@ -3191,17 +3191,16 @@ def test_convert_to_anthropic_tool_result_image_with_cache_control(): assert result["content"][1]["cache_control"]["type"] == "ephemeral" -def test_convert_to_anthropic_tool_result_serializes_mapping_content(): +@pytest.mark.parametrize("tool_output", [{"result_key": "result_value"}, 42, 0, 3.5, True, False]) +def test_convert_to_anthropic_tool_result_serializes_non_string_content(tool_output): from litellm.litellm_core_utils.prompt_templates.factory import ( convert_to_anthropic_tool_result, ) - result = convert_to_anthropic_tool_result( - {"role": "tool", "tool_call_id": "call_dict", "content": {"result_key": "result_value"}} - ) + result = convert_to_anthropic_tool_result({"role": "tool", "tool_call_id": "call_value", "content": tool_output}) assert result["type"] == "tool_result" - assert json.loads(result["content"]) == {"result_key": "result_value"} + assert json.loads(result["content"]) == tool_output def test_convert_to_anthropic_tool_result_image_without_cache_control(): From 0d1885414fb127257a6e25600ef7538518413d11 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:39:22 -0700 Subject: [PATCH 20/26] fix(anthropic): send an empty tool result when a tool message has null or missing content --- .../prompt_templates/factory.py | 13 +++++++------ ...tellm_core_utils_prompt_templates_factory.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 4d4366e0dfe..9621373913d 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1933,12 +1933,13 @@ def convert_to_anthropic_tool_result( | ToolReference ] ) = "" - if isinstance(message["content"], str): - anthropic_content = message["content"] - elif isinstance(message["content"], (Mapping, int, float)): - anthropic_content = json.dumps(message["content"]) - elif isinstance(message["content"], list): - content_list: Final = message["content"] + tool_content: Final = message.get("content") + if isinstance(tool_content, str): + anthropic_content = tool_content + elif isinstance(tool_content, (Mapping, int, float)): + anthropic_content = json.dumps(tool_content) + elif isinstance(tool_content, list): + content_list: Final = tool_content anthropic_content_list: list[ AnthropicMessagesToolResultContent | AnthropicMessagesImageParam 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 c7134c18676..32e90965e8e 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 @@ -3203,6 +3203,23 @@ def test_convert_to_anthropic_tool_result_serializes_non_string_content(tool_out assert json.loads(result["content"]) == tool_output +@pytest.mark.parametrize( + "tool_message", + [ + {"role": "tool", "tool_call_id": "call_value", "content": None}, + {"role": "tool", "tool_call_id": "call_value"}, + ], +) +def test_convert_to_anthropic_tool_result_sends_an_empty_result_for_null_or_missing_content(tool_message): + from litellm.litellm_core_utils.prompt_templates.factory import ( + convert_to_anthropic_tool_result, + ) + + result = convert_to_anthropic_tool_result(tool_message) + + assert result == {"type": "tool_result", "tool_use_id": "call_value", "content": ""} + + def test_convert_to_anthropic_tool_result_image_without_cache_control(): """ Test that images without cache_control in tool results work correctly. From 7d5bce3aec69440a375b58b18ffb1c627428b8db Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:03:54 -0700 Subject: [PATCH 21/26] fix(anthropic): only decode text attachments in standard text encodings --- .../prompt_templates/factory.py | 36 ++++++++++++++++++- ...llm_core_utils_prompt_templates_factory.py | 24 +++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 9621373913d..ffb512f63bd 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1,4 +1,5 @@ import base64 +import codecs import copy import hashlib import itertools @@ -1842,6 +1843,39 @@ def anthropic_document_media_type_for_data_uri(url: str, format_override: str | return declared if declared in _ANTHROPIC_DOCUMENT_BASE64_MEDIA_TYPES else None +_TEXT_ATTACHMENT_ENCODINGS: Final = frozenset( + codecs.lookup(label).name + for label in ( + "utf-8", + "utf-16", + "utf-16-le", + "utf-16-be", + "ascii", + "latin-1", + *(f"iso8859-{part}" for part in (2, 3, 4, 5, 6, 7, 8, 10, 13, 14, 15, 16)), + *(f"cp{code_page}" for code_page in (866, 874, *range(1250, 1259))), + "koi8-r", + "koi8-u", + "mac-roman", + "mac-cyrillic", + "gbk", + "gb18030", + "big5", + "euc-jp", + "iso2022-jp", + "shift-jis", + "euc-kr", + ) +) + + +def _text_attachment_encoding(charset: str) -> str: + encoding: Final = codecs.lookup(charset).name + if encoding not in _TEXT_ATTACHMENT_ENCODINGS: + raise LookupError(f"unsupported encoding: {charset}") + return encoding + + def _anthropic_text_document_source( media_type_with_parameters: str, data: str ) -> AnthropicContentParamSourceText | None: @@ -1852,7 +1886,7 @@ def _anthropic_text_document_source( content_type_header["content-type"] = media_type_with_parameters charset: Final = content_type_header.get_content_charset("utf-8") try: - text: Final = base64.b64decode(data).decode(charset) + text: Final = base64.b64decode(data).decode(_text_attachment_encoding(charset)) except (LookupError, UnicodeDecodeError) as decode_error: raise litellm.BadRequestError( message=f"Text attachment could not be read as {charset!r}: {decode_error}. " 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 32e90965e8e..0ba858e9f3f 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 @@ -9218,6 +9218,30 @@ def test_anthropic_messages_pt_text_document_keeps_charset_named_by_the_data_uri ] +@pytest.mark.parametrize( + ("charset", "text"), + [("shift_jis", "日本語"), ("windows-1252", "café"), ("KOI8-R", "привет"), ("utf-16", "naïve")], +) +def test_anthropic_messages_pt_reads_a_text_attachment_in_a_legacy_encoding_its_data_uri_names(charset, text): + data_uri: Final = f"data:text/plain;charset={charset};base64," + base64.b64encode(text.encode(charset)).decode() + messages = [{"role": "user", "content": [{"type": "file", "file": {"file_data": data_uri}}]}] + + result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") + + assert result[0]["content"] == [ + {"type": "document", "source": {"type": "text", "media_type": "text/plain", "data": text}} + ] + + +@pytest.mark.parametrize("charset", ["punycode", "utf-7"]) +def test_anthropic_messages_pt_rejects_a_text_attachment_charset_that_is_not_a_supported_text_encoding(charset): + data_uri: Final = f"data:text/plain;charset={charset};base64," + base64.b64encode(b"abc").decode() + messages = [{"role": "user", "content": [{"type": "file", "file": {"file_data": data_uri}}]}] + + with pytest.raises(litellm.BadRequestError, match=f"unsupported encoding: {charset}"): + anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") + + def test_anthropic_messages_pt_native_tool_use_input_survives_guardrail_redaction(): messages = [ {"role": "user", "content": "hi"}, From fb753f003da3fef563f0063ef7a0adcea8db71e4 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:27:55 -0700 Subject: [PATCH 22/26] fix(anthropic): keep the request out of deliberate translation 400s on Claude routes A BadRequestError raised by the shared message translation already names its problem, so it no longer carries every message as Received Messages. A 700 KB text attachment with an unsupported charset otherwise answered a 700 KB 400, and redacting that text held the event loop for about 40 s through the proxy. --- litellm/llms/anthropic/chat/transformation.py | 2 ++ .../test_anthropic_chat_transformation.py | 25 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index f03004cec15..295526ef9c5 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -2384,6 +2384,8 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): llm_provider=self._resolved_provider, drop_params=drop_params, ) + except litellm.BadRequestError as e: + raise AnthropicError(status_code=400, message=str(e)) except Exception as e: raise AnthropicError( status_code=400, diff --git a/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py index c8a2f783809..8b84c42dc4d 100644 --- a/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -1,3 +1,4 @@ +import base64 import copy import json import logging @@ -10103,3 +10104,27 @@ def test_claude_cache_control_cap_does_not_count_a_message_level_hint_that_list_ assert sorted(_cached_texts(body)) == ["system prompt", "turn 1", "turn 2", "turn 3"] assert json.dumps(body).count('"cache_control"') == 4 + + +def test_completion_rejecting_a_text_attachment_does_not_echo_the_request_back(respx_mock: respx.MockRouter): + route: Final = respx_mock.post("https://api.anthropic.com/v1/messages").mock(return_value=httpx.Response(500)) + attachment_data: Final = base64.b64encode(b"attachment body that stays out of the error " * 64).decode() + + with pytest.raises(litellm.BadRequestError) as exc_info: + litellm.completion( + model="anthropic/claude-sonnet-4-5-20250929", + api_key="mock_api_key", + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "Summarize this file"}, + {"type": "file", "file": {"file_data": f"data:text/plain;charset=punycode;base64,{attachment_data}"}}, + ], + } + ], + ) + + assert "unsupported encoding: punycode" in str(exc_info.value) + assert attachment_data not in str(exc_info.value) + assert route.call_count == 0 From 1fecc7a8f8ec44d39ed0c6b6cf5efe48b95104e0 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:16:40 -0700 Subject: [PATCH 23/26] test(snowflake): expect a string system prompt's cache_control mark on Snowflake Claude Snowflake Claude now builds its system prompt with the shared Anthropic transform, which keeps a message-level cache_control mark on a string system prompt the way the anthropic, vertex, and bedrock routes do. The base dropped that mark on Snowflake alone while it already sent the same block shape for marked system content lists. --- .../test_responses_bridge_message_cache_control_wire.py | 2 +- .../snowflake/chat/test_snowflake_chat_transformation.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/integration/providers/test_responses_bridge_message_cache_control_wire.py b/tests/integration/providers/test_responses_bridge_message_cache_control_wire.py index 4b25275285e..1d3551fe549 100644 --- a/tests/integration/providers/test_responses_bridge_message_cache_control_wire.py +++ b/tests/integration/providers/test_responses_bridge_message_cache_control_wire.py @@ -962,10 +962,10 @@ def _shaped_requests(model: str, shape: str) -> tuple[dict[str, JsonValue], dict pytest.param(f"openrouter/anthropic/{ANTHROPIC_MODEL}", "system", True, id="openrouter-claude"), pytest.param("openrouter/google/gemini-3.8-flash", "system", True, id="openrouter-gemini"), pytest.param("snowflake/claude-4-sonnet", "tool", True, id="snowflake-claude-tool-output"), + pytest.param("snowflake/claude-4-sonnet", "system", True, id="snowflake-claude-string-system"), pytest.param("vertex-claude", "system", True, id="vertex-claude"), pytest.param("sap-claude", "system", True, id="sap-claude"), pytest.param("anthropic/my-gateway-model", "system", True, id="anthropic-non-claude-name"), - pytest.param("snowflake/claude-4-sonnet", "system", False, id="snowflake-claude-string-system"), pytest.param("openrouter/openai/gpt-5.4", "system", False, id="openrouter-gpt"), pytest.param("snowflake/llama3.1-70b", "system", False, id="snowflake-llama"), pytest.param("snowflake/llama-claude-distill", "system", False, id="snowflake-claude-inside-the-name"), diff --git a/tests/unit/llms/snowflake/chat/test_snowflake_chat_transformation.py b/tests/unit/llms/snowflake/chat/test_snowflake_chat_transformation.py index a51174c90c3..f907e92e4ab 100644 --- a/tests/unit/llms/snowflake/chat/test_snowflake_chat_transformation.py +++ b/tests/unit/llms/snowflake/chat/test_snowflake_chat_transformation.py @@ -359,6 +359,15 @@ class TestSnowflakeCortexClaudeFixes: ) assert body["system"] == [{"type": "text", "text": "You are helpful", "cache_control": {"type": "ephemeral"}}] + def test_string_system_prompt_keeps_its_message_cache_control_without_ttl(self): + body = self._transform( + [ + {"role": "system", "content": "You are helpful", "cache_control": {"type": "ephemeral", "ttl": "1h"}}, + {"role": "user", "content": "hi"}, + ] + ) + assert body["system"] == [{"type": "text", "text": "You are helpful", "cache_control": {"type": "ephemeral"}}] + def test_direct_system_param_is_normalized(self): body = self._transform( [{"role": "user", "content": "hi"}], From 488f3d295e4823c91289e30ab98061d824043d8e Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:55:32 -0700 Subject: [PATCH 24/26] fix(anthropic): drop blank text beside an answered native tool_use instead of sending the placeholder The empty-text sanitizer only counted tool_calls, thinking, server_tool_use and tool result blocks as content that survives the assistant converter, so a blank text part next to a replayed native tool_use became the protocol placeholder and reached Claude as assistant text. A native tool_use now counts when the next turn answers it, the same check the converter uses before keeping it, so the blank part is dropped there and an unanswered tool_use still gets the placeholder, since the converter drops it and the turn would otherwise be empty --- .../prompt_templates/factory.py | 38 +++++++++++++------ ...llm_core_utils_prompt_templates_factory.py | 34 +++++++++++++++++ .../anthropic/test_message_sanitization.py | 2 +- 3 files changed, 61 insertions(+), 13 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 2f8441fe0b6..39a8099955b 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -2598,13 +2598,14 @@ def _with_placeholder_text(block: object) -> object: return {**fields, "text": _EMPTY_TEXT_PLACEHOLDER} -def _survives_assistant_conversion(block: object) -> bool: +def _survives_assistant_conversion(block: object, answered: Callable[[], frozenset[str]]) -> bool: """Whether ``anthropic_messages_pt`` keeps this list-content block of an assistant turn. Mirrors the assistant converter: a ``thinking`` / ``redacted_thinking`` block is kept - unless it is unsignable (a signed block is kept even with empty text), and - ``server_tool_use`` and ``*_tool_result`` blocks pass through. A redacted block with - blank data is dropped under ``drop_params``, which is not known here, so it never counts. + unless it is unsignable (a signed block is kept even with empty text), + ``server_tool_use`` and ``*_tool_result`` blocks pass through, and a native ``tool_use`` + is kept when the next turn answers it. A redacted block with blank data is dropped under + ``drop_params``, which is not known here, so it never counts. """ fields: Final = _dict_block(block) if fields is None: @@ -2614,6 +2615,8 @@ def _survives_assistant_conversion(block: object) -> bool: match fields: case {"type": "server_tool_use"}: return True + case {"type": "tool_use"}: + return tool_use_is_answered(native_tool_use_id(fields), answered()) case {"type": str() as block_type}: return block_type.endswith("_tool_result") case _: @@ -2635,12 +2638,16 @@ def _tool_call_survives_conversion(tool_call: object) -> bool: return True -def _carries_non_text_content(message: AllMessageValues) -> bool: +def _carries_non_text_content(message: AllMessageValues, answered: Callable[[], frozenset[str]]) -> bool: if any(_tool_call_survives_conversion(tool_call) for tool_call in parts_of(message_field(message, "tool_calls"))): return True content: Final = message_field(message, "content") blocks: Final = parts_of(content) - return isinstance(content, list) and any(_survives_assistant_conversion(block) for block in blocks) + return isinstance(content, list) and any(_survives_assistant_conversion(block, answered) for block in blocks) + + +def _answered_at(answered_by_index: Callable[[], Mapping[int, frozenset[str]]], index: int) -> frozenset[str]: + return answered_by_index().get(index, frozenset()) def _with_content(message: AllMessageValues, content: object, action: str) -> AllMessageValues: @@ -2651,6 +2658,7 @@ def _with_content(message: AllMessageValues, content: object, action: str) -> Al def _sanitize_empty_text_content( message: AllMessageValues, + answered: Callable[[], frozenset[str]], ) -> AllMessageValues: """ Case C: Sanitize empty text content @@ -2658,8 +2666,8 @@ def _sanitize_empty_text_content( Anthropic rejects empty text blocks, so empty or whitespace-only text must never be forwarded verbatim. What replaces it depends on what else the turn carries. - Assistant turns that already carry substantive content (``tool_calls``, or non-text - blocks such as ``server_tool_use``) get the empty text dropped. Anthropic accepts an + Assistant turns that already carry substantive content (``tool_calls``, an answered + ``tool_use``, or other non-text blocks such as ``server_tool_use``) get the empty text dropped. Anthropic accepts an assistant turn whose content is just ``tool_use``, so there is nothing to substitute, and substituting anyway leaks a protocol diagnostic into the conversation as though the assistant had said it, which the model then echoes on later turns. @@ -2674,7 +2682,9 @@ def _sanitize_empty_text_content( return message content: Final = message_field(message, "content") - drop_empty_text: Final = message_field(message, "role") == "assistant" and _carries_non_text_content(message) + drop_empty_text: Final = message_field(message, "role") == "assistant" and _carries_non_text_content( + message, answered + ) if isinstance(content, str): if content.strip(): @@ -2960,6 +2970,7 @@ def _pair_tool_calls_with_results(messages: Sequence[AllMessageValues]) -> tuple latest_assistant_tool_call_ids: Final = tuple( itertools.accumulate(messages, _tool_call_ids_of_the_latest_assistant, initial=frozenset[object]()) ) + answered_by_index: Final = cache(partial(answered_ids_by_index, messages)) sanitized_messages: list[AllMessageValues] = [] i = 0 @@ -2967,7 +2978,7 @@ def _pair_tool_calls_with_results(messages: Sequence[AllMessageValues]) -> tuple current_message = messages[i] # Case C: Sanitize empty text content - current_message = _sanitize_empty_text_content(current_message) + current_message = _sanitize_empty_text_content(current_message, partial(_answered_at, answered_by_index, i)) # Case A: Check if assistant message has tool_calls without following tool results if current_message.get("role") == "assistant": @@ -3203,7 +3214,11 @@ def anthropic_messages_pt( # request will always 400 otherwise. The richer tool-call sanitization # (Cases A/B/D in `sanitize_messages_for_tool_calling`) remains gated on # `modify_params` because it actually mutates conversation structure. - messages = [_sanitize_empty_text_content(m) for m in messages] + answered_by_assistant_index: Final = cache(partial(answered_ids_by_index, messages)) + messages = [ + _sanitize_empty_text_content(m, partial(_answered_at, answered_by_assistant_index, i)) + for i, m in enumerate(messages) + ] replaced_server_tool_answers: Final = server_tool_answers_replaced_by_their_result(messages) client_answered_tool_ids: Final = _tool_call_ids_in_tool_messages(messages) - replaced_server_tool_answers @@ -3225,7 +3240,6 @@ def anthropic_messages_pt( inline_base64_media: Final = force_base64 or requires_inline_base64_media(model, llm_provider) tool_result_later_in_turn: Final = _tool_result_later_in_turn(messages, replaced_server_tool_answers) - answered_by_assistant_index: Final = cache(partial(answered_ids_by_index, messages)) msg_i = 0 while msg_i < len(messages): user_content: list[AnthropicMessagesUserMessageValues] = [] 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 cd02b28c293..a3e802e97a4 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 @@ -9438,6 +9438,40 @@ def test_anthropic_messages_pt_drops_a_native_tool_use_nobody_answered(): assert result[1]["content"] == [{"type": "text", "text": "checking"}] +def _empty_text_beside_native_tool_use(follow_up: dict[str, object]) -> list[dict[str, object]]: + return [ + {"role": "user", "content": "what is the weather"}, + { + "role": "assistant", + "content": [ + {"type": "text", "text": " "}, + {"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {}}, + ], + }, + follow_up, + ] + + +@pytest.mark.parametrize("modify_params", [False, True]) +def test_anthropic_messages_pt_drops_empty_text_beside_an_answered_native_tool_use(monkeypatch, modify_params): + monkeypatch.setattr(litellm, "modify_params", modify_params) + messages: Final = _empty_text_beside_native_tool_use({"role": "tool", "tool_call_id": "toolu_01", "content": "sunny"}) + + result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") + + assert result[1]["content"] == [{"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {}}] + + +@pytest.mark.parametrize("modify_params", [False, True]) +def test_anthropic_messages_pt_keeps_the_placeholder_beside_an_unanswered_native_tool_use(monkeypatch, modify_params): + monkeypatch.setattr(litellm, "modify_params", modify_params) + messages: Final = _empty_text_beside_native_tool_use({"role": "user", "content": "never mind"}) + + result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic") + + assert result[1]["content"] == [{"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER}] + + def _answered_native_tool_use(tool_use: dict[str, object]) -> list[dict[str, object]]: return [ {"role": "user", "content": "what is the weather"}, diff --git a/tests/unit/llms/anthropic/test_message_sanitization.py b/tests/unit/llms/anthropic/test_message_sanitization.py index 0afd443d07a..1a0007ba1e1 100644 --- a/tests/unit/llms/anthropic/test_message_sanitization.py +++ b/tests/unit/llms/anthropic/test_message_sanitization.py @@ -449,7 +449,7 @@ class TestMessageSanitization: ], } - sanitized = _sanitize_empty_text_content(message) + sanitized = _sanitize_empty_text_content(message, frozenset) assert [b["type"] for b in sanitized["content"]] == ["thinking"] assert PLACEHOLDER not in json.dumps(sanitized) From 4681fbd56056a8b8c0bc1e9dd2320962a6c95c33 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:13:13 -0700 Subject: [PATCH 25/26] fix(anthropic): name a non-string role in a deliberate translation 400, as main does Main's #49237 answers 400 naming the first non-string role whatever error the conversion raised. This PR's handler for deliberate translation 400s ran first and skipped that check, so an int or bool role answered "Invalid Message passed in" at the merged tip. The handler now names the role first and keeps its own message otherwise. Main's test for a dropped tool answer id used object tool call arguments as its later failure, which this PR accepts on purpose, so that case now uses list arguments, which still fail. --- litellm/llms/anthropic/chat/transformation.py | 2 +- .../anthropic/chat/test_anthropic_chat_transformation.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 01521ba1905..1cd20291149 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -2468,7 +2468,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): drop_params=drop_params, ) except litellm.BadRequestError as e: - raise AnthropicError(status_code=400, message=str(e)) + raise non_string_message_field_error(messages, e) or AnthropicError(status_code=400, message=str(e)) except Exception as e: conversion_field_error: Final = non_string_message_field_error(messages, e) raise conversion_field_error or AnthropicError( diff --git a/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py index f429306a3b1..d084c02adad 100644 --- a/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -10219,14 +10219,14 @@ def test_chat_transform_request_keeps_a_conversion_error_that_a_dropped_orphan_a "tool_calls": [ { **_WEATHER_CALL["tool_calls"][0], - "function": {"name": "get_weather", "arguments": {"city": "Paris"}}, + "function": {"name": "get_weather", "arguments": ["Paris"]}, } ], }, {"role": "tool", "tool_call_id": "toolu_01", "content": "rain"}, ], - "'dict' object has no attribute 'strip'", - id="object_tool_call_arguments", + "'list' object has no attribute 'strip'", + id="list_tool_call_arguments", ), pytest.param( [{"role": "user", "content": [{"type": "image_url", "image_url": ["x"]}]}], From 21d08c8482104d672eaf92ce4e3721b6e2c627d8 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sun, 1 Nov 2026 09:14:06 -0800 Subject: [PATCH 26/26] fix(anthropic): decode text attachments under every WHATWG label Python knows The allowlist kept Python codec names for one label per WHATWG encoding, so labels that Python maps to a codec of its own (iso-8859-9, iso-8859-11, tis-620, gb2312, big5-hkscs, ms932) answered 400 instead of decoding. All six are linear table codecs, so the punycode and utf-7 rejection stands --- .../litellm_core_utils/prompt_templates/factory.py | 6 +++++- ...t_litellm_core_utils_prompt_templates_factory.py | 13 ++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 1170dca53d8..3ee61a9a94a 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1868,18 +1868,22 @@ _TEXT_ATTACHMENT_ENCODINGS: Final = frozenset( "utf-16-be", "ascii", "latin-1", - *(f"iso8859-{part}" for part in (2, 3, 4, 5, 6, 7, 8, 10, 13, 14, 15, 16)), + *(f"iso8859-{part}" for part in (*range(2, 12), *range(13, 17))), + "tis-620", *(f"cp{code_page}" for code_page in (866, 874, *range(1250, 1259))), "koi8-r", "koi8-u", "mac-roman", "mac-cyrillic", + "gb2312", "gbk", "gb18030", "big5", + "big5hkscs", "euc-jp", "iso2022-jp", "shift-jis", + "cp932", "euc-kr", ) ) 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 a3e802e97a4..c8d53c12aaf 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 @@ -9383,7 +9383,18 @@ def test_anthropic_messages_pt_text_document_keeps_charset_named_by_the_data_uri @pytest.mark.parametrize( ("charset", "text"), - [("shift_jis", "日本語"), ("windows-1252", "café"), ("KOI8-R", "привет"), ("utf-16", "naïve")], + [ + ("shift_jis", "日本語"), + ("windows-1252", "café"), + ("KOI8-R", "привет"), + ("utf-16", "naïve"), + ("iso-8859-9", "İstanbul"), + ("iso-8859-11", "ไทย"), + ("tis-620", "ไทย"), + ("gb2312", "中文"), + ("big5-hkscs", "香港"), + ("ms932", "日本語"), + ], ) def test_anthropic_messages_pt_reads_a_text_attachment_in_a_legacy_encoding_its_data_uri_names(charset, text): data_uri: Final = f"data:text/plain;charset={charset};base64," + base64.b64encode(text.encode(charset)).decode()