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] 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"},