fix(anthropic): address review feedback on think sanitization

This commit is contained in:
Gennaro Malafronte 2026-04-23 19:53:34 +02:00
parent 415264ff62
commit bd87086bfb
4 changed files with 336 additions and 264 deletions

View file

@ -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': <original_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,

View file

@ -71,13 +71,13 @@ def _sanitize_think_tag_text_blocks(
continue
cleaned_text = text.split("</think>", 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 ###################

View file

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

View file

@ -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</think>"},
{
"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</think>"
assert sanitized["content"][0]["type"] == "text"
assert sanitized["content"][0]["text"] == ""
assert len(sanitized["content"]) == 2
assert sanitized["content"][1]["type"] == "tool_use"