fix(snowflake): answer 400 for user parts Cortex Claude cannot take, and link an https pdf with a pdf format as a document

This commit is contained in:
mateo-berri 2026-10-29 06:12:54 -07:00
parent 98812f15ad
commit 490922d8a9
4 changed files with 67 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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