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