mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-28 06:23:07 +00:00
fix(anthropic): drop unanswered native tool_use, keep PDF bytes a document, and honor drop_params in the shared transform
Some checks failed
Terraform Modules / fmt, validate, test (aws) (push) Has been cancelled
Terraform Modules / fmt, validate, test (gcp) (push) Has been cancelled
LiteLLM Rust / rust-lint (push) Has been cancelled
LiteLLM Rust / rust-test (push) Has been cancelled
LiteLLM Rust / rust-wheel (push) Has been cancelled
Terraform Provider / gofmt, vet, build, test (push) Has been cancelled
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Has been cancelled
Some checks failed
Terraform Modules / fmt, validate, test (aws) (push) Has been cancelled
Terraform Modules / fmt, validate, test (gcp) (push) Has been cancelled
LiteLLM Rust / rust-lint (push) Has been cancelled
LiteLLM Rust / rust-test (push) Has been cancelled
LiteLLM Rust / rust-wheel (push) Has been cancelled
Terraform Provider / gofmt, vet, build, test (push) Has been cancelled
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Has been cancelled
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.
This commit is contained in:
parent
9c11499fe6
commit
ade0e427ea
9 changed files with 351 additions and 95 deletions
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue