mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-23 05:34:41 +00:00
Merge 328866c020 into 4bebcdf757
This commit is contained in:
commit
6ff641e03a
8 changed files with 231 additions and 302 deletions
|
|
@ -2017,11 +2017,11 @@ def strip_prefill_prefix_field(message: AllMessageValues) -> AllMessageValues:
|
|||
return _message_without_fields(message, frozenset({"prefix"}))
|
||||
|
||||
|
||||
def filter_value_from_dict(dictionary: dict, key: str, depth: int = 0) -> Any:
|
||||
def filter_value_from_dict(dictionary: dict, key: str, *extra_keys: str, depth: int = 0) -> Any:
|
||||
"""
|
||||
Filters a value from a dictionary
|
||||
|
||||
Goes through the nested dict and removes the key if it exists
|
||||
Goes through the nested dict and removes the key (plus any extra keys) if it exists
|
||||
"""
|
||||
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH
|
||||
|
||||
|
|
@ -2032,14 +2032,14 @@ def filter_value_from_dict(dictionary: dict, key: str, depth: int = 0) -> Any:
|
|||
keys: Final = list(dictionary.keys())
|
||||
for k in keys:
|
||||
v = dictionary[k]
|
||||
if k == key:
|
||||
if k == key or k in extra_keys:
|
||||
del dictionary[k]
|
||||
elif isinstance(v, dict):
|
||||
filter_value_from_dict(v, key, depth + 1)
|
||||
filter_value_from_dict(v, key, *extra_keys, depth=depth + 1)
|
||||
elif isinstance(v, list):
|
||||
for item in v:
|
||||
if isinstance(item, dict):
|
||||
filter_value_from_dict(item, key, depth + 1)
|
||||
filter_value_from_dict(item, key, *extra_keys, depth=depth + 1)
|
||||
return dictionary
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -534,6 +534,7 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
role="tool",
|
||||
tool_call_id=content.get("tool_use_id", ""),
|
||||
content=self._tool_result_content(content.get("content")),
|
||||
**({"is_error": True} if "is_error" in content and content["is_error"] is True else {}),
|
||||
)
|
||||
self._add_cache_control_if_applicable(content, tool_result, model)
|
||||
tool_message_list.append(tool_result)
|
||||
|
|
|
|||
|
|
@ -422,9 +422,13 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig):
|
|||
from litellm.types.llms.openai import ChatCompletionToolParam
|
||||
|
||||
for i, message in enumerate(messages):
|
||||
# is_error: litellm's anthropic adapter carries tool_result.is_error
|
||||
# on the intermediate tool message so error-aware translations can
|
||||
# restore it; real OpenAI has no such field (#44979). Stripped in
|
||||
# the same in-place pass as cache_control.
|
||||
messages[i] = cast(
|
||||
AllMessageValues,
|
||||
filter_value_from_dict(message, "cache_control"),
|
||||
filter_value_from_dict(message, "cache_control", "is_error"),
|
||||
)
|
||||
if tools is not None:
|
||||
for i, tool in enumerate(tools):
|
||||
|
|
|
|||
|
|
@ -47066,6 +47066,10 @@
|
|||
],
|
||||
"title": "Content"
|
||||
},
|
||||
"is_error": {
|
||||
"title": "Is Error",
|
||||
"type": "boolean"
|
||||
},
|
||||
"role": {
|
||||
"const": "tool",
|
||||
"title": "Role",
|
||||
|
|
|
|||
|
|
@ -918,6 +918,10 @@ class ChatCompletionToolMessage(TypedDict):
|
|||
role: Literal["tool"]
|
||||
content: str | Iterable[ToolMessageContentPart] # writable-ok: Pydantic warns on ReadOnly TypedDict fields
|
||||
tool_call_id: str
|
||||
# litellm-internal carrier for Anthropic tool_result.is_error, so the flag
|
||||
# survives translation to the OpenAI shape and downstream provider
|
||||
# translations can restore it. Not an OpenAI wire field.
|
||||
is_error: NotRequired[bool] # writable-ok: Pydantic warns on ReadOnly TypedDict fields
|
||||
|
||||
|
||||
class ChatCompletionFunctionMessage(TypedDict):
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import pytest
|
|||
import litellm
|
||||
|
||||
|
||||
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
TOOL_RESULT_IMAGE_PLACEHOLDER,
|
||||
encrypted_reasoning_signature,
|
||||
|
|
@ -63,10 +62,17 @@ def test_translate_openai_response_to_anthropic_empty_choices() -> None:
|
|||
assert result["usage"]["input_tokens"] == 10
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text,count,expected_stop", [
|
||||
("", 1, "compaction"), (None, 1, "compaction"), ("Answer", 1, "max_tokens"),
|
||||
(" ", 1, "max_tokens"), ("", 2, "max_tokens"), ("", 0, "max_tokens"),
|
||||
])
|
||||
@pytest.mark.parametrize(
|
||||
"text,count,expected_stop",
|
||||
[
|
||||
("", 1, "compaction"),
|
||||
(None, 1, "compaction"),
|
||||
("Answer", 1, "max_tokens"),
|
||||
(" ", 1, "max_tokens"),
|
||||
("", 2, "max_tokens"),
|
||||
("", 0, "max_tokens"),
|
||||
],
|
||||
)
|
||||
def test_native_compaction_response_roundtrip(text: str | None, count: int, expected_stop: str) -> None:
|
||||
block: Final = {"type": "compaction", "content": "Exact summary", "signature": "opaque-signature"}
|
||||
message: Final = Message(content=text, provider_specific_fields={"compaction_blocks": [block] * count})
|
||||
|
|
@ -175,9 +181,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_content_block():
|
|||
tool_calls=[
|
||||
ChatCompletionDeltaToolCall(
|
||||
id="call_d581d130-e234-4315-94e8-27e7ff7c4e55",
|
||||
function=Function(
|
||||
arguments='{"location": "Boston"}', name="get_weather"
|
||||
),
|
||||
function=Function(arguments='{"location": "Boston"}', name="get_weather"),
|
||||
type="function",
|
||||
index=0,
|
||||
)
|
||||
|
|
@ -221,9 +225,7 @@ def test_translate_streaming_openai_chunk_strips_gemini_thought_from_tool_call_i
|
|||
tool_calls=[
|
||||
ChatCompletionDeltaToolCall(
|
||||
id=combined,
|
||||
function=Function(
|
||||
arguments='{"a": 17, "b": 25}', name="add_numbers"
|
||||
),
|
||||
function=Function(arguments='{"a": 17, "b": 25}', name="add_numbers"),
|
||||
type="function",
|
||||
index=0,
|
||||
)
|
||||
|
|
@ -460,10 +462,7 @@ def test_translate_anthropic_messages_to_openai_thinking_blocks():
|
|||
assert "thinking_blocks" in result[1]
|
||||
assert len(result[1]["thinking_blocks"]) == 2
|
||||
assert result[1]["thinking_blocks"][0]["type"] == "thinking"
|
||||
assert (
|
||||
result[1]["thinking_blocks"][0]["thinking"]
|
||||
== "I will call the get_weather tool."
|
||||
)
|
||||
assert result[1]["thinking_blocks"][0]["thinking"] == "I will call the get_weather tool."
|
||||
assert result[1]["thinking_blocks"][0]["signature"] == "sigsig"
|
||||
assert result[1]["thinking_blocks"][1]["type"] == "redacted_thinking"
|
||||
assert result[1]["thinking_blocks"][1]["data"] == "REDACTED"
|
||||
|
|
@ -603,9 +602,7 @@ 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"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -1071,9 +1068,7 @@ def test_translate_anthropic_to_openai_skips_prompt_cache_key_when_provider_lack
|
|||
|
||||
|
||||
def test_translate_anthropic_to_openai_skips_prompt_cache_key_for_chained_litellm_proxy():
|
||||
assert "prompt_cache_key" in litellm.get_supported_openai_params(
|
||||
model="xai", custom_llm_provider="litellm_proxy"
|
||||
)
|
||||
assert "prompt_cache_key" in litellm.get_supported_openai_params(model="xai", custom_llm_provider="litellm_proxy")
|
||||
openai_request = _translate_with_metadata("litellm_proxy/xai", {"user_id": CLAUDE_CODE_USER_ID}, "litellm_proxy")
|
||||
assert openai_request["user"] == CLAUDE_CODE_USER_ID
|
||||
assert "prompt_cache_key" not in openai_request
|
||||
|
|
@ -1118,7 +1113,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
|
||||
),
|
||||
)
|
||||
],
|
||||
|
|
@ -1133,9 +1129,7 @@ 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"
|
||||
assert "provider_specific_fields" not in result[0]
|
||||
|
||||
|
||||
|
|
@ -1259,9 +1253,7 @@ def test_translate_openai_response_to_anthropic_text_and_tool_calls():
|
|||
ChatCompletionAssistantToolCall(
|
||||
id="call_tool_combo",
|
||||
type="function",
|
||||
function=Function(
|
||||
name="get_weather", arguments='{"location": "Paris"}'
|
||||
),
|
||||
function=Function(name="get_weather", arguments='{"location": "Paris"}'),
|
||||
)
|
||||
],
|
||||
),
|
||||
|
|
@ -1271,9 +1263,7 @@ def test_translate_openai_response_to_anthropic_text_and_tool_calls():
|
|||
)
|
||||
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
anthropic_response = adapter.translate_openai_response_to_anthropic(
|
||||
response=openai_response
|
||||
)
|
||||
anthropic_response = adapter.translate_openai_response_to_anthropic(response=openai_response)
|
||||
|
||||
anthropic_content = anthropic_response.get("content")
|
||||
assert anthropic_content is not None
|
||||
|
|
@ -1314,9 +1304,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_with_partial_json():
|
|||
(
|
||||
type_of_content,
|
||||
content_block_delta,
|
||||
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(
|
||||
choices=choices
|
||||
)
|
||||
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(choices=choices)
|
||||
|
||||
print("Type of content:", type_of_content)
|
||||
print("Content block delta:", content_block_delta)
|
||||
|
|
@ -1425,9 +1413,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_thinking_delta():
|
|||
(
|
||||
type_of_content,
|
||||
content_block_delta,
|
||||
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(
|
||||
choices=choices
|
||||
)
|
||||
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(choices=choices)
|
||||
|
||||
assert type_of_content == "thinking_delta"
|
||||
assert content_block_delta["type"] == "thinking_delta"
|
||||
|
|
@ -1470,9 +1456,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_with_thinking():
|
|||
(
|
||||
type_of_content,
|
||||
content_block_delta,
|
||||
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(
|
||||
choices=choices
|
||||
)
|
||||
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(choices=choices)
|
||||
|
||||
assert type_of_content == "signature_delta"
|
||||
assert content_block_delta["type"] == "signature_delta"
|
||||
|
|
@ -1576,9 +1560,7 @@ def test_translate_anthropic_messages_to_openai_user_message_with_base64_image()
|
|||
# Check image content
|
||||
assert result[0]["content"][1]["type"] == "image_url"
|
||||
assert "image_url" in result[0]["content"][1]
|
||||
assert result[0]["content"][1]["image_url"]["url"].startswith(
|
||||
"data:image/png;base64,"
|
||||
)
|
||||
assert result[0]["content"][1]["image_url"]["url"].startswith("data:image/png;base64,")
|
||||
assert (
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
in result[0]["content"][1]["image_url"]["url"]
|
||||
|
|
@ -1616,18 +1598,14 @@ def test_translate_anthropic_messages_to_openai_user_message_with_url_image():
|
|||
# Check image content
|
||||
assert result[0]["content"][1]["type"] == "image_url"
|
||||
assert "image_url" in result[0]["content"][1]
|
||||
assert (
|
||||
result[0]["content"][1]["image_url"]["url"] == "https://example.com/forest.jpg"
|
||||
)
|
||||
assert result[0]["content"][1]["image_url"]["url"] == "https://example.com/forest.jpg"
|
||||
|
||||
|
||||
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"}]
|
||||
),
|
||||
AnthropicMessagesUserMessageParam(role="user", content=[{"type": "text", "text": "Take a screenshot"}]),
|
||||
AnthopicMessagesAssistantMessageParam(
|
||||
role="assistant",
|
||||
content=[
|
||||
|
|
@ -1779,9 +1757,7 @@ def test_translate_anthropic_messages_to_openai_mixed_content_with_image():
|
|||
|
||||
# Check first image (base64)
|
||||
assert result[0]["content"][1]["type"] == "image_url"
|
||||
assert result[0]["content"][1]["image_url"]["url"].startswith(
|
||||
"data:image/png;base64,"
|
||||
)
|
||||
assert result[0]["content"][1]["image_url"]["url"].startswith("data:image/png;base64,")
|
||||
|
||||
# Check middle text
|
||||
assert result[0]["content"][2]["type"] == "text"
|
||||
|
|
@ -1789,9 +1765,7 @@ def test_translate_anthropic_messages_to_openai_mixed_content_with_image():
|
|||
|
||||
# Check second image (URL)
|
||||
assert result[0]["content"][3]["type"] == "image_url"
|
||||
assert (
|
||||
result[0]["content"][3]["image_url"]["url"] == "https://example.com/image2.jpg"
|
||||
)
|
||||
assert result[0]["content"][3]["image_url"]["url"] == "https://example.com/image2.jpg"
|
||||
|
||||
# Check final text
|
||||
assert result[0]["content"][4]["type"] == "text"
|
||||
|
|
@ -1837,10 +1811,7 @@ def test_translate_anthropic_messages_to_openai_tool_use_with_signature():
|
|||
assert tool_call["id"] == "call_386f67af31f9415781bc35071405"
|
||||
assert "function" in tool_call
|
||||
assert "provider_specific_fields" in tool_call["function"]
|
||||
assert (
|
||||
tool_call["function"]["provider_specific_fields"]["thought_signature"]
|
||||
== test_signature
|
||||
)
|
||||
assert tool_call["function"]["provider_specific_fields"]["thought_signature"] == test_signature
|
||||
|
||||
|
||||
def test_translate_anthropic_messages_to_openai_tool_result_with_multiple_content_items():
|
||||
|
|
@ -1898,9 +1869,7 @@ def test_translate_anthropic_messages_to_openai_tool_result_with_multiple_conten
|
|||
result = adapter.translate_anthropic_messages_to_openai(messages=anthropic_messages)
|
||||
|
||||
# Count how many tool messages have the same tool_call_id
|
||||
tool_messages = [
|
||||
msg for msg in result if isinstance(msg, dict) and msg.get("role") == "tool"
|
||||
]
|
||||
tool_messages = [msg for msg in result if isinstance(msg, dict) and msg.get("role") == "tool"]
|
||||
tool_call_ids = [msg.get("tool_call_id") for msg in tool_messages]
|
||||
|
||||
# The critical assertion: each tool_call_id should appear only ONCE
|
||||
|
|
@ -1916,12 +1885,8 @@ 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"
|
||||
|
|
@ -1970,17 +1935,14 @@ def test_translate_anthropic_messages_to_openai_tool_result_single_item_backward
|
|||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
result = adapter.translate_anthropic_messages_to_openai(messages=anthropic_messages)
|
||||
|
||||
tool_messages = [
|
||||
msg for msg in result if isinstance(msg, dict) and msg.get("role") == "tool"
|
||||
]
|
||||
tool_messages = [msg for msg in result if isinstance(msg, dict) and msg.get("role") == "tool"]
|
||||
|
||||
assert len(tool_messages) == 1
|
||||
tool_message = tool_messages[0]
|
||||
|
||||
# Single item should be a string for backward compatibility
|
||||
assert isinstance(tool_message["content"], str), (
|
||||
f"Single content item should be a string for backward compatibility, "
|
||||
f"got {type(tool_message['content'])}"
|
||||
f"Single content item should be a string for backward compatibility, got {type(tool_message['content'])}"
|
||||
)
|
||||
assert tool_message["content"] == "72°F and sunny"
|
||||
|
||||
|
|
@ -2084,15 +2046,12 @@ def test_streaming_chunk_with_text_and_empty_tool_calls_returns_text_delta():
|
|||
# ============================================================================
|
||||
|
||||
# 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"
|
||||
# Bedrock Application Inference Profile ARN: the string contains neither
|
||||
# "anthropic" nor "claude", so the model can only be recognized via its ARN shape
|
||||
CACHE_CONTROL_BEDROCK_ARN_MODEL = (
|
||||
"bedrock/converse/arn:aws:bedrock:us-east-1:123456789012:"
|
||||
"application-inference-profile/abcdef123456"
|
||||
"bedrock/converse/arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abcdef123456"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -2108,9 +2067,7 @@ 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
|
||||
|
||||
|
|
@ -2125,9 +2082,7 @@ def test_should_not_add_cache_control_for_non_anthropic_model():
|
|||
"openai/gpt-4-turbo",
|
||||
]:
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -2142,9 +2097,7 @@ def test_should_add_cache_control_for_gemini_model():
|
|||
"vertex_ai/gemini-2.5-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 target.get("cache_control") == cache_control
|
||||
|
||||
|
||||
|
|
@ -2182,9 +2135,7 @@ def test_should_not_add_cache_control_when_none():
|
|||
{},
|
||||
]:
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -2195,9 +2146,7 @@ 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
|
||||
|
||||
|
||||
|
|
@ -2303,12 +2252,7 @@ def test_cache_control_fix_does_not_broaden_claude_detection():
|
|||
make is_anthropic_claude_model treat ARN profiles as Claude, which would route
|
||||
thinking params through unmodified and break non-Claude Bedrock profiles.
|
||||
"""
|
||||
assert (
|
||||
LiteLLMAnthropicMessagesAdapter.is_anthropic_claude_model(
|
||||
CACHE_CONTROL_BEDROCK_ARN_MODEL
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert LiteLLMAnthropicMessagesAdapter.is_anthropic_claude_model(CACHE_CONTROL_BEDROCK_ARN_MODEL) is False
|
||||
|
||||
|
||||
def test_thinking_preserved_for_bedrock_arn_inference_profile():
|
||||
|
|
@ -2906,9 +2850,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_reasoning_content_without
|
|||
(
|
||||
type_of_content,
|
||||
content_block_delta,
|
||||
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(
|
||||
choices=choices
|
||||
)
|
||||
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(choices=choices)
|
||||
|
||||
assert type_of_content == "thinking_delta"
|
||||
assert content_block_delta["type"] == "thinking_delta"
|
||||
|
|
@ -2940,9 +2882,7 @@ def test_translate_openai_response_to_anthropic_with_reasoning_content_only():
|
|||
)
|
||||
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
anthropic_response = adapter.translate_openai_response_to_anthropic(
|
||||
response=openai_response
|
||||
)
|
||||
anthropic_response = adapter.translate_openai_response_to_anthropic(response=openai_response)
|
||||
|
||||
anthropic_content = anthropic_response.get("content")
|
||||
assert anthropic_content is not None
|
||||
|
|
@ -2955,9 +2895,7 @@ def test_translate_openai_response_to_anthropic_with_reasoning_content_only():
|
|||
|
||||
# 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"
|
||||
|
||||
|
|
@ -3009,9 +2947,7 @@ 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)
|
||||
|
|
@ -3031,9 +2967,7 @@ 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},
|
||||
|
|
@ -3058,9 +2992,7 @@ def test_translate_anthropic_tools_with_long_names():
|
|||
]
|
||||
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
result, tool_name_mapping = adapter.translate_anthropic_tools_to_openai(
|
||||
tools=tools, model="gpt-4"
|
||||
)
|
||||
result, tool_name_mapping = adapter.translate_anthropic_tools_to_openai(tools=tools, model="gpt-4")
|
||||
|
||||
assert len(result) == 1
|
||||
# The tool name should be truncated
|
||||
|
|
@ -3082,9 +3014,7 @@ def test_translate_anthropic_tools_mixed_names():
|
|||
]
|
||||
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
result, tool_name_mapping = adapter.translate_anthropic_tools_to_openai(
|
||||
tools=tools, model="gpt-4"
|
||||
)
|
||||
result, tool_name_mapping = adapter.translate_anthropic_tools_to_openai(tools=tools, model="gpt-4")
|
||||
|
||||
assert len(result) == 2
|
||||
# Short name unchanged
|
||||
|
|
@ -3098,9 +3028,7 @@ 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}
|
||||
|
||||
|
|
@ -3132,9 +3060,7 @@ def test_translate_openai_response_restores_tool_names():
|
|||
)
|
||||
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
result = adapter.translate_openai_response_to_anthropic(
|
||||
response=response, tool_name_mapping=tool_name_mapping
|
||||
)
|
||||
result = adapter.translate_openai_response_to_anthropic(response=response, tool_name_mapping=tool_name_mapping)
|
||||
|
||||
# Find the tool_use block in the response
|
||||
tool_use_blocks = [c for c in result["content"] if c.get("type") == "tool_use"]
|
||||
|
|
@ -3555,9 +3481,7 @@ def test_translate_streaming_openai_response_to_anthropic_cache_tokens_with_appl
|
|||
assert message_delta["usage"]["output_tokens"] == 50
|
||||
assert message_delta["usage"]["cache_read_input_tokens"] == 30
|
||||
assert message_delta["usage"]["cache_creation_input_tokens"] == 20
|
||||
assert message_delta["context_management"]["applied_edits"][0]["type"] == (
|
||||
"compact_20260112"
|
||||
)
|
||||
assert message_delta["context_management"]["applied_edits"][0]["type"] == ("compact_20260112")
|
||||
|
||||
|
||||
# =====================================================================
|
||||
|
|
@ -3606,9 +3530,7 @@ def test_translate_anthropic_client_web_search_preserves_schema_and_choice(schem
|
|||
translated, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai(request)
|
||||
|
||||
assert "web_search_options" not in translated
|
||||
assert translated["tools"] == [
|
||||
{"type": "function", "function": {"name": "web_search", "parameters": schema}}
|
||||
]
|
||||
assert translated["tools"] == [{"type": "function", "function": {"name": "web_search", "parameters": schema}}]
|
||||
assert translated["tool_choice"] == {"type": "function", "function": {"name": "web_search"}}
|
||||
|
||||
|
||||
|
|
@ -3753,15 +3675,8 @@ 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 = {
|
||||
|
|
@ -3836,19 +3751,9 @@ 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
|
||||
|
||||
|
||||
class TestAnthropicStreamWrapperToolArgs:
|
||||
|
|
@ -4052,9 +3957,7 @@ def test_translate_openai_response_to_anthropic_with_polyfill_compaction_block()
|
|||
)
|
||||
response = _make_simple_openai_response(text="Hello after compaction.")
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
result = adapter.translate_openai_response_to_anthropic(
|
||||
response=response, polyfill_result=polyfill
|
||||
)
|
||||
result = adapter.translate_openai_response_to_anthropic(response=response, polyfill_result=polyfill)
|
||||
|
||||
content = result.get("content")
|
||||
assert content is not None
|
||||
|
|
@ -4086,9 +3989,7 @@ def test_translate_openai_response_to_anthropic_with_polyfill_iterations_usage()
|
|||
)
|
||||
response = _make_simple_openai_response(prompt_tokens=100, completion_tokens=30)
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
result = adapter.translate_openai_response_to_anthropic(
|
||||
response=response, polyfill_result=polyfill
|
||||
)
|
||||
result = adapter.translate_openai_response_to_anthropic(response=response, polyfill_result=polyfill)
|
||||
|
||||
usage = result.get("usage")
|
||||
assert usage is not None
|
||||
|
|
@ -4143,13 +4044,9 @@ def test_translate_openai_response_to_anthropic_with_polyfill_both_compaction_an
|
|||
{"type": "compaction", "input_tokens": 300, "output_tokens": 75},
|
||||
],
|
||||
)
|
||||
response = _make_simple_openai_response(
|
||||
text="After compaction.", prompt_tokens=120, completion_tokens=40
|
||||
)
|
||||
response = _make_simple_openai_response(text="After compaction.", prompt_tokens=120, completion_tokens=40)
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
result = adapter.translate_openai_response_to_anthropic(
|
||||
response=response, polyfill_result=polyfill
|
||||
)
|
||||
result = adapter.translate_openai_response_to_anthropic(response=response, polyfill_result=polyfill)
|
||||
|
||||
# compaction block must come first
|
||||
content = result.get("content")
|
||||
|
|
@ -4251,7 +4148,9 @@ def test_translate_anthropic_tools_to_openai_omits_unset_strict():
|
|||
assert function["parameters"]["required"] == ["query"]
|
||||
|
||||
|
||||
TOOL_RESULT_IMAGE_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
TOOL_RESULT_IMAGE_B64 = (
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
)
|
||||
TOOL_RESULT_IMAGE_URL = "https://example.com/screenshot.png"
|
||||
|
||||
|
||||
|
|
@ -4259,8 +4158,7 @@ def _anthropic_tool_use_turn(*tool_use_ids):
|
|||
return AnthopicMessagesAssistantMessageParam(
|
||||
role="assistant",
|
||||
content=[
|
||||
{"type": "tool_use", "id": tid, "name": "read_file", "input": {"path": "img.png"}}
|
||||
for tid in tool_use_ids
|
||||
{"type": "tool_use", "id": tid, "name": "read_file", "input": {"path": "img.png"}} for tid in tool_use_ids
|
||||
],
|
||||
)
|
||||
|
||||
|
|
@ -4380,9 +4278,7 @@ def test_tool_result_parallel_tool_calls_keep_tool_message_adjacency():
|
|||
result = _run_chat_completions_pipeline(
|
||||
[
|
||||
_anthropic_tool_use_turn("toolu_01", "toolu_02"),
|
||||
_anthropic_tool_result_turn(
|
||||
{"toolu_01": [_base64_image_block()], "toolu_02": [_url_image_block()]}
|
||||
),
|
||||
_anthropic_tool_result_turn({"toolu_01": [_base64_image_block()], "toolu_02": [_url_image_block()]}),
|
||||
]
|
||||
)
|
||||
|
||||
|
|
@ -4573,10 +4469,7 @@ def test_tool_result_text_and_document_reach_bedrock_converse_tool_result():
|
|||
)
|
||||
|
||||
tool_results = [
|
||||
block["toolResult"]
|
||||
for message in converse_messages
|
||||
for block in message["content"]
|
||||
if "toolResult" in block
|
||||
block["toolResult"] for message in converse_messages for block in message["content"] if "toolResult" in block
|
||||
]
|
||||
assert len(tool_results) == 1
|
||||
documents = [part["document"] for part in tool_results[0]["content"] if "document" in part]
|
||||
|
|
@ -4635,7 +4528,9 @@ def test_translate_anthropic_to_openai_without_prompt_cache_breakpoint_adds_noth
|
|||
def test_translate_anthropic_messages_to_openai_carries_midturn_system_prompt_cache_breakpoint():
|
||||
explicit = {"mode": "explicit"}
|
||||
result = LiteLLMAnthropicMessagesAdapter().translate_anthropic_messages_to_openai(
|
||||
messages=[{"role": "system", "content": [{"type": "text", "text": "fix", "prompt_cache_breakpoint": explicit}]}],
|
||||
messages=[
|
||||
{"role": "system", "content": [{"type": "text", "text": "fix", "prompt_cache_breakpoint": explicit}]}
|
||||
],
|
||||
model="gpt-5.6",
|
||||
)
|
||||
assert result == [
|
||||
|
|
@ -5235,3 +5130,58 @@ def test_eager_input_streaming_tool_reaches_bedrock_converse_as_beta():
|
|||
|
||||
assert data["additionalModelRequestFields"]["anthropic_beta"] == ["fine-grained-tool-streaming-2025-05-14"]
|
||||
assert data["toolConfig"]["tools"][0]["toolSpec"]["inputSchema"]["json"] == EAGER_INPUT_SCHEMA
|
||||
|
||||
|
||||
def test_translate_anthropic_messages_preserves_tool_result_is_error():
|
||||
messages: Final = [
|
||||
AnthropicMessagesUserMessageParam(
|
||||
role="user",
|
||||
content=[
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "toolu_error",
|
||||
"is_error": True,
|
||||
"content": "ToolNotFound: get_weather is not available",
|
||||
},
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
result: Final = LiteLLMAnthropicMessagesAdapter().translate_anthropic_messages_to_openai(messages=messages)
|
||||
|
||||
tool_messages: Final = [m for m in result if isinstance(m, dict) and m.get("role") == "tool"]
|
||||
assert len(tool_messages) == 1
|
||||
# the error bit must survive the first hop so downstream provider
|
||||
# translations (e.g. Gemini's functionResponse error) can restore it
|
||||
assert tool_messages[0] == {
|
||||
"role": "tool",
|
||||
"tool_call_id": "toolu_error",
|
||||
"content": "ToolNotFound: get_weather is not available",
|
||||
"is_error": True,
|
||||
}
|
||||
|
||||
|
||||
def test_translate_anthropic_messages_omits_is_error_for_plain_tool_results():
|
||||
messages: Final = [
|
||||
AnthropicMessagesUserMessageParam(
|
||||
role="user",
|
||||
content=[
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "toolu_ok",
|
||||
"content": "Sunny, 75°F",
|
||||
},
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
result: Final = LiteLLMAnthropicMessagesAdapter().translate_anthropic_messages_to_openai(messages=messages)
|
||||
|
||||
tool_messages: Final = [m for m in result if isinstance(m, dict) and m.get("role") == "tool"]
|
||||
assert len(tool_messages) == 1
|
||||
# non-error tool results keep their existing shape byte-for-byte
|
||||
assert tool_messages[0] == {
|
||||
"role": "tool",
|
||||
"tool_call_id": "toolu_ok",
|
||||
"content": "Sunny, 75°F",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,9 +41,7 @@ class TestOpenAIGPTConfig:
|
|||
be included in supported params so it reaches OpenAI and SpendLogs.
|
||||
"""
|
||||
# responses/gpt-4.1-mini should support 'user' just like gpt-4.1-mini
|
||||
supported_params = self.config.get_supported_openai_params(
|
||||
"responses/gpt-4.1-mini"
|
||||
)
|
||||
supported_params = self.config.get_supported_openai_params("responses/gpt-4.1-mini")
|
||||
assert "user" in supported_params
|
||||
|
||||
supported_params = self.config.get_supported_openai_params("responses/gpt-4o")
|
||||
|
|
@ -60,9 +58,7 @@ class TestOpenAIGPTConfig:
|
|||
"""
|
||||
# Both should have the same supported params
|
||||
regular_params = self.config.get_supported_openai_params("gpt-4.1-mini")
|
||||
responses_params = self.config.get_supported_openai_params(
|
||||
"responses/gpt-4.1-mini"
|
||||
)
|
||||
responses_params = self.config.get_supported_openai_params("responses/gpt-4.1-mini")
|
||||
|
||||
# 'user' should be in both
|
||||
assert "user" in regular_params
|
||||
|
|
@ -80,9 +76,7 @@ class TestOpenAIGPTConfig:
|
|||
"tool_choice",
|
||||
]
|
||||
|
||||
supported_params = self.config.get_supported_openai_params(
|
||||
"responses/gpt-4.1-mini"
|
||||
)
|
||||
supported_params = self.config.get_supported_openai_params("responses/gpt-4.1-mini")
|
||||
|
||||
for param in base_expected_params:
|
||||
assert param in supported_params, f"Expected '{param}' in supported params"
|
||||
|
|
@ -192,9 +186,7 @@ class TestGetOptionalParamsIntegration:
|
|||
reasoning_effort must be forwarded so the server decides support."""
|
||||
from litellm.llms.openai.openai import OpenAIConfig
|
||||
|
||||
supported_params = OpenAIConfig().get_supported_openai_params(
|
||||
"my-claude-alias"
|
||||
)
|
||||
supported_params = OpenAIConfig().get_supported_openai_params("my-claude-alias")
|
||||
assert "reasoning_effort" in supported_params
|
||||
|
||||
def test_reasoning_effort_not_supported_for_known_non_reasoning_models(self):
|
||||
|
|
@ -203,9 +195,7 @@ class TestGetOptionalParamsIntegration:
|
|||
|
||||
config = OpenAIConfig()
|
||||
assert "reasoning_effort" not in config.get_supported_openai_params("gpt-4o")
|
||||
assert "reasoning_effort" not in config.get_supported_openai_params(
|
||||
"responses/gpt-4.1-mini"
|
||||
)
|
||||
assert "reasoning_effort" not in config.get_supported_openai_params("responses/gpt-4.1-mini")
|
||||
|
||||
def test_reasoning_effort_not_inherited_by_openai_compatible_subclasses(self):
|
||||
"""Providers subclassing either openai config keep their own reasoning_effort gating
|
||||
|
|
@ -218,12 +208,8 @@ class TestGetOptionalParamsIntegration:
|
|||
class InheritingGPTConfig(OpenAIGPTConfig):
|
||||
pass
|
||||
|
||||
assert "reasoning_effort" not in InheritingDispatcherConfig().get_supported_openai_params(
|
||||
"some-unknown-model"
|
||||
)
|
||||
assert "reasoning_effort" not in InheritingGPTConfig().get_supported_openai_params(
|
||||
"some-unknown-model"
|
||||
)
|
||||
assert "reasoning_effort" not in InheritingDispatcherConfig().get_supported_openai_params("some-unknown-model")
|
||||
assert "reasoning_effort" not in InheritingGPTConfig().get_supported_openai_params("some-unknown-model")
|
||||
|
||||
def test_reasoning_effort_forwarded_in_optional_params_for_unknown_model_alias(
|
||||
self,
|
||||
|
|
@ -259,9 +245,7 @@ class TestOpenAIChatCompletionStreamingHandler:
|
|||
Test that chunk_parser preserves the usage field from streaming chunks.
|
||||
|
||||
"""
|
||||
handler = OpenAIChatCompletionStreamingHandler(
|
||||
streaming_response=None, sync_stream=True
|
||||
)
|
||||
handler = OpenAIChatCompletionStreamingHandler(streaming_response=None, sync_stream=True)
|
||||
|
||||
usage_chunk = {
|
||||
"id": "gen-123",
|
||||
|
|
@ -293,9 +277,7 @@ class TestOpenAIChatCompletionStreamingHandler:
|
|||
"""OpenAI-compatible upstreams serve a service_tier on every streamed
|
||||
chunk; chunk_parser must keep it on the emitted ModelResponseStream so
|
||||
disconnect billing and the reassembled response see the served tier."""
|
||||
handler = OpenAIChatCompletionStreamingHandler(
|
||||
streaming_response=None, sync_stream=True
|
||||
)
|
||||
handler = OpenAIChatCompletionStreamingHandler(streaming_response=None, sync_stream=True)
|
||||
|
||||
tiered_chunk = {
|
||||
"id": "gen-123",
|
||||
|
|
@ -323,9 +305,7 @@ class TestOpenAIChatCompletionStreamingHandler:
|
|||
ends the stream (https://github.com/BerriAI/litellm/issues/25492)."""
|
||||
from litellm.llms.openai.common_utils import OpenAIError
|
||||
|
||||
handler = OpenAIChatCompletionStreamingHandler(
|
||||
streaming_response=None, sync_stream=True
|
||||
)
|
||||
handler = OpenAIChatCompletionStreamingHandler(streaming_response=None, sync_stream=True)
|
||||
|
||||
error_chunk = {
|
||||
"error": {
|
||||
|
|
@ -348,14 +328,10 @@ class TestOpenAIChatCompletionStreamingHandler:
|
|||
"invalid_api_key") or none at all; those must map to 500, not crash."""
|
||||
from litellm.llms.openai.common_utils import OpenAIError
|
||||
|
||||
handler = OpenAIChatCompletionStreamingHandler(
|
||||
streaming_response=None, sync_stream=True
|
||||
)
|
||||
handler = OpenAIChatCompletionStreamingHandler(streaming_response=None, sync_stream=True)
|
||||
|
||||
with pytest.raises(OpenAIError) as excinfo:
|
||||
handler.chunk_parser(
|
||||
{"error": {"message": "engine crashed", "code": "server_error"}}
|
||||
)
|
||||
handler.chunk_parser({"error": {"message": "engine crashed", "code": "server_error"}})
|
||||
assert excinfo.value.status_code == 500
|
||||
assert "engine crashed" in excinfo.value.message
|
||||
|
||||
|
|
@ -372,9 +348,7 @@ class TestOpenAIChatCompletionStreamingHandler:
|
|||
def test_chunk_parser_tolerates_null_error_field(self):
|
||||
"""A chunk that carries "error": null alongside real data must parse
|
||||
normally, not raise."""
|
||||
handler = OpenAIChatCompletionStreamingHandler(
|
||||
streaming_response=None, sync_stream=True
|
||||
)
|
||||
handler = OpenAIChatCompletionStreamingHandler(streaming_response=None, sync_stream=True)
|
||||
|
||||
chunk = {
|
||||
"id": "gen-123",
|
||||
|
|
@ -396,9 +370,7 @@ class TestOpenAIChatCompletionStreamingHandler:
|
|||
|
||||
def test_chunk_parser_without_usage(self):
|
||||
"""Test that chunk_parser works normally for chunks without usage."""
|
||||
handler = OpenAIChatCompletionStreamingHandler(
|
||||
streaming_response=None, sync_stream=True
|
||||
)
|
||||
handler = OpenAIChatCompletionStreamingHandler(streaming_response=None, sync_stream=True)
|
||||
|
||||
chunk = {
|
||||
"id": "gen-123",
|
||||
|
|
@ -430,9 +402,7 @@ class TestOpenAIChatCompletionStreamingHandler:
|
|||
Regression test for: Streaming responses with delta.reasoning field
|
||||
coming back empty when using openai/ or hosted_vllm/ providers.
|
||||
"""
|
||||
handler = OpenAIChatCompletionStreamingHandler(
|
||||
streaming_response=None, sync_stream=True
|
||||
)
|
||||
handler = OpenAIChatCompletionStreamingHandler(streaming_response=None, sync_stream=True)
|
||||
|
||||
# Simulate a chunk with reasoning field (as returned by GLM-5)
|
||||
chunk = {
|
||||
|
|
@ -456,9 +426,7 @@ class TestOpenAIChatCompletionStreamingHandler:
|
|||
parsed_chunk = handler.chunk_parser(chunk)
|
||||
|
||||
# Verify that reasoning was mapped to reasoning_content
|
||||
assert (
|
||||
parsed_chunk.choices[0].delta.reasoning_content == "The capital of France"
|
||||
)
|
||||
assert parsed_chunk.choices[0].delta.reasoning_content == "The capital of France"
|
||||
# Verify that the original 'reasoning' field was removed
|
||||
assert not hasattr(parsed_chunk.choices[0].delta, "reasoning")
|
||||
|
||||
|
|
@ -466,9 +434,7 @@ class TestOpenAIChatCompletionStreamingHandler:
|
|||
"""
|
||||
Test that chunks without reasoning field still work correctly.
|
||||
"""
|
||||
handler = OpenAIChatCompletionStreamingHandler(
|
||||
streaming_response=None, sync_stream=True
|
||||
)
|
||||
handler = OpenAIChatCompletionStreamingHandler(streaming_response=None, sync_stream=True)
|
||||
|
||||
# Simulate a chunk without reasoning field
|
||||
chunk = {
|
||||
|
|
@ -506,9 +472,7 @@ class TestOpenAIChatCompletionStreamingHandler:
|
|||
|
||||
Regression test for: KeyError: 'id' when using MiniMax m2.5 model
|
||||
"""
|
||||
handler = OpenAIChatCompletionStreamingHandler(
|
||||
streaming_response=None, sync_stream=True
|
||||
)
|
||||
handler = OpenAIChatCompletionStreamingHandler(streaming_response=None, sync_stream=True)
|
||||
|
||||
# Simulate a chunk without 'id' field (as returned by MiniMax)
|
||||
chunk = {
|
||||
|
|
@ -625,9 +589,7 @@ class TestGPT5ReasoningEffortPreservation:
|
|||
map_openai_params normalizes all dicts to string. Full dict is restored in main.py
|
||||
when routing to Responses API (test_gpt_5_4_responses_bridge_preserves_reasoning_summary_dict).
|
||||
"""
|
||||
non_default_params = {
|
||||
"reasoning_effort": {"effort": "high", "summary": "detailed"}
|
||||
}
|
||||
non_default_params = {"reasoning_effort": {"effort": "high", "summary": "detailed"}}
|
||||
optional_params = {}
|
||||
|
||||
self.config.map_openai_params(
|
||||
|
|
@ -642,9 +604,7 @@ class TestGPT5ReasoningEffortPreservation:
|
|||
|
||||
def test_reasoning_effort_dict_with_generate_summary_normalized(self):
|
||||
"""Test that reasoning_effort dict with 'generate_summary' is normalized for Chat Completions API."""
|
||||
non_default_params = {
|
||||
"reasoning_effort": {"effort": "medium", "generate_summary": "auto"}
|
||||
}
|
||||
non_default_params = {"reasoning_effort": {"effort": "medium", "generate_summary": "auto"}}
|
||||
optional_params = {}
|
||||
|
||||
self.config.map_openai_params(
|
||||
|
|
@ -686,9 +646,7 @@ class TestGPT5ReasoningEffortPreservation:
|
|||
"""
|
||||
import litellm
|
||||
|
||||
non_default_params = {
|
||||
"reasoning_effort": {"effort": "xhigh", "summary": "detailed"}
|
||||
}
|
||||
non_default_params = {"reasoning_effort": {"effort": "xhigh", "summary": "detailed"}}
|
||||
optional_params = {}
|
||||
|
||||
with pytest.raises(litellm.utils.UnsupportedParamsError):
|
||||
|
|
@ -701,9 +659,7 @@ class TestGPT5ReasoningEffortPreservation:
|
|||
|
||||
def test_reasoning_effort_dict_xhigh_dropped_when_requested(self):
|
||||
"""xhigh-dict with drop_params=True: reasoning_effort is dropped."""
|
||||
non_default_params = {
|
||||
"reasoning_effort": {"effort": "xhigh", "summary": "detailed"}
|
||||
}
|
||||
non_default_params = {"reasoning_effort": {"effort": "xhigh", "summary": "detailed"}}
|
||||
optional_params = {}
|
||||
|
||||
self.config.map_openai_params(
|
||||
|
|
@ -717,9 +673,7 @@ class TestGPT5ReasoningEffortPreservation:
|
|||
|
||||
def test_reasoning_effort_dict_none_passed_through_for_gpt5_4_with_tools(self):
|
||||
"""none-dict with tools on gpt-5.4: reasoning_effort is passed through (routing to Responses at completion level)."""
|
||||
tools = [
|
||||
{"type": "function", "function": {"name": "test", "description": "test"}}
|
||||
]
|
||||
tools = [{"type": "function", "function": {"name": "test", "description": "test"}}]
|
||||
non_default_params = {
|
||||
"reasoning_effort": {"effort": "none", "summary": "detailed"},
|
||||
"tools": tools,
|
||||
|
|
@ -830,63 +784,34 @@ class TestCacheControlPreservationForCustomEndpoint:
|
|||
)
|
||||
|
||||
def test_predicate_openai_provider_custom_api_base_preserves(self):
|
||||
assert (
|
||||
self.config._should_preserve_cache_control_for_endpoint(
|
||||
"openai", "http://localhost:4000/v1"
|
||||
)
|
||||
is True
|
||||
)
|
||||
assert self.config._should_preserve_cache_control_for_endpoint("openai", "http://localhost:4000/v1") is True
|
||||
|
||||
def test_predicate_real_openai_no_api_base_strips(self):
|
||||
assert (
|
||||
self.config._should_preserve_cache_control_for_endpoint("openai", None)
|
||||
is False
|
||||
)
|
||||
assert self.config._should_preserve_cache_control_for_endpoint("openai", None) is False
|
||||
|
||||
def test_predicate_explicit_openai_host_strips(self):
|
||||
assert (
|
||||
self.config._should_preserve_cache_control_for_endpoint(
|
||||
"openai", "https://api.openai.com/v1"
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert self.config._should_preserve_cache_control_for_endpoint("openai", "https://api.openai.com/v1") is False
|
||||
|
||||
def test_predicate_non_openai_provider_strips(self):
|
||||
assert (
|
||||
self.config._should_preserve_cache_control_for_endpoint(
|
||||
"deepseek", "https://api.deepseek.com"
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert self.config._should_preserve_cache_control_for_endpoint("deepseek", "https://api.deepseek.com") is False
|
||||
|
||||
def test_predicate_resolves_openai_base_url_env(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:4000/v1")
|
||||
assert (
|
||||
self.config._should_preserve_cache_control_for_endpoint("openai", None)
|
||||
is True
|
||||
)
|
||||
assert self.config._should_preserve_cache_control_for_endpoint("openai", None) is True
|
||||
|
||||
def test_predicate_resolves_openai_api_base_env(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_BASE", "http://localhost:4000/v1")
|
||||
assert (
|
||||
self.config._should_preserve_cache_control_for_endpoint("openai", None)
|
||||
is True
|
||||
)
|
||||
assert self.config._should_preserve_cache_control_for_endpoint("openai", None) is True
|
||||
|
||||
def test_predicate_lookalike_host_is_not_treated_as_openai(self):
|
||||
assert (
|
||||
self.config._should_preserve_cache_control_for_endpoint(
|
||||
"openai", "https://api.openai.com.evil.example/v1"
|
||||
)
|
||||
self.config._should_preserve_cache_control_for_endpoint("openai", "https://api.openai.com.evil.example/v1")
|
||||
is True
|
||||
)
|
||||
|
||||
def test_predicate_openai_subdomain_strips(self):
|
||||
assert (
|
||||
self.config._should_preserve_cache_control_for_endpoint(
|
||||
"openai", "https://eu.api.openai.com/v1"
|
||||
)
|
||||
is False
|
||||
self.config._should_preserve_cache_control_for_endpoint("openai", "https://eu.api.openai.com/v1") is False
|
||||
)
|
||||
|
||||
def test_transform_request_preserves_for_custom_api_base(self):
|
||||
|
|
@ -909,9 +834,7 @@ class TestCacheControlPreservationForCustomEndpoint:
|
|||
"cache_control": {"type": "ephemeral"},
|
||||
}
|
||||
]
|
||||
body = self._transform(
|
||||
"openai", "http://localhost:4000/v1", optional_params={"tools": tools}
|
||||
)
|
||||
body = self._transform("openai", "http://localhost:4000/v1", optional_params={"tools": tools})
|
||||
assert "cache_control" in body["tools"][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -1193,9 +1116,7 @@ class TestToolChoiceWithoutToolsDropped:
|
|||
assert "tool_choice" not in body
|
||||
|
||||
def test_drops_named_function_tool_choice_without_tools(self):
|
||||
body = self._transform(
|
||||
{"tool_choice": {"type": "function", "function": {"name": "get_weather"}}}
|
||||
)
|
||||
body = self._transform({"tool_choice": {"type": "function", "function": {"name": "get_weather"}}})
|
||||
assert "tool_choice" not in body
|
||||
|
||||
def test_drops_tool_choice_but_keeps_empty_tools_array(self):
|
||||
|
|
@ -1369,9 +1290,7 @@ class TestToolMessageImageHoisting:
|
|||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{"id": "call_1", "type": "function", "function": {"name": "read", "arguments": "{}"}}
|
||||
],
|
||||
"tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "read", "arguments": "{}"}}],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
|
|
@ -1588,7 +1507,9 @@ class TestOpenAIPromptCacheBreakpointChatPath:
|
|||
assert request["messages"][0]["content"] == [
|
||||
{"type": "text", "text": "sys", "prompt_cache_breakpoint": self.EXPLICIT}
|
||||
]
|
||||
assert request["messages"][1]["content"] == [{"type": "text", "text": "hi", "prompt_cache_breakpoint": self.EXPLICIT}]
|
||||
assert request["messages"][1]["content"] == [
|
||||
{"type": "text", "text": "hi", "prompt_cache_breakpoint": self.EXPLICIT}
|
||||
]
|
||||
assert request["extra_body"] == {"prompt_cache_options": self.EXPLICIT}
|
||||
assert "prompt_cache_options" not in request
|
||||
|
||||
|
|
@ -1729,9 +1650,7 @@ class TestToolSchemaCombinatorFlatteningForOpenAI:
|
|||
"parameters": {"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"]},
|
||||
},
|
||||
}
|
||||
request = self._transform(
|
||||
self.config, "gpt-4o", {"custom_llm_provider": "openai", "api_base": None}, [tool]
|
||||
)
|
||||
request = self._transform(self.config, "gpt-4o", {"custom_llm_provider": "openai", "api_base": None}, [tool])
|
||||
assert request["tools"][0] is tool
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -1793,3 +1712,48 @@ class TestToolSchemaCombinatorFlatteningForOpenAI:
|
|||
)
|
||||
|
||||
assert request["tools"][0] is tool
|
||||
|
||||
|
||||
def test_openai_transform_strips_is_error_carrier_from_tool_messages():
|
||||
# Anthropic tool_result.is_error rides on the intermediate tool message as
|
||||
# a litellm-internal carrier (see the anthropic adapter); real OpenAI must
|
||||
# not receive it, mirroring how cache_control is stripped for openai.com
|
||||
# hosts.
|
||||
messages: Final = [
|
||||
{"role": "user", "content": "check the weather"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": "ToolNotFound: get_weather is not available",
|
||||
"is_error": True,
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
},
|
||||
]
|
||||
|
||||
transformed: Final = OpenAIGPTConfig().transform_request(
|
||||
model="gpt-4o",
|
||||
messages=messages,
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
tool_messages: Final = [m for m in transformed["messages"] if m.get("role") == "tool"]
|
||||
assert len(tool_messages) == 1
|
||||
# whole-value assert: the internal carriers are gone and nothing else moved
|
||||
assert tool_messages[0] == {
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": "ToolNotFound: get_weather is not available",
|
||||
}
|
||||
|
|
|
|||
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -28922,6 +28922,8 @@ export interface components {
|
|||
ChatCompletionToolMessage: {
|
||||
/** Content */
|
||||
content: string | (components["schemas"]["ChatCompletionTextObject"] | components["schemas"]["ChatCompletionImageObject"] | components["schemas"]["ChatCompletionToolReferenceObject"])[];
|
||||
/** Is Error */
|
||||
is_error?: boolean;
|
||||
/**
|
||||
* Role
|
||||
* @constant
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue