From bd87086bfbc58e53552f6bbe40f3989e1e2cf460 Mon Sep 17 00:00:00 2001 From: Gennaro Malafronte <8798987+malafronte@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:53:34 +0200 Subject: [PATCH] fix(anthropic): address review feedback on think sanitization --- .../adapters/transformation.py | 304 ++++++++---------- .../messages/handler.py | 12 +- ...al_pass_through_adapters_transformation.py | 248 ++++++++------ ...erimental_pass_through_messages_handler.py | 36 +++ 4 files changed, 336 insertions(+), 264 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 9143d8c5739..8d7da5c6540 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -1,16 +1,11 @@ import copy import hashlib import json +from collections.abc import AsyncIterator from typing import ( TYPE_CHECKING, Any, - AsyncIterator, - Dict, - List, Literal, - Optional, - Tuple, - Union, cast, ) @@ -26,8 +21,7 @@ TOOL_NAME_PREFIX_LENGTH = OPENAI_MAX_TOOL_NAME_LENGTH - TOOL_NAME_HASH_LENGTH - def truncate_tool_name(name: str) -> str: - """ - Truncate tool names that exceed OpenAI's 64-character limit. + """Truncate tool names that exceed OpenAI's 64-character limit. Uses format: {55-char-prefix}_{8-char-hash} to avoid collisions when multiple tools have similar long names. @@ -37,6 +31,7 @@ def truncate_tool_name(name: str) -> str: Returns: The original name if <= 64 chars, otherwise truncated with hash + """ if len(name) <= OPENAI_MAX_TOOL_NAME_LENGTH: return name @@ -47,18 +42,18 @@ def truncate_tool_name(name: str) -> str: def create_tool_name_mapping( - tools: List[Dict[str, Any]], -) -> Dict[str, str]: - """ - Create a mapping of truncated tool names to original names. + tools: list[dict[str, Any]], +) -> dict[str, str]: + """Create a mapping of truncated tool names to original names. Args: tools: List of tool definitions with 'name' field Returns: Dict mapping truncated names to original names (only for truncated tools) + """ - mapping: Dict[str, str] = {} + mapping: dict[str, str] = {} for tool in tools: original_name = tool.get("name", "") truncated_name = truncate_tool_name(original_name) @@ -67,8 +62,6 @@ def create_tool_name_mapping( return mapping -from openai.types.chat.chat_completion_chunk import Choice as OpenAIStreamingChoice - from litellm.litellm_core_utils.prompt_templates.common_utils import ( parse_tool_call_arguments, ) @@ -118,6 +111,7 @@ from litellm.types.llms.openai import ( ChatCompletionUserMessage, ) from litellm.types.utils import Choices, ModelResponse, StreamingChoices, Usage +from openai.types.chat.chat_completion_chunk import Choice as OpenAIStreamingChoice from .streaming_iterator import AnthropicStreamWrapper @@ -129,11 +123,8 @@ class AnthropicAdapter: def __init__(self) -> None: pass - def translate_completion_input_params( - self, kwargs - ) -> Optional[ChatCompletionRequest]: - """ - Translate Anthropic request params to OpenAI format. + def translate_completion_input_params(self, kwargs) -> ChatCompletionRequest | None: + """Translate Anthropic request params to OpenAI format. - translate params, where needed - pass rest, as is @@ -146,9 +137,8 @@ class AnthropicAdapter: def translate_completion_input_params_with_tool_mapping( self, kwargs - ) -> Tuple[Optional[ChatCompletionRequest], Dict[str, str]]: - """ - Translate Anthropic request params to OpenAI format, returning tool name mapping. + ) -> tuple[ChatCompletionRequest | None, dict[str, str]]: + """Translate Anthropic request params to OpenAI format, returning tool name mapping. This method handles truncation of tool names that exceed OpenAI's 64-character limit. The mapping allows restoring original names when translating responses. @@ -156,8 +146,8 @@ class AnthropicAdapter: Returns: Tuple of (openai_request, tool_name_mapping) - tool_name_mapping maps truncated tool names back to original names - """ + """ ######################################################### # Validate required params ######################################################### @@ -191,16 +181,16 @@ class AnthropicAdapter: def translate_completion_output_params( self, response: ModelResponse, - tool_name_mapping: Optional[Dict[str, str]] = None, - ) -> Optional[AnthropicMessagesResponse]: - """ - Translate OpenAI response to Anthropic format. + tool_name_mapping: dict[str, str] | None = None, + ) -> AnthropicMessagesResponse | None: + """Translate OpenAI response to Anthropic format. Args: response: The OpenAI ModelResponse tool_name_mapping: Optional mapping of truncated tool names to original names. Used to restore original names for tools that exceeded OpenAI's 64-char limit. + """ return LiteLLMAnthropicMessagesAdapter().translate_openai_response_to_anthropic( response=response, @@ -211,15 +201,15 @@ class AnthropicAdapter: self, completion_stream: Any, model: str, - tool_name_mapping: Optional[Dict[str, str]] = None, - ) -> Union[AsyncIterator[bytes], None]: - """ - Translate OpenAI streaming response to Anthropic format. + tool_name_mapping: dict[str, str] | None = None, + ) -> AsyncIterator[bytes] | None: + """Translate OpenAI streaming response to Anthropic format. Args: completion_stream: The OpenAI streaming response model: The model name tool_name_mapping: Optional mapping of truncated tool names to original names. + """ anthropic_wrapper = AnthropicStreamWrapper( completion_stream=completion_stream, @@ -236,9 +226,8 @@ class LiteLLMAnthropicMessagesAdapter: ### FOR [BETA] `/v1/messages` endpoint support - def _extract_signature_from_tool_call(self, tool_call: Any) -> Optional[str]: - """ - Extract signature from a tool call's provider_specific_fields. + def _extract_signature_from_tool_call(self, tool_call: Any) -> str | None: + """Extract signature from a tool call's provider_specific_fields. Only checks provider_specific_fields, not thinking blocks. """ signature = None @@ -261,11 +250,9 @@ class LiteLLMAnthropicMessagesAdapter: return signature def _extract_signature_from_tool_use_content( - self, content: Dict[str, Any] - ) -> Optional[str]: - """ - Extract signature from a tool_use content block's provider_specific_fields. - """ + self, content: dict[str, Any] + ) -> str | None: + """Extract signature from a tool_use content block's provider_specific_fields.""" provider_specific_fields = content.get("provider_specific_fields", {}) if provider_specific_fields: return provider_specific_fields.get("signature") @@ -275,10 +262,9 @@ class LiteLLMAnthropicMessagesAdapter: self, source: Any, target: Any, - model: Optional[str], + model: str | None, ) -> None: - """ - Extract cache_control from source and add to target if it should be preserved. + """Extract cache_control from source and add to target if it should be preserved. This method accepts Any type to support both regular dicts and TypedDict objects. TypedDict objects (like ChatCompletionTextObject, ChatCompletionImageObject, etc.) @@ -289,6 +275,7 @@ class LiteLLMAnthropicMessagesAdapter: source: Dict or TypedDict containing potential cache_control field target: Dict or TypedDict to add cache_control to model: Model name to check if cache_control should be preserved + """ # TypedDict objects are dicts at runtime, so .get() works cache_control = ( @@ -303,12 +290,10 @@ class LiteLLMAnthropicMessagesAdapter: target["cache_control"] = cache_control # type: ignore[typeddict-item] else: # Fallback for non-dict objects (shouldn't happen in practice) - cast(Dict[str, Any], target)["cache_control"] = cache_control + cast(dict[str, Any], target)["cache_control"] = cache_control - def translatable_anthropic_params(self) -> List: - """ - Which anthropic params, we need to translate to the openai format. - """ + def translatable_anthropic_params(self) -> list: + """Which anthropic params, we need to translate to the openai format.""" return [ "messages", "metadata", @@ -319,9 +304,8 @@ class LiteLLMAnthropicMessagesAdapter: "output_format", ] - def _is_web_search_tool(self, tool: Dict[str, Any]) -> bool: - """ - Check if a tool is an Anthropic web search tool. + def _is_web_search_tool(self, tool: dict[str, Any]) -> bool: + """Check if a tool is an Anthropic web search tool. Anthropic web search tools have: - type starting with "web_search" (e.g., "web_search_20260209") @@ -332,6 +316,7 @@ class LiteLLMAnthropicMessagesAdapter: Returns: True if this is a web search tool + """ tool_type = tool.get("type", "") tool_name = tool.get("name", "") @@ -341,20 +326,17 @@ class LiteLLMAnthropicMessagesAdapter: def translate_anthropic_messages_to_openai( # noqa: PLR0915 self, - messages: List[ - Union[ - AnthropicMessagesUserMessageParam, - AnthopicMessagesAssistantMessageParam, - ] + messages: list[ + AnthropicMessagesUserMessageParam | AnthopicMessagesAssistantMessageParam ], - model: Optional[str] = None, - ) -> List: - new_messages: List[AllMessageValues] = [] + model: str | None = None, + ) -> list: + new_messages: list[AllMessageValues] = [] for m in messages: - user_message: Optional[ChatCompletionUserMessage] = None - tool_message_list: List[ChatCompletionToolMessage] = [] - new_user_content_list: List[ - Union[ChatCompletionTextObject, ChatCompletionImageObject] + user_message: ChatCompletionUserMessage | None = None + tool_message_list: list[ChatCompletionToolMessage] = [] + new_user_content_list: list[ + ChatCompletionTextObject | ChatCompletionImageObject ] = [] ## USER MESSAGE ## if m["role"] == "user": @@ -489,11 +471,9 @@ class LiteLLMAnthropicMessagesAdapter: else: # For multiple content items, combine into a single tool message # with list content to preserve all items while having one tool_use_id - combined_content_parts: List[ - Union[ - ChatCompletionTextObject, - ChatCompletionImageObject, - ] + combined_content_parts: list[ + ChatCompletionTextObject + | ChatCompletionImageObject ] = [] for c in content_items: if isinstance(c, str): @@ -549,15 +529,15 @@ class LiteLLMAnthropicMessagesAdapter: new_messages.append({"role": "user", "content": new_user_content_list}) # type: ignore ## ASSISTANT MESSAGE ## - assistant_message_str: Optional[str] = None - assistant_content_list: List[ - Dict[str, Any] + assistant_message_str: str | None = None + assistant_content_list: list[ + dict[str, Any] ] = [] # For content blocks with cache_control has_cache_control_in_text = False - tool_calls: List[ChatCompletionAssistantToolCall] = [] - reasoning_content_parts: List[str] = [] - thinking_blocks: List[ - Union[ChatCompletionThinkingBlock, ChatCompletionRedactedThinkingBlock] + tool_calls: list[ChatCompletionAssistantToolCall] = [] + reasoning_content_parts: list[str] = [] + thinking_blocks: list[ + ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock ] = [] if m["role"] == "assistant": if isinstance(m.get("content"), str): @@ -568,7 +548,7 @@ class LiteLLMAnthropicMessagesAdapter: assistant_message_str = str(content) elif isinstance(content, dict): if content.get("type") == "text": - text_block: Dict[str, Any] = { + text_block: dict[str, Any] = { "type": "text", "text": content.get("text", ""), } @@ -587,21 +567,21 @@ class LiteLLMAnthropicMessagesAdapter: } signature = ( self._extract_signature_from_tool_use_content( - cast(Dict[str, Any], content) + cast(dict[str, Any], content) ) ) if signature: - provider_specific_fields: Dict[str, Any] = ( + provider_specific_fields: dict[str, Any] = ( function_chunk.get("provider_specific_fields") or {} ) - provider_specific_fields[ - "thought_signature" - ] = signature - function_chunk[ - "provider_specific_fields" - ] = provider_specific_fields + provider_specific_fields["thought_signature"] = ( + signature + ) + function_chunk["provider_specific_fields"] = ( + provider_specific_fields + ) tool_call = ChatCompletionAssistantToolCall( id=content.get("id", ""), @@ -659,10 +639,14 @@ class LiteLLMAnthropicMessagesAdapter: ) if len(tool_calls) > 0: assistant_message["tool_calls"] = tool_calls # type: ignore + reasoning_content = ( + "".join(reasoning_content_parts) + if len(reasoning_content_parts) > 0 + else None + ) + if reasoning_content is not None or len(tool_calls) > 0: assistant_message["reasoning_content"] = ( - "".join(reasoning_content_parts) - if len(reasoning_content_parts) > 0 - else None + reasoning_content ) # type: ignore if len(thinking_blocks) > 0: assistant_message["thinking_blocks"] = thinking_blocks # type: ignore @@ -672,10 +656,9 @@ class LiteLLMAnthropicMessagesAdapter: @staticmethod def translate_anthropic_thinking_to_reasoning_effort( - thinking: Dict[str, Any] - ) -> Optional[str]: - """ - Translate Anthropic's thinking parameter to OpenAI's reasoning_effort. + thinking: dict[str, Any], + ) -> str | None: + """Translate Anthropic's thinking parameter to OpenAI's reasoning_effort. Anthropic thinking format: {'type': 'enabled'|'disabled', 'budget_tokens': int} OpenAI reasoning_effort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'default' @@ -708,8 +691,7 @@ class LiteLLMAnthropicMessagesAdapter: @staticmethod def is_anthropic_claude_model(model: str) -> bool: - """ - Check if the model is an Anthropic Claude model that supports the thinking parameter. + """Check if the model is an Anthropic Claude model that supports the thinking parameter. Returns True for: - anthropic/* models @@ -721,11 +703,10 @@ class LiteLLMAnthropicMessagesAdapter: @staticmethod def translate_thinking_for_model( - thinking: Dict[str, Any], + thinking: dict[str, Any], model: str, - ) -> Dict[str, Any]: - """ - Translate Anthropic thinking parameter based on the target model. + ) -> dict[str, Any]: + """Translate Anthropic thinking parameter based on the target model. For Claude/Anthropic models: returns {'thinking': } - Preserves exact budget_tokens value @@ -739,6 +720,7 @@ class LiteLLMAnthropicMessagesAdapter: Returns: Dict with either 'thinking' or 'reasoning_effort' key + """ if LiteLLMAnthropicMessagesAdapter.is_anthropic_claude_model(model): return {"thinking": thinking} @@ -787,22 +769,22 @@ class LiteLLMAnthropicMessagesAdapter: ) else: raise ValueError( - "Incompatible tool choice param submitted - {}".format(tool_choice) + f"Incompatible tool choice param submitted - {tool_choice}" ) def translate_anthropic_tools_to_openai( - self, tools: List[AllAnthropicToolsValues], model: Optional[str] = None - ) -> Tuple[List[ChatCompletionToolParam], Dict[str, str]]: - """ - Translate Anthropic tools to OpenAI format. + self, tools: list[AllAnthropicToolsValues], model: str | None = None + ) -> tuple[list[ChatCompletionToolParam], dict[str, str]]: + """Translate Anthropic tools to OpenAI format. Returns: Tuple of (translated_tools, tool_name_mapping) - tool_name_mapping maps truncated names back to original names for tools that exceeded OpenAI's 64-char limit + """ - new_tools: List[ChatCompletionToolParam] = [] - tool_name_mapping: Dict[str, str] = {} + new_tools: list[ChatCompletionToolParam] = [] + tool_name_mapping: dict[str, str] = {} mapped_tool_params = ["name", "input_schema", "description", "cache_control"] for idx, tool in enumerate(tools): @@ -847,9 +829,8 @@ class LiteLLMAnthropicMessagesAdapter: def translate_anthropic_output_format_to_openai( self, output_format: Any - ) -> Optional[Dict[str, Any]]: - """ - Translate Anthropic's output_format to OpenAI's response_format. + ) -> dict[str, Any] | None: + """Translate Anthropic's output_format to OpenAI's response_format. Anthropic output_format: {"type": "json_schema", "schema": {...}} OpenAI response_format: {"type": "json_schema", "json_schema": {"name": "...", "schema": {...}}} @@ -859,6 +840,7 @@ class LiteLLMAnthropicMessagesAdapter: Returns: OpenAI-compatible response_format dict, or None if invalid + """ if not isinstance(output_format, dict): return None @@ -888,8 +870,7 @@ class LiteLLMAnthropicMessagesAdapter: @staticmethod def _add_additional_properties_false(schema: dict) -> None: - """ - Recursively ensure object schemas comply with OpenAI strict mode. + """Recursively ensure object schemas comply with OpenAI strict mode. OpenAI's strict mode requires: 1. 'additionalProperties': false at every object nesting level @@ -928,7 +909,7 @@ class LiteLLMAnthropicMessagesAdapter: def _add_system_message_to_messages( self, - new_messages: List[AllMessageValues], + new_messages: list[AllMessageValues], anthropic_message_request: AnthropicMessagesRequest, ) -> None: """Add system message to messages list if present in request.""" @@ -945,11 +926,11 @@ class LiteLLMAnthropicMessagesAdapter: ) elif isinstance(system_content, list): # Convert Anthropic system content blocks to OpenAI format - openai_system_content: List[Dict[str, Any]] = [] + openai_system_content: list[dict[str, Any]] = [] model_name = anthropic_message_request.get("model", "") for block in system_content: if isinstance(block, dict) and block.get("type") == "text": - text_block: Dict[str, Any] = { + text_block: dict[str, Any] = { "type": "text", "text": block.get("text", ""), } @@ -958,7 +939,9 @@ class LiteLLMAnthropicMessagesAdapter: if openai_system_content: new_messages.insert( 0, - ChatCompletionSystemMessage(role="system", content=openai_system_content), # type: ignore + ChatCompletionSystemMessage( + role="system", content=openai_system_content + ), # type: ignore ) def _translate_metadata_to_openai( @@ -995,7 +978,7 @@ class LiteLLMAnthropicMessagesAdapter: self, anthropic_message_request: AnthropicMessagesRequest, new_kwargs: ChatCompletionRequest, - ) -> Dict[str, str]: + ) -> dict[str, str]: """Translate tools and extract web_search_options when needed.""" if "tools" not in anthropic_message_request: return {} @@ -1004,10 +987,10 @@ class LiteLLMAnthropicMessagesAdapter: if not tools: return {} - web_search_tools: List[AllAnthropicToolsValues] = [] - regular_tools: List[AllAnthropicToolsValues] = [] + web_search_tools: list[AllAnthropicToolsValues] = [] + regular_tools: list[AllAnthropicToolsValues] = [] for tool in tools: - cast_tool = cast(Dict[str, Any], tool) + cast_tool = cast(dict[str, Any], tool) if self._is_web_search_tool(cast_tool): web_search_tools.append(cast(AllAnthropicToolsValues, tool)) else: @@ -1045,7 +1028,7 @@ class LiteLLMAnthropicMessagesAdapter: return reasoning_effort = self.translate_anthropic_thinking_to_reasoning_effort( - cast(Dict[str, Any], thinking) + cast(dict[str, Any], thinking) ) if not reasoning_effort: return @@ -1101,30 +1084,26 @@ class LiteLLMAnthropicMessagesAdapter: def translate_anthropic_to_openai( self, anthropic_message_request: AnthropicMessagesRequest - ) -> Tuple[ChatCompletionRequest, Dict[str, str]]: - """ - This is used by the beta Anthropic Adapter, for translating anthropic `/v1/messages` requests to the openai format. + ) -> tuple[ChatCompletionRequest, dict[str, str]]: + """This is used by the beta Anthropic Adapter, for translating anthropic `/v1/messages` requests to the openai format. Returns: Tuple of (openai_request, tool_name_mapping) - tool_name_mapping maps truncated tool names back to original names for tools that exceeded OpenAI's 64-char limit + """ # Debug: Processing Anthropic message request - new_messages: List[AllMessageValues] = [] - tool_name_mapping: Dict[str, str] = {} + new_messages: list[AllMessageValues] = [] + tool_name_mapping: dict[str, str] = {} ## CONVERT ANTHROPIC MESSAGES TO OPENAI - messages_list: List[ - Union[ - AnthropicMessagesUserMessageParam, AnthopicMessagesAssistantMessageParam - ] + messages_list: list[ + AnthropicMessagesUserMessageParam | AnthopicMessagesAssistantMessageParam ] = cast( - List[ - Union[ - AnthropicMessagesUserMessageParam, - AnthopicMessagesAssistantMessageParam, - ] + list[ + AnthropicMessagesUserMessageParam + | AnthopicMessagesAssistantMessageParam ], anthropic_message_request["messages"], ) @@ -1171,9 +1150,8 @@ class LiteLLMAnthropicMessagesAdapter: return new_kwargs, tool_name_mapping - def _translate_anthropic_image_to_openai(self, image_source: dict) -> Optional[str]: - """ - Translate Anthropic image source format to OpenAI-compatible image URL. + def _translate_anthropic_image_to_openai(self, image_source: dict) -> str | None: + """Translate Anthropic image source format to OpenAI-compatible image URL. Anthropic supports two image source formats: 1. Base64: {"type": "base64", "media_type": "image/jpeg", "data": "..."} @@ -1200,10 +1178,10 @@ class LiteLLMAnthropicMessagesAdapter: def _translate_openai_content_to_anthropic( self, - choices: List[Choices], - tool_name_mapping: Optional[Dict[str, str]] = None, - ) -> List[Dict[str, Any]]: - new_content: List[Dict[str, Any]] = [] + choices: list[Choices], + tool_name_mapping: dict[str, str] | None = None, + ) -> list[dict[str, Any]]: + new_content: list[dict[str, Any]] = [] for choice in choices: # Handle thinking blocks first if ( @@ -1315,16 +1293,16 @@ class LiteLLMAnthropicMessagesAdapter: def translate_openai_response_to_anthropic( self, response: ModelResponse, - tool_name_mapping: Optional[Dict[str, str]] = None, + tool_name_mapping: dict[str, str] | None = None, ) -> AnthropicMessagesResponse: - """ - Translate OpenAI response to Anthropic format. + """Translate OpenAI response to Anthropic format. Args: response: The OpenAI ModelResponse tool_name_mapping: Optional mapping of truncated tool names to original names. Used to restore original names for tools that exceeded OpenAI's 64-char limit. + """ ## translate content block anthropic_content = self._translate_openai_content_to_anthropic( @@ -1336,7 +1314,7 @@ class LiteLLMAnthropicMessagesAdapter: openai_finish_reason=response.choices[0].finish_reason # type: ignore ) # extract usage - usage: Usage = getattr(response, "usage") + usage: Usage = response.usage uncached_input_tokens = usage.prompt_tokens or 0 cached_tokens = 0 if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details: @@ -1353,9 +1331,9 @@ class LiteLLMAnthropicMessagesAdapter: hasattr(usage, "_cache_creation_input_tokens") and usage._cache_creation_input_tokens > 0 ): - anthropic_usage[ - "cache_creation_input_tokens" - ] = usage._cache_creation_input_tokens + anthropic_usage["cache_creation_input_tokens"] = ( + usage._cache_creation_input_tokens + ) if cached_tokens > 0: anthropic_usage["cache_read_input_tokens"] = cached_tokens @@ -1373,8 +1351,8 @@ class LiteLLMAnthropicMessagesAdapter: return translated_obj def _translate_streaming_openai_chunk_to_anthropic_content_block( - self, choices: List[Union[OpenAIStreamingChoice, StreamingChoices]] - ) -> Tuple[ + self, choices: list[OpenAIStreamingChoice | StreamingChoices] + ) -> tuple[ Literal["text", "tool_use", "thinking"], "ContentBlockContentBlockDict", ]: @@ -1420,20 +1398,18 @@ class LiteLLMAnthropicMessagesAdapter: return "text", TextBlock(type="text", text="") def _translate_streaming_openai_chunk_to_anthropic( - self, choices: List[Union[OpenAIStreamingChoice, StreamingChoices]] - ) -> Tuple[ + self, choices: list[OpenAIStreamingChoice | StreamingChoices] + ) -> tuple[ Literal["text_delta", "input_json_delta", "thinking_delta", "signature_delta"], - Union[ - ContentTextBlockDelta, - ContentJsonBlockDelta, - ContentThinkingBlockDelta, - ContentThinkingSignatureBlockDelta, - ], + ContentTextBlockDelta + | ContentJsonBlockDelta + | ContentThinkingBlockDelta + | ContentThinkingSignatureBlockDelta, ]: text: str = "" reasoning_content: str = "" reasoning_signature: str = "" - partial_json: Optional[str] = None + partial_json: str | None = None for choice in choices: if choice.delta.content is not None and len(choice.delta.content) > 0: text += choice.delta.content @@ -1490,7 +1466,7 @@ class LiteLLMAnthropicMessagesAdapter: def translate_streaming_openai_response_to_anthropic( self, response: ModelResponse, current_content_block_index: int - ) -> Union[ContentBlockDelta, MessageBlockDelta]: + ) -> ContentBlockDelta | MessageBlockDelta: ## base case - final chunk w/ finish reason if response.choices[0].finish_reason is not None: delta = MessageDelta( @@ -1499,7 +1475,7 @@ class LiteLLMAnthropicMessagesAdapter: ), ) if getattr(response, "usage", None) is not None: - litellm_usage_chunk: Optional[Usage] = response.usage # type: ignore + litellm_usage_chunk: Usage | None = response.usage # type: ignore elif ( hasattr(response, "_hidden_params") and "usage" in response._hidden_params @@ -1532,15 +1508,17 @@ class LiteLLMAnthropicMessagesAdapter: hasattr(litellm_usage_chunk, "_cache_creation_input_tokens") and litellm_usage_chunk._cache_creation_input_tokens > 0 ): - usage_delta[ - "cache_creation_input_tokens" - ] = litellm_usage_chunk._cache_creation_input_tokens + usage_delta["cache_creation_input_tokens"] = ( + litellm_usage_chunk._cache_creation_input_tokens + ) if cached_tokens > 0: usage_delta["cache_read_input_tokens"] = cached_tokens else: usage_delta = UsageDelta(input_tokens=0, output_tokens=0) return MessageBlockDelta( - type="message_delta", delta=delta, usage=usage_delta # type: ignore + type="message_delta", + delta=delta, + usage=usage_delta, # type: ignore ) ( type_of_content, diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py index ebb045cbfad..b2caec0db77 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py @@ -71,13 +71,13 @@ def _sanitize_think_tag_text_blocks( continue cleaned_text = text.split("", 1)[1].lstrip() - if cleaned_text: - sanitized_block = dict(block) - sanitized_block["text"] = cleaned_text - sanitized_content.append(sanitized_block) + sanitized_block = dict(block) + sanitized_block["text"] = cleaned_text + sanitized_content.append(sanitized_block) - response["content"] = sanitized_content - return response + sanitized_response = dict(response) + sanitized_response["content"] = sanitized_content + return cast(AnthropicMessagesResponse, sanitized_response) ####### ENVIRONMENT VARIABLES ################### diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index c186a661a86..3002e288dfe 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -1,6 +1,5 @@ import os import sys -from typing import Any, cast import pytest @@ -211,7 +210,6 @@ def test_translate_streaming_openai_chunk_to_anthropic_raises_when_thinking_and_ def test_translate_anthropic_messages_to_openai_thinking_blocks(): """Test that tool result messages are placed before user messages in the conversation order.""" - anthropic_messages = [ AnthropicMessagesUserMessageParam( role="user", @@ -261,7 +259,6 @@ def test_translate_anthropic_messages_to_openai_thinking_blocks(): def test_translate_anthropic_messages_to_openai_tool_message_placement(): """Test that tool result messages are placed before user messages in the conversation order.""" - anthropic_messages = [ AnthropicMessagesUserMessageParam( role="user", @@ -311,14 +308,13 @@ def test_translate_anthropic_messages_to_openai_tool_message_placement(): assert tool_message_idx is not None, "Tool message not found" assert user_message_idx is not None, "User message not found" - assert ( - tool_message_idx < user_message_idx - ), "Tool message should be placed before user message" + assert tool_message_idx < user_message_idx, ( + "Tool message should be placed before user message" + ) def test_translate_openai_content_to_anthropic_empty_function_arguments(): """Test that empty function arguments are handled safely and don't cause JSON parsing errors.""" - openai_choices = [ Choices( message=Message( @@ -329,7 +325,8 @@ def test_translate_openai_content_to_anthropic_empty_function_arguments(): id="call_empty_args", type="function", function=Function( - name="test_function", arguments="" # empty arguments string + name="test_function", + arguments="", # empty arguments string ), ) ], @@ -344,7 +341,9 @@ def test_translate_openai_content_to_anthropic_empty_function_arguments(): assert result[0]["type"] == "tool_use" assert result[0]["id"] == "call_empty_args" assert result[0]["name"] == "test_function" - assert result[0]["input"] == {}, "Empty function arguments should result in empty dict" + assert result[0]["input"] == {}, ( + "Empty function arguments should result in empty dict" + ) def test_translate_openai_content_to_anthropic_text_and_tool_calls(): @@ -661,7 +660,6 @@ def test_translate_streaming_openai_chunk_to_anthropic_raises_when_thinking_and_ def test_translate_anthropic_messages_to_openai_user_message_with_base64_image(): """Test that base64 images in user messages are correctly translated to OpenAI format.""" - anthropic_messages = [ AnthropicMessagesUserMessageParam( role="user", @@ -705,7 +703,6 @@ def test_translate_anthropic_messages_to_openai_user_message_with_base64_image() def test_translate_anthropic_messages_to_openai_user_message_with_url_image(): """Test that URL-based images in user messages are correctly translated to OpenAI format.""" - anthropic_messages = [ AnthropicMessagesUserMessageParam( role="user", @@ -741,7 +738,6 @@ def test_translate_anthropic_messages_to_openai_user_message_with_url_image(): def test_translate_anthropic_messages_to_openai_tool_result_with_base64_image(): """Test that base64 images in tool results are correctly translated to OpenAI format.""" - anthropic_messages = [ AnthropicMessagesUserMessageParam( role="user", content=[{"type": "text", "text": "Take a screenshot"}] @@ -797,7 +793,6 @@ def test_translate_anthropic_messages_to_openai_tool_result_with_base64_image(): def test_translate_anthropic_messages_to_openai_tool_result_with_url_image(): """Test that URL-based images in tool results are correctly translated to OpenAI format.""" - anthropic_messages = [ AnthropicMessagesUserMessageParam( role="user", @@ -855,7 +850,6 @@ def test_translate_anthropic_messages_to_openai_tool_result_with_url_image(): def test_translate_anthropic_messages_to_openai_mixed_content_with_image(): """Test that messages with mixed text and image content are correctly translated.""" - anthropic_messages = [ AnthropicMessagesUserMessageParam( role="user", @@ -914,7 +908,6 @@ def test_translate_anthropic_messages_to_openai_mixed_content_with_image(): def test_translate_anthropic_messages_to_openai_tool_use_with_signature(): """Test that thought signatures from tool_use blocks are correctly extracted and placed in provider_specific_fields.""" - test_signature = "EpYECpMEAdHtim9iBECdK1l5uVIIXoZZmq+PUBH9nz3Q6EMeIdEqWwVb5GlxSNtxuSkFoseFco5U4zxN/lacJxD2WUjFvEyL2GOkbPgXFeCcgNBMEYVRg7UAr45KGeWJJmJMoheLHezKawI1L94vi2PsB9TDpWv4vyAx1vKG2PByiVmWWtd0rondsdbENNp2Rrz3ol1zha+XhOtyhTCdSWce8GVD/zElklL3C0h9HrsTQrnNyouaZa9KlXZJ72XDCIkIlV0m6EtxbzdMwbH4sLFOpifRlRn+AmzXjxvLovRtn2bXh/X3bUgPxqypaST57Dlpddlk1Mt0oJmGFtwB/FH1JmK21cIC06uXtlUc8lm/9cTQLd5hcEUX+XRrmTdzqxDgRttN8CRfVUAGE7Er+prN4yCIdNtEQdZm8zymEpHTkYplJ/hK7SMf9Iu1k+eCDFYCzvQuzLcJtNpRaGS1BbVA3va5JKrEu96G7a3Wl3DyzmrH8N3+RA+UIHvP6P5v93tI/eTyfMY54rKpLGkfFeeSMAr5aSoUZVYkvFI8xGEcIrqLWPDF91MclLZa7USSVql0wYu1G9KD10IkopeKkTIAl81WfoY5+Kw1o4CHo7bEQ6tfTuTB4IEywf1XKMBYHmsfAe5B9ferkLYtnAzzt1hoiK1m/2CjX8yQAknRLsnAuyeXfJZRZidVKYOKaSDftddbXJpIlJApC" anthropic_messages = [ @@ -985,9 +978,45 @@ def test_translate_anthropic_messages_to_openai_tool_use_without_thinking_blocks assert result[1].get("reasoning_content") is None +def test_translate_anthropic_messages_to_openai_thinking_only_sets_reasoning_content(): + anthropic_messages = [ + AnthropicMessagesUserMessageParam( + role="user", + content=[{"type": "text", "text": "Think through this carefully."}], + ), + AnthopicMessagesAssistantMessageParam( + role="assistant", + content=[ + { + "type": "thinking", + "thinking": "First step.", + "signature": "sig-1", + }, + { + "type": "thinking", + "thinking": " Second step.", + "signature": "sig-2", + }, + { + "type": "text", + "text": "Done.", + }, + ], + ), + ] + + adapter = LiteLLMAnthropicMessagesAdapter() + result = adapter.translate_anthropic_messages_to_openai(messages=anthropic_messages) + + assert len(result) == 2 + assert result[1]["role"] == "assistant" + assert "tool_calls" not in result[1] + assert result[1]["reasoning_content"] == "First step. Second step." + assert len(result[1]["thinking_blocks"]) == 2 + + def test_translate_anthropic_messages_to_openai_tool_result_with_multiple_content_items(): - """ - Test that tool_result with multiple content items creates a single tool message + """Test that tool_result with multiple content items creates a single tool message (not multiple messages with the same tool_call_id). This is a regression test for the bug: @@ -996,7 +1025,6 @@ def test_translate_anthropic_messages_to_openai_tool_result_with_multiple_conten When a tool_result has a list of content items (e.g., text + image), we should create ONE tool message with combined content, not multiple tool messages with the same ID. """ - anthropic_messages = [ AnthropicMessagesUserMessageParam( role="user", @@ -1058,12 +1086,12 @@ def test_translate_anthropic_messages_to_openai_tool_result_with_multiple_conten # The content should be a list with all items combined tool_message = tool_messages[0] assert tool_message["tool_call_id"] == "toolu_016hYHBkTf4JDF3p22UoYk5C" - assert isinstance( - tool_message["content"], list - ), "Multiple content items should be combined into a list" - assert ( - len(tool_message["content"]) == 3 - ), f"Expected 3 content items, got {len(tool_message['content'])}" + assert isinstance(tool_message["content"], list), ( + "Multiple content items should be combined into a list" + ) + assert len(tool_message["content"]) == 3, ( + f"Expected 3 content items, got {len(tool_message['content'])}" + ) # Verify content types assert tool_message["content"][0]["type"] == "text" @@ -1074,11 +1102,9 @@ def test_translate_anthropic_messages_to_openai_tool_result_with_multiple_conten def test_translate_anthropic_messages_to_openai_tool_result_single_item_backward_compat(): - """ - Test that tool_result with a single content item maintains backward compatibility + """Test that tool_result with a single content item maintains backward compatibility by returning a string content (not a list). """ - anthropic_messages = [ AnthropicMessagesUserMessageParam( role="user", @@ -1128,8 +1154,7 @@ def test_translate_anthropic_messages_to_openai_tool_result_single_item_backward def test_streaming_chunk_with_both_text_and_tool_calls_issue_18238(): - """ - When a streaming choice contains both text content and tool_calls, + """When a streaming choice contains both text content and tool_calls, both should be processed (tool_calls should not be ignored). """ # streaming choice with both text and tool_calls @@ -1185,7 +1210,9 @@ def test_streaming_chunk_with_both_text_and_tool_calls_issue_18238(): # ============================================================================ # Model constant for cache control tests -CACHE_CONTROL_BEDROCK_CONVERSE_MODEL = "bedrock/converse/global.anthropic.claude-opus-4-5-20251101-v1:0" +CACHE_CONTROL_BEDROCK_CONVERSE_MODEL = ( + "bedrock/converse/global.anthropic.claude-opus-4-5-20251101-v1:0" +) CACHE_CONTROL_NON_ANTHROPIC_MODEL = "gpt-4" @@ -1201,7 +1228,9 @@ def test_should_add_cache_control_for_anthropic_model(): "vertex_ai/claude-3-sonnet@20240229", ]: target = {} - adapter._add_cache_control_if_applicable({"cache_control": cache_control}, target, model) + adapter._add_cache_control_if_applicable( + {"cache_control": cache_control}, target, model + ) assert "cache_control" in target assert target["cache_control"] == cache_control @@ -1211,9 +1240,15 @@ def test_should_not_add_cache_control_for_non_anthropic_model(): adapter = LiteLLMAnthropicMessagesAdapter() cache_control = {"type": "ephemeral"} - for model in [CACHE_CONTROL_NON_ANTHROPIC_MODEL, "openai/gpt-4-turbo", "gemini-pro"]: + for model in [ + CACHE_CONTROL_NON_ANTHROPIC_MODEL, + "openai/gpt-4-turbo", + "gemini-pro", + ]: target = {} - adapter._add_cache_control_if_applicable({"cache_control": cache_control}, target, model) + adapter._add_cache_control_if_applicable( + {"cache_control": cache_control}, target, model + ) assert "cache_control" not in target @@ -1221,9 +1256,16 @@ def test_should_not_add_cache_control_when_none(): """Should not add cache_control when source has None or empty cache_control.""" adapter = LiteLLMAnthropicMessagesAdapter() - for source in [{"cache_control": None}, {"cache_control": {}}, {"cache_control": ""}, {}]: + for source in [ + {"cache_control": None}, + {"cache_control": {}}, + {"cache_control": ""}, + {}, + ]: target = {} - adapter._add_cache_control_if_applicable(source, target, CACHE_CONTROL_BEDROCK_CONVERSE_MODEL) + adapter._add_cache_control_if_applicable( + source, target, CACHE_CONTROL_BEDROCK_CONVERSE_MODEL + ) assert "cache_control" not in target @@ -1234,7 +1276,9 @@ def test_should_not_add_cache_control_when_model_none(): for model in [None, ""]: target = {} - adapter._add_cache_control_if_applicable({"cache_control": cache_control}, target, model) + adapter._add_cache_control_if_applicable( + {"cache_control": cache_control}, target, model + ) assert "cache_control" not in target @@ -1452,7 +1496,10 @@ def test_cache_control_preserved_in_tools_for_claude(): { "name": "get_weather", "description": "Get weather for a location", - "input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}, + "input_schema": { + "type": "object", + "properties": {"location": {"type": "string"}}, + }, "cache_control": {"type": "ephemeral"}, } ] @@ -1473,7 +1520,10 @@ def test_cache_control_not_preserved_in_tools_for_non_claude(): { "name": "get_weather", "description": "Get weather for a location", - "input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}, + "input_schema": { + "type": "object", + "properties": {"location": {"type": "string"}}, + }, "cache_control": {"type": "ephemeral"}, } ] @@ -1506,10 +1556,9 @@ def test_translate_anthropic_tools_to_openai_fills_missing_tool_name(): def test_translate_openai_content_to_anthropic_reasoning_content_without_thinking_blocks(): - """ - Test that reasoning_content is converted to thinking block when thinking_blocks is not present. + """Test that reasoning_content is converted to thinking block when thinking_blocks is not present. This handles providers like OpenRouter that return reasoning_content instead of thinking_blocks. - + Regression test for: OpenRouter models returning reasoning_content in /v1/messages endpoint should be converted to Anthropic's thinking block format. """ @@ -1517,7 +1566,7 @@ def test_translate_openai_content_to_anthropic_reasoning_content_without_thinkin Choices( message=Message( role="assistant", - content="There are **3** \"r\"s in the word strawberry.", + content='There are **3** "r"s in the word strawberry.', reasoning_content="**Considering Letter Frequency**\n\nI've homed in on the specifics: The task focuses on counting the letter 'r'. I've identified the target word, \"strawberry,\" and confirmed my understanding of the letter's location. The first 'r' follows 't', the second after 'e', and the third… well, I'm almost there.\n\n\n**Calculating the Count**\n\nMy analysis is complete! I've confirmed that the letter \"r\" appears three times in \"strawberry.\" The first follows \"t,\" the second \"e,\" and the third immediately follows the second. The count is definitively three.", ) ) @@ -1534,15 +1583,14 @@ def test_translate_openai_content_to_anthropic_reasoning_content_without_thinkin assert result[0]["signature"] is None # Second block should be text block with content assert result[1]["type"] == "text" - assert result[1]["text"] == "There are **3** \"r\"s in the word strawberry." + assert result[1]["text"] == 'There are **3** "r"s in the word strawberry.' def test_translate_streaming_openai_chunk_to_anthropic_reasoning_content_without_thinking_blocks(): - """ - Test that reasoning_content in streaming chunks is converted to thinking_delta + """Test that reasoning_content in streaming chunks is converted to thinking_delta when thinking_blocks is not present. - - This handles providers like OpenRouter that return reasoning_content in streaming + + This handles providers like OpenRouter that return reasoning_content in streaming responses without thinking_blocks. """ choices = [ @@ -1574,10 +1622,9 @@ def test_translate_streaming_openai_chunk_to_anthropic_reasoning_content_without def test_translate_openai_response_to_anthropic_with_reasoning_content_only(): - """ - Test the full response translation when only reasoning_content is present + """Test the full response translation when only reasoning_content is present (no thinking_blocks). - + This simulates OpenRouter's response format being translated to Anthropic format through /v1/messages endpoint. """ @@ -1589,7 +1636,7 @@ def test_translate_openai_response_to_anthropic_with_reasoning_content_only(): finish_reason="stop", message=Message( role="assistant", - content="There are **3** \"r\"s in the word strawberry.", + content='There are **3** "r"s in the word strawberry.', reasoning_content="**Considering Letter Frequency**\n\nI've homed in on the specifics: The task focuses on counting the letter 'r'.", ), ) @@ -1605,16 +1652,18 @@ def test_translate_openai_response_to_anthropic_with_reasoning_content_only(): anthropic_content = anthropic_response.get("content") assert anthropic_content is not None assert len(anthropic_content) == 2 - + # First block should be thinking assert anthropic_content[0]["type"] == "thinking" assert "Considering Letter Frequency" in anthropic_content[0]["thinking"] assert anthropic_content[0].get("signature") is None - + # Second block should be text assert anthropic_content[1]["type"] == "text" - assert anthropic_content[1]["text"] == "There are **3** \"r\"s in the word strawberry." - + assert ( + anthropic_content[1]["text"] == 'There are **3** "r"s in the word strawberry.' + ) + assert anthropic_response.get("stop_reason") == "end_turn" @@ -1665,7 +1714,9 @@ def test_truncate_tool_name_deterministic(): def test_truncate_tool_name_avoids_collisions(): """Similar long names should produce different truncated names.""" name1 = "process_user_data_with_validation_and_error_handling_for_production_environment" - name2 = "process_user_data_with_validation_and_error_handling_for_staging_environment" + name2 = ( + "process_user_data_with_validation_and_error_handling_for_staging_environment" + ) result1 = truncate_tool_name(name1) result2 = truncate_tool_name(name2) @@ -1685,7 +1736,9 @@ def test_create_tool_name_mapping_no_long_names(): def test_create_tool_name_mapping_with_long_names(): """Mapping should contain entries for truncated names.""" - long_name = "a_very_long_tool_name_that_exceeds_the_64_character_limit_imposed_by_openai" + long_name = ( + "a_very_long_tool_name_that_exceeds_the_64_character_limit_imposed_by_openai" + ) tools = [ {"name": "short_name"}, {"name": long_name}, @@ -1750,7 +1803,9 @@ def test_translate_anthropic_tools_mixed_names(): def test_translate_openai_response_restores_tool_names(): """Tool names in responses should be restored to original.""" - original_name = "a_very_long_tool_name_that_needs_truncation_for_openai_api_compatibility" + original_name = ( + "a_very_long_tool_name_that_needs_truncation_for_openai_api_compatibility" + ) truncated_name = truncate_tool_name(original_name) tool_name_mapping = {truncated_name: original_name} @@ -1794,20 +1849,19 @@ def test_translate_openai_response_restores_tool_names(): def test_translate_openai_response_to_anthropic_input_tokens_excludes_cached_tokens(): - """ - Regression test: input_tokens in Anthropic format should NOT include cached tokens. - + """Regression test: input_tokens in Anthropic format should NOT include cached tokens. + Issue: v1/messages API was returning incorrect input_token count when using prompt caching. The OpenAI format includes cached tokens in prompt_tokens, but Anthropic format should not. - + According to Anthropic's spec: - input_tokens = uncached input tokens only - cache_read_input_tokens = tokens read from cache - + In OpenAI format: - prompt_tokens = all input tokens (including cached) - prompt_tokens_details.cached_tokens = cached tokens - + Expected: anthropic.input_tokens = openai.prompt_tokens - openai.prompt_tokens_details.cached_tokens """ from litellm.types.utils import PromptTokensDetailsWrapper @@ -1818,12 +1872,10 @@ def test_translate_openai_response_to_anthropic_input_tokens_excludes_cached_tok prompt_tokens=100, completion_tokens=50, total_tokens=150, - prompt_tokens_details=PromptTokensDetailsWrapper( - cached_tokens=30 - ), + prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=30), cache_read_input_tokens=30, # Anthropic format cache info ) - + response = ModelResponse( id="test-id", choices=[ @@ -1839,14 +1891,14 @@ def test_translate_openai_response_to_anthropic_input_tokens_excludes_cached_tok model="claude-3-sonnet-20240229", usage=usage, ) - + # Convert to Anthropic format adapter = LiteLLMAnthropicMessagesAdapter() anthropic_response = adapter.translate_openai_response_to_anthropic( response=response, tool_name_mapping=None, ) - + # Validate: input_tokens should be 70 (100 - 30 cached), not 100 assert anthropic_response["usage"]["input_tokens"] == 70, ( f"Expected input_tokens=70 (100 total - 30 cached), " @@ -1858,18 +1910,14 @@ def test_translate_openai_response_to_anthropic_input_tokens_excludes_cached_tok def test_translate_openai_response_to_anthropic_input_tokens_no_cache(): - """ - Regression test: input_tokens should equal prompt_tokens when there are no cached tokens. - """ - from litellm.types.utils import PromptTokensDetailsWrapper - + """Regression test: input_tokens should equal prompt_tokens when there are no cached tokens.""" # Create OpenAI format response without cached tokens usage = Usage( prompt_tokens=100, completion_tokens=50, total_tokens=150, ) - + response = ModelResponse( id="test-id", choices=[ @@ -1885,22 +1933,21 @@ def test_translate_openai_response_to_anthropic_input_tokens_no_cache(): model="claude-3-sonnet-20240229", usage=usage, ) - + # Convert to Anthropic format adapter = LiteLLMAnthropicMessagesAdapter() anthropic_response = adapter.translate_openai_response_to_anthropic( response=response, tool_name_mapping=None, ) - + # Validate: input_tokens should equal prompt_tokens when no caching assert anthropic_response["usage"]["input_tokens"] == 100 assert anthropic_response["usage"]["output_tokens"] == 50 def test_translate_openai_response_to_anthropic_cache_tokens_from_prompt_tokens_details(): - """ - OpenAI/Azure providers set prompt_tokens_details.cached_tokens but not + """OpenAI/Azure providers set prompt_tokens_details.cached_tokens but not _cache_read_input_tokens. The adapter should populate cache_read_input_tokens from prompt_tokens_details.cached_tokens directly. """ @@ -1911,9 +1958,7 @@ def test_translate_openai_response_to_anthropic_cache_tokens_from_prompt_tokens_ prompt_tokens=100, completion_tokens=50, total_tokens=150, - prompt_tokens_details=PromptTokensDetailsWrapper( - cached_tokens=30 - ), + prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=30), ) response = ModelResponse( @@ -1975,8 +2020,7 @@ def test_is_web_search_tool(): def test_translate_anthropic_to_openai_with_web_search_tool(): - """ - Test that Anthropic web search tools are converted to web_search_options parameter. + """Test that Anthropic web search tools are converted to web_search_options parameter. When a user sends an Anthropic /v1/messages request with {"type": "web_search_20260209"} tool, it should be transformed to OpenAI format with web_search_options: {} parameter. @@ -2017,8 +2061,7 @@ def test_translate_anthropic_to_openai_with_web_search_tool(): def test_translate_anthropic_to_openai_with_mixed_tools(): - """ - Test that web search tools are separated from regular tools. + """Test that web search tools are separated from regular tools. When a request has both web search tools and regular function tools, only the regular tools should be in the tools array, and web_search_options @@ -2045,9 +2088,7 @@ def test_translate_anthropic_to_openai_with_mixed_tools(): "description": "Get weather information", "input_schema": { "type": "object", - "properties": { - "location": {"type": "string"} - }, + "properties": {"location": {"type": "string"}}, }, }, ], @@ -2117,8 +2158,15 @@ class TestTranslateAnthropicOutputFormatToOpenAI: assert schema["required"] == ["user"] assert schema["properties"]["user"]["additionalProperties"] is False assert schema["properties"]["user"]["required"] == ["name", "address"] - assert schema["properties"]["user"]["properties"]["address"]["additionalProperties"] is False - assert schema["properties"]["user"]["properties"]["address"]["required"] == ["city"] + assert ( + schema["properties"]["user"]["properties"]["address"][ + "additionalProperties" + ] + is False + ) + assert schema["properties"]["user"]["properties"]["address"]["required"] == [ + "city" + ] def test_array_items_object_adds_additional_properties_false(self): output_format = { @@ -2193,6 +2241,16 @@ class TestTranslateAnthropicOutputFormatToOpenAI: assert sorted(schema["required"]) == ["age", "email", "name"] def test_invalid_output_format_returns_none(self): - assert self.adapter.translate_anthropic_output_format_to_openai("invalid") is None - assert self.adapter.translate_anthropic_output_format_to_openai({"type": "text"}) is None - assert self.adapter.translate_anthropic_output_format_to_openai({"type": "json_schema"}) is None + assert ( + self.adapter.translate_anthropic_output_format_to_openai("invalid") is None + ) + assert ( + self.adapter.translate_anthropic_output_format_to_openai({"type": "text"}) + is None + ) + assert ( + self.adapter.translate_anthropic_output_format_to_openai( + {"type": "json_schema"} + ) + is None + ) diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py index 598513451e7..e9c7adc0462 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py @@ -547,3 +547,39 @@ def test_anthropic_messages_handler_strips_leaked_think_tags_from_completion_pat assert result["content"][0]["text"] == "tool-loop-ok" assert result["content"][1]["type"] == "tool_use" assert result["content"][1]["name"] == "echo_status" + + +def test_sanitize_think_tag_text_blocks_preserves_empty_blocks_without_mutation(): + from litellm.llms.anthropic.experimental_pass_through.messages.handler import ( + _sanitize_think_tag_text_blocks, + ) + from litellm.types.llms.anthropic_messages.anthropic_response import ( + AnthropicMessagesResponse, + ) + + response = AnthropicMessagesResponse( + id="msg_test_empty", + type="message", + role="assistant", + content=[ + {"type": "text", "text": "Thinking...\n"}, + { + "type": "tool_use", + "id": "toolu_01EMPTY", + "name": "echo_status", + "input": {"status": "ok"}, + }, + ], + model="custom-provider/test-model", + stop_reason="tool_use", + usage={"input_tokens": 10, "output_tokens": 20}, + ) + + sanitized = _sanitize_think_tag_text_blocks(response) + + assert sanitized is not response + assert response["content"][0]["text"] == "Thinking...\n" + assert sanitized["content"][0]["type"] == "text" + assert sanitized["content"][0]["text"] == "" + assert len(sanitized["content"]) == 2 + assert sanitized["content"][1]["type"] == "tool_use"