fix(anthropic): stop the files surface forwarding a caller credential, and normalize the discovery base

Two defects the review bots found in this PR's own code, both confirmed against the
implementation before fixing.

The files surface built its headers by merging the resolved credential over the
caller's, which leaves a caller-supplied x-api-key in place next to a minted
federation Bearer. The chat surface already stripped those, so this was an
inconsistency rather than a new rule. That stripping is now one helper both
surfaces call, and it applies whatever the credential turned out to be, since a
header that authenticates the caller to LiteLLM should never reach Anthropic.
Batches is unaffected: it passes no caller headers through.

Model discovery appended /v1/models to the configured base without normalizing it,
so a deployment whose api_base already ended in /v1, or in the /v1/messages URL an
operator copied out of the docs, asked for /v1/v1/models and failed. It now reuses
the same suffix stripping the token exchange derives its URL from, so both agree on
what the deployment's base is.
This commit is contained in:
derhornspieler 2026-08-23 20:54:09 -04:00
parent 2b1e56b877
commit 2bdd5becd6
42 changed files with 652 additions and 1535 deletions

View file

@ -20,7 +20,11 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
from litellm.litellm_core_utils.prompt_templates.factory import (
THOUGHT_SIGNATURE_SEPARATOR,
)
from litellm.llms.anthropic.wif import aget_anthropic_wif_token, get_anthropic_wif_token
from litellm.llms.anthropic.wif import (
aget_anthropic_wif_token,
anthropic_base_without_chat_suffix,
get_anthropic_wif_token,
)
from litellm.llms.base_llm.base_utils import BaseLLMModelInfo, BaseTokenCounter
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.proxy._types import SpecialHeaders
@ -62,6 +66,16 @@ _SERVER_OWNED_AUTH_HEADERS: Final = SpecialHeaders.litellm_credential_header_nam
_WIF_ELIGIBILITY_ATTR: Final = "_workload_identity_eligible"
def without_caller_credential_headers(headers: Mapping[str, str]) -> dict[str, str]:
"""``headers`` minus every header that authenticates the caller to litellm.
The deployment's own credential is applied on top of the result, so a caller-supplied
credential must not survive into the upstream request: without this a minted federation
Bearer travels beside the caller's own ``x-api-key``, and Anthropic sees two credentials.
"""
return {name: value for name, value in headers.items() if name.lower() not in _SERVER_OWNED_AUTH_HEADERS}
def config_allows_workload_identity(config: object) -> bool:
"""A federation token is an Anthropic-org credential and its exchange POSTs the workload's OIDC
assertion to the deployment's own host, so eligibility is declared per class and read from that
@ -887,11 +901,7 @@ class AnthropicModelInfo(BaseLLMModelInfo):
wif_minted=wif_minted,
)
caller_headers: Final = (
{name: value for name, value in headers.items() if name.lower() not in _SERVER_OWNED_AUTH_HEADERS}
if wif_minted
else headers
)
caller_headers: Final = without_caller_credential_headers(headers) if wif_minted else headers
return {**caller_headers, **anthropic_headers}
@ -1032,8 +1042,13 @@ class AnthropicModelInfo(BaseLLMModelInfo):
"Anthropic's `/models` endpoint."
)
headers: Final = MappingProxyType({"anthropic-version": "2023-06-01", **auth_header})
# /v1/models is appended below, so a base the operator already wrote as .../v1 or
# .../v1/messages would otherwise be asked for /v1/v1/models.
model_ids: Final = _fetch_anthropic_model_ids(
resolved_api_base, headers, after_id=None, pages_left=_MODEL_LIST_PAGE_CAP
anthropic_base_without_chat_suffix(resolved_api_base),
headers,
after_id=None,
pages_left=_MODEL_LIST_PAGE_CAP,
)
return [ # mutable-ok: matches get_models' list[str] contract shared by every provider override
"anthropic/" + model_id for model_id in model_ids

View file

@ -36,7 +36,12 @@ from litellm.types.llms.openai import (
)
from litellm.types.utils import LlmProviders
from ..common_utils import AnthropicError, AnthropicModelInfo, merge_anthropic_beta_headers
from ..common_utils import (
AnthropicError,
AnthropicModelInfo,
merge_anthropic_beta_headers,
without_caller_credential_headers,
)
ANTHROPIC_FILES_API_BASE: Final = "https://api.anthropic.com"
ANTHROPIC_FILES_BETA_HEADER: Final = "files-api-2025-04-14"
@ -138,14 +143,12 @@ class AnthropicFilesConfig(BaseFilesConfig):
merge_anthropic_beta_headers(headers.get("anthropic-beta"), auth_header.get("anthropic-beta")),
ANTHROPIC_FILES_BETA_HEADER,
)
headers.update(
{
**auth_header,
"anthropic-version": "2023-06-01",
"anthropic-beta": merged_beta,
}
)
return headers
return {
**without_caller_credential_headers(headers),
**auth_header,
"anthropic-version": "2023-06-01",
"anthropic-beta": merged_beta,
}
def get_supported_openai_params(self, model: str) -> list[OpenAICreateFileRequestOptionalParams]:
return ["purpose"]

View file

@ -271,7 +271,7 @@ def _token_exchange_base(api_base: str | None) -> str:
"""Exchange base for any caller-supplied form of the deployment base: trailing
slashes and chat-appended ``/v1/messages`` suffixes stripped, so every tier
derives the same token URL (and cache key) for the same deployment."""
return _strip_chat_suffix(api_base if api_base is not None else _resolve_default_api_base())
return anthropic_base_without_chat_suffix(api_base if api_base is not None else _resolve_default_api_base())
def _trusted_exchange_hosts() -> frozenset[str]:
@ -315,7 +315,9 @@ def _resolve_default_api_base() -> str:
return AnthropicModelInfo.get_api_base(None) or _DEFAULT_API_BASE
def _strip_chat_suffix(base: str) -> str:
def anthropic_base_without_chat_suffix(base: str) -> str:
"""A deployment base with its chat-surface suffix removed, so the token URL and model
discovery both derive from the same value whatever form the operator configured."""
parts: Final = urlsplit(base)
if not parts.scheme or not parts.netloc:
return base.rstrip("/")

View file

@ -37,9 +37,7 @@ def _ok_batch_response():
"ended_at": "2024-09-24T11:00:00Z",
"request_counts": {"succeeded": 2, "errored": 0},
},
request=httpx.Request(
"GET", "https://api.anthropic.com/v1/messages/batches/msgbatch_abc"
),
request=httpx.Request("GET", "https://api.anthropic.com/v1/messages/batches/msgbatch_abc"),
)
@ -61,9 +59,7 @@ def patched_client():
@pytest.mark.asyncio
async def test_aretrieve_batch_fires_get_with_correct_url_and_headers(
handler, patched_client
):
async def test_aretrieve_batch_fires_get_with_correct_url_and_headers(handler, patched_client):
fake_client, factory = patched_client
batch = await handler.aretrieve_batch(
@ -78,9 +74,7 @@ async def test_aretrieve_batch_fires_get_with_correct_url_and_headers(
fake_client.get.assert_awaited_once()
_, call_kwargs = fake_client.get.call_args
# Exact URL built by get_retrieve_batch_url.
assert call_kwargs["url"] == (
"https://api.anthropic.com/v1/messages/batches/msgbatch_abc"
)
assert call_kwargs["url"] == ("https://api.anthropic.com/v1/messages/batches/msgbatch_abc")
# Auth + version + beta headers built by validate_environment.
headers = call_kwargs["headers"]
assert headers["x-api-key"] == "sk-ant-test"
@ -95,9 +89,7 @@ async def test_aretrieve_batch_fires_get_with_correct_url_and_headers(
@pytest.mark.asyncio
async def test_aretrieve_batch_uses_anthropic_provider_for_client(
handler, patched_client
):
async def test_aretrieve_batch_uses_anthropic_provider_for_client(handler, patched_client):
from litellm.types.utils import LlmProviders
_, factory = patched_client
@ -113,14 +105,10 @@ async def test_aretrieve_batch_uses_anthropic_provider_for_client(
@pytest.mark.asyncio
async def test_aretrieve_batch_resolves_api_key_from_model_info(
handler, patched_client
):
async def test_aretrieve_batch_resolves_api_key_from_model_info(handler, patched_client):
fake_client, _ = patched_client
# api_key=None -> handler falls back to AnthropicModelInfo.get_api_key().
with patch.object(
handler.anthropic_model_info, "get_api_key", return_value="sk-from-env"
):
with patch.object(handler.anthropic_model_info, "get_api_key", return_value="sk-from-env"):
await handler.aretrieve_batch(
batch_id="msgbatch_abc",
api_base="https://api.anthropic.com",
@ -136,9 +124,7 @@ async def test_aretrieve_batch_resolves_api_key_from_model_info(
async def test_aretrieve_batch_missing_api_key_raises(handler, patched_client):
fake_client, _ = patched_client
# No api_key and resolver yields None -> hard error before any network call.
with patch.object(
handler.anthropic_model_info, "get_api_key", return_value=None
):
with patch.object(handler.anthropic_model_info, "get_api_key", return_value=None):
with pytest.raises(ValueError, match="Missing Anthropic API Key"):
await handler.aretrieve_batch(
batch_id="msgbatch_abc",
@ -167,9 +153,7 @@ async def test_aretrieve_batch_resolves_default_api_base(handler, patched_client
max_retries=0,
)
_, call_kwargs = fake_client.get.call_args
assert call_kwargs["url"] == (
"https://api.anthropic.com/v1/messages/batches/msgbatch_abc"
)
assert call_kwargs["url"] == ("https://api.anthropic.com/v1/messages/batches/msgbatch_abc")
@pytest.mark.asyncio
@ -178,9 +162,7 @@ async def test_aretrieve_batch_raises_for_status(handler):
error_response = httpx.Response(
status_code=404,
json={"error": "not found"},
request=httpx.Request(
"GET", "https://api.anthropic.com/v1/messages/batches/missing"
),
request=httpx.Request("GET", "https://api.anthropic.com/v1/messages/batches/missing"),
)
fake_client = MagicMock()
fake_client.get = AsyncMock(return_value=error_response)
@ -215,21 +197,15 @@ async def test_aretrieve_batch_invokes_pre_call_logging(handler, patched_client)
assert pre_kwargs["input"] == "msgbatch_abc"
assert pre_kwargs["api_key"] == "sk-ant-test"
# The logged api_base is the full retrieve URL, not the bare base.
assert pre_kwargs["additional_args"]["api_base"] == (
"https://api.anthropic.com/v1/messages/batches/msgbatch_abc"
)
assert pre_kwargs["additional_args"]["api_base"] == ("https://api.anthropic.com/v1/messages/batches/msgbatch_abc")
@pytest.mark.asyncio
async def test_aretrieve_batch_builds_default_logging_obj_when_absent(
handler, patched_client
):
async def test_aretrieve_batch_builds_default_logging_obj_when_absent(handler, patched_client):
# logging_obj=None -> handler constructs a real Logging object; the call
# must still complete (no AttributeError on a missing logger).
_, _ = patched_client
with patch(
"litellm.litellm_core_utils.litellm_logging.Logging"
) as logging_cls:
with patch("litellm.litellm_core_utils.litellm_logging.Logging") as logging_cls:
logging_cls.return_value = MagicMock()
batch = await handler.aretrieve_batch(
batch_id="msgbatch_abc",
@ -318,9 +294,7 @@ class _BlockingPoster:
@pytest.mark.asyncio
async def test_aretrieve_batch_wif_exchange_does_not_block_event_loop(
handler, patched_client, monkeypatch
):
async def test_aretrieve_batch_wif_exchange_does_not_block_event_loop(handler, patched_client, monkeypatch):
"""Regression: aretrieve_batch called the synchronous validate_environment
directly, so a cold WIF mint ran inline on the event loop and froze every
other concurrent coroutine until the exchange finished."""
@ -345,9 +319,7 @@ async def test_aretrieve_batch_wif_exchange_does_not_block_event_loop(
def routed_through_injected_engine(litellm_params, api_base, model):
return get_anthropic_wif_token(litellm_params, api_base, model, engine)
monkeypatch.setattr(
anthropic_common_utils, "get_anthropic_wif_token", routed_through_injected_engine
)
monkeypatch.setattr(anthropic_common_utils, "get_anthropic_wif_token", routed_through_injected_engine)
ticks = []

View file

@ -103,9 +103,7 @@ def test_validate_environment_oauth_key_uses_bearer(config):
def test_validate_environment_missing_key_raises(config):
# No api_key passed and no env credentials -> get_auth_header returns None.
with patch.object(
config.anthropic_model_info, "get_auth_header", return_value=None
):
with patch.object(config.anthropic_model_info, "get_auth_header", return_value=None):
with pytest.raises(ValueError, match="Missing Anthropic API Key"):
config.validate_environment(
headers={},
@ -244,12 +242,7 @@ def test_get_retrieve_batch_url_uses_default_api_base(config):
def test_transform_retrieve_batch_request_returns_empty_dict(config):
assert (
config.transform_retrieve_batch_request(
batch_id="msgbatch_123", optional_params={}, litellm_params={}
)
== {}
)
assert config.transform_retrieve_batch_request(batch_id="msgbatch_123", optional_params={}, litellm_params={}) == {}
# =========================================================================== #
@ -458,9 +451,7 @@ def test_transform_retrieve_response_unparseable_json_raises(config):
def test_get_error_class_with_dict_headers(config):
err = config.get_error_class(
error_message="rate limited", status_code=429, headers={"x-ratelimit": "0"}
)
err = config.get_error_class(error_message="rate limited", status_code=429, headers={"x-ratelimit": "0"})
from litellm.llms.anthropic.common_utils import AnthropicError
assert isinstance(err, AnthropicError)
@ -470,9 +461,7 @@ def test_get_error_class_with_dict_headers(config):
def test_get_error_class_with_httpx_headers(config):
hdrs = httpx.Headers({"retry-after": "5"})
err = config.get_error_class(
error_message="server error", status_code=500, headers=hdrs
)
err = config.get_error_class(error_message="server error", status_code=500, headers=hdrs)
assert err.status_code == 500
assert err.message == "server error"
@ -546,9 +535,7 @@ def test_transform_response_skips_malformed_lines(config):
def fake_transform_parsed(*, completion_response, raw_response, model_response):
mr = ModelResponse()
setattr(
mr, "usage", Usage(prompt_tokens=7, completion_tokens=3, total_tokens=10)
)
setattr(mr, "usage", Usage(prompt_tokens=7, completion_tokens=3, total_tokens=10))
return mr
with patch.object(
@ -591,13 +578,16 @@ def test_transform_response_reraises_unexpected_error(config):
# A non-JSONDecodeError raised during usage aggregation must propagate
# (the outer `except Exception: raise e`), not be swallowed.
with patch.object(
config.anthropic_chat_config,
"transform_parsed_response",
side_effect=fake_transform_parsed,
), patch(
"litellm.cost_calculator.BaseTokenUsageProcessor.combine_usage_objects",
side_effect=RuntimeError("boom"),
with (
patch.object(
config.anthropic_chat_config,
"transform_parsed_response",
side_effect=fake_transform_parsed,
),
patch(
"litellm.cost_calculator.BaseTokenUsageProcessor.combine_usage_objects",
side_effect=RuntimeError("boom"),
),
):
with pytest.raises(RuntimeError, match="boom"):
config.transform_response(

View file

@ -47,9 +47,7 @@ class MockDynamicGuardrail(CustomGuardrail):
input_type: Literal["request", "response"],
logging_obj: Optional[Any] = None,
) -> GenericGuardrailAPIInputs:
self.dynamic_params = self.get_guardrail_dynamic_request_body_params(
request_data
)
self.dynamic_params = self.get_guardrail_dynamic_request_body_params(request_data)
return inputs
@ -196,9 +194,7 @@ class TestAnthropicMessagesHandlerStreamingRequestData:
assert guardrail.request_data is not None
assert guardrail.request_data["response"] is mock_response
assert (
guardrail.request_data["litellm_metadata"]["user_api_key_user_id"] == "u-1"
)
assert guardrail.request_data["litellm_metadata"]["user_api_key_user_id"] == "u-1"
@pytest.mark.asyncio
async def test_mid_stream_chunk_passes_responses_so_far_and_metadata(self):
@ -210,9 +206,7 @@ class TestAnthropicMessagesHandlerStreamingRequestData:
with (
patch.object(handler, "_check_streaming_has_ended", return_value=False),
patch.object(
handler, "get_streaming_string_so_far", return_value="partial text"
),
patch.object(handler, "get_streaming_string_so_far", return_value="partial text"),
):
await handler.process_output_streaming_response(
responses_so_far=responses_so_far,
@ -224,9 +218,7 @@ class TestAnthropicMessagesHandlerStreamingRequestData:
assert guardrail.request_data is not None
assert guardrail.request_data["responses"] is responses_so_far
assert (
guardrail.request_data["litellm_metadata"]["user_api_key_user_id"] == "u-1"
)
assert guardrail.request_data["litellm_metadata"]["user_api_key_user_id"] == "u-1"
class TestAnthropicMessagesHandlerStreamingOutputProcessing:
@ -275,17 +267,11 @@ class TestAnthropicMessagesHandlerInputProcessing:
data = {
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "hello"}],
"litellm_metadata": {
"guardrails": [
{"cygnal-monitor": {"extra_body": {"policy_id": "policy-123"}}}
]
},
"litellm_metadata": {"guardrails": [{"cygnal-monitor": {"extra_body": {"policy_id": "policy-123"}}}]},
}
with patch("litellm.proxy.proxy_server.premium_user", True):
await handler.process_input_messages(
data=data, guardrail_to_apply=guardrail
)
await handler.process_input_messages(data=data, guardrail_to_apply=guardrail)
assert data.get("litellm_metadata", {}).get("guardrails")
assert guardrail.dynamic_params == {"policy_id": "policy-123"}
@ -1164,9 +1150,7 @@ class TestAnthropicMessagesHandlerInputProcessing:
# Mock _check_streaming_has_ended to return False (stream not ended)
with (
patch.object(handler, "_check_streaming_has_ended", return_value=False),
patch.object(
handler, "get_streaming_string_so_far", return_value="partial text"
),
patch.object(handler, "get_streaming_string_so_far", return_value="partial text"),
):
responses_so_far = [b"data: some chunk"]
@ -1197,9 +1181,7 @@ class TestAnthropicMessagesHandlerInputProcessing:
data = {
"model": "claude-opus-4-6",
"messages": [
{"role": "user", "content": "What is the weather in San Francisco?"}
],
"messages": [{"role": "user", "content": "What is the weather in San Francisco?"}],
"tools": [
{
"type": "tool_search_tool_regex_20251119",
@ -1362,17 +1344,11 @@ class TestAnthropicMessagesIncrementalScan:
]
with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api:
mock_api.return_value = {"action": "NONE", "output": [], "outputs": []}
await handler.process_input_messages(
data=self._data(turn1, sid), guardrail_to_apply=guardrail
)
await handler.process_input_messages(data=self._data(turn1, sid), guardrail_to_apply=guardrail)
assert mock_api.call_count == 1
assert [m["content"] for m in mock_api.call_args.kwargs["messages"]] == [
"What is the capital of France?"
]
assert [m["content"] for m in mock_api.call_args.kwargs["messages"]] == ["What is the capital of France?"]
mock_api.reset_mock()
await handler.process_input_messages(
data=self._data(turn2, sid), guardrail_to_apply=guardrail
)
await handler.process_input_messages(data=self._data(turn2, sid), guardrail_to_apply=guardrail)
assert mock_api.call_count == 1
assert [m["content"] for m in mock_api.call_args.kwargs["messages"]] == [
"Paris.",
@ -1725,9 +1701,7 @@ class TestAnthropicMessagesScanOnlyToolResults:
await handler.process_input_messages(data=data, guardrail_to_apply=guardrail)
assert guardrail.seen_texts == ["fetched POISON page"], (
"only the tool_result payload may reach the guardrail"
)
assert guardrail.seen_texts == ["fetched POISON page"], "only the tool_result payload may reach the guardrail"
assert guardrail.captured_inputs is not None
assert guardrail.captured_inputs.get("tools") is None
assert [m["role"] for m in guardrail.captured_inputs["structured_messages"]] == ["tool"]

View file

@ -21,9 +21,7 @@ async def test_make_call_passes_logging_obj_to_client_post():
mock_client = AsyncMock()
mock_response = MagicMock()
mock_response.aiter_lines = MagicMock(
return_value=iter(
[b'data: {"type":"message_start"}\n', b'data: {"type":"message_delta"}\n']
)
return_value=iter([b'data: {"type":"message_start"}\n', b'data: {"type":"message_delta"}\n'])
)
mock_client.post.return_value = mock_response
@ -55,9 +53,7 @@ def test_redacted_thinking_content_block_delta():
"data": "EuoBCoYBGAIiQJ/SxkPAgqxhKok29YrpJHRUJ0OT8ahCHKAwyhmRuUhtdmDX9+mn4gDzKNv3fVpQdB01zEPMzNY3QuTCd+1bdtEqQK6JuKHqdndbwpr81oVWb4wxd1GqF/7Jkw74IlQa27oobX+KuRkopr9Dllt/RDe7Se0sI1IkU7tJIAQCoP46OAwSDF51P09q67xhHlQ3ihoM2aOVlkghq/X0w8NlIjBMNvXYNbjhyrOcIg6kPFn2ed/KK7Cm5prYAtXCwkb4Wr5tUSoSHu9T5hKdJRbr6WsqEc7Lle7FULqMLZGkhqXyc3BA",
},
}
model_response_iterator = ModelResponseIterator(
streaming_response=MagicMock(), sync_stream=False, json_mode=False
)
model_response_iterator = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=False, json_mode=False)
model_response = model_response_iterator.chunk_parser(chunk=chunk)
print(f"\n\nmodel_response: {model_response}\n\n")
assert model_response.choices[0].delta.thinking_blocks is not None
@ -65,19 +61,14 @@ def test_redacted_thinking_content_block_delta():
print(
f"\n\nmodel_response.choices[0].delta.thinking_blocks[0]: {model_response.choices[0].delta.thinking_blocks[0]}\n\n"
)
assert (
model_response.choices[0].delta.thinking_blocks[0]["type"]
== "redacted_thinking"
)
assert model_response.choices[0].delta.thinking_blocks[0]["type"] == "redacted_thinking"
assert model_response.choices[0].delta.provider_specific_fields is not None
assert "thinking_blocks" in model_response.choices[0].delta.provider_specific_fields
def test_streaming_thinking_blocks_are_replayable_after_signature_delta():
model_response_iterator = ModelResponseIterator(
streaming_response=MagicMock(), sync_stream=True, json_mode=False
)
model_response_iterator = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=True, json_mode=False)
chunks = [
{
"type": "content_block_start",
@ -101,17 +92,12 @@ def test_streaming_thinking_blocks_are_replayable_after_signature_delta():
},
]
parsed_chunks = [
model_response_iterator.chunk_parser(chunk=chunk) for chunk in chunks
]
parsed_chunks = [model_response_iterator.chunk_parser(chunk=chunk) for chunk in chunks]
reasoning_content = "".join(
getattr(chunk.choices[0].delta, "reasoning_content", None) or ""
for chunk in parsed_chunks
getattr(chunk.choices[0].delta, "reasoning_content", None) or "" for chunk in parsed_chunks
)
thinking_blocks = tuple(
block
for chunk in parsed_chunks
for block in (getattr(chunk.choices[0].delta, "thinking_blocks", None) or [])
block for chunk in parsed_chunks for block in (getattr(chunk.choices[0].delta, "thinking_blocks", None) or [])
)
expected_delta_blocks = (
{"type": "thinking", "thinking": "Step 1. "},
@ -125,18 +111,12 @@ def test_streaming_thinking_blocks_are_replayable_after_signature_delta():
assert reasoning_content == "Step 1. Step 2."
assert thinking_blocks == (*expected_delta_blocks, expected_thinking_block)
assert parsed_chunks[1].choices[0].delta.provider_specific_fields == {
"thinking_blocks": [expected_delta_blocks[0]]
}
assert parsed_chunks[-1].choices[0].delta.provider_specific_fields == {
"thinking_blocks": [expected_thinking_block]
}
assert parsed_chunks[1].choices[0].delta.provider_specific_fields == {"thinking_blocks": [expected_delta_blocks[0]]}
assert parsed_chunks[-1].choices[0].delta.provider_specific_fields == {"thinking_blocks": [expected_thinking_block]}
def test_streaming_unsigned_thinking_deltas_keep_reasoning_content():
model_response_iterator = ModelResponseIterator(
streaming_response=MagicMock(), sync_stream=True, json_mode=False
)
model_response_iterator = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=True, json_mode=False)
chunks = [
{
"type": "content_block_start",
@ -156,17 +136,12 @@ def test_streaming_unsigned_thinking_deltas_keep_reasoning_content():
{"type": "content_block_stop", "index": 0},
]
parsed_chunks = [
model_response_iterator.chunk_parser(chunk=chunk) for chunk in chunks
]
parsed_chunks = [model_response_iterator.chunk_parser(chunk=chunk) for chunk in chunks]
reasoning_content = "".join(
getattr(chunk.choices[0].delta, "reasoning_content", None) or ""
for chunk in parsed_chunks
getattr(chunk.choices[0].delta, "reasoning_content", None) or "" for chunk in parsed_chunks
)
thinking_blocks = tuple(
block
for chunk in parsed_chunks
for block in (getattr(chunk.choices[0].delta, "thinking_blocks", None) or [])
block for chunk in parsed_chunks for block in (getattr(chunk.choices[0].delta, "thinking_blocks", None) or [])
)
assert reasoning_content == "Step 1. Step 2."
@ -177,9 +152,7 @@ def test_streaming_unsigned_thinking_deltas_keep_reasoning_content():
def test_streaming_truncated_thinking_deltas_keep_reasoning_content():
model_response_iterator = ModelResponseIterator(
streaming_response=MagicMock(), sync_stream=True, json_mode=False
)
model_response_iterator = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=True, json_mode=False)
chunks = [
{
"type": "content_block_start",
@ -198,17 +171,12 @@ def test_streaming_truncated_thinking_deltas_keep_reasoning_content():
},
]
parsed_chunks = [
model_response_iterator.chunk_parser(chunk=chunk) for chunk in chunks
]
parsed_chunks = [model_response_iterator.chunk_parser(chunk=chunk) for chunk in chunks]
reasoning_content = "".join(
getattr(chunk.choices[0].delta, "reasoning_content", None) or ""
for chunk in parsed_chunks
getattr(chunk.choices[0].delta, "reasoning_content", None) or "" for chunk in parsed_chunks
)
thinking_blocks = tuple(
block
for chunk in parsed_chunks
for block in (getattr(chunk.choices[0].delta, "thinking_blocks", None) or [])
block for chunk in parsed_chunks for block in (getattr(chunk.choices[0].delta, "thinking_blocks", None) or [])
)
assert reasoning_content == "Step 1. Step 2."
@ -219,9 +187,7 @@ def test_streaming_truncated_thinking_deltas_keep_reasoning_content():
def test_handle_json_mode_chunk_response_format_tool():
model_response_iterator = ModelResponseIterator(
streaming_response=MagicMock(), sync_stream=True, json_mode=True
)
model_response_iterator = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=True, json_mode=True)
response_format_tool = ChatCompletionToolCallChunk(
id="tool_123",
type="function",
@ -232,9 +198,7 @@ def test_handle_json_mode_chunk_response_format_tool():
index=0,
)
text, tool_use = model_response_iterator._handle_json_mode_chunk(
"", response_format_tool
)
text, tool_use = model_response_iterator._handle_json_mode_chunk("", response_format_tool)
print(f"\n\nresponse_format_tool text: {text}\n\n")
print(f"\n\nresponse_format_tool tool_use: {tool_use}\n\n")
@ -243,15 +207,11 @@ def test_handle_json_mode_chunk_response_format_tool():
def test_handle_json_mode_chunk_regular_tool():
model_response_iterator = ModelResponseIterator(
streaming_response=MagicMock(), sync_stream=True, json_mode=True
)
model_response_iterator = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=True, json_mode=True)
regular_tool = ChatCompletionToolCallChunk(
id="tool_456",
type="function",
function=ChatCompletionToolCallFunctionChunk(
name="get_weather", arguments='{"location": "San Francisco, CA"}'
),
function=ChatCompletionToolCallFunctionChunk(name="get_weather", arguments='{"location": "San Francisco, CA"}'),
index=0,
)
@ -265,17 +225,13 @@ def test_handle_json_mode_chunk_regular_tool():
def test_handle_json_mode_chunk_streaming_response_format_tool():
model_response_iterator = ModelResponseIterator(
streaming_response=MagicMock(), sync_stream=True, json_mode=True
)
model_response_iterator = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=True, json_mode=True)
# First chunk: response_format tool with id and name, but no arguments
first_chunk = ChatCompletionToolCallChunk(
id="tool_123",
type="function",
function=ChatCompletionToolCallFunctionChunk(
name=RESPONSE_FORMAT_TOOL_NAME, arguments=""
),
function=ChatCompletionToolCallFunctionChunk(name=RESPONSE_FORMAT_TOOL_NAME, arguments=""),
index=0,
)
@ -283,9 +239,7 @@ def test_handle_json_mode_chunk_streaming_response_format_tool():
second_chunk = ChatCompletionToolCallChunk(
id=None,
type="function",
function=ChatCompletionToolCallFunctionChunk(
name=None, arguments='{"question": "What is the weather?"'
),
function=ChatCompletionToolCallFunctionChunk(name=None, arguments='{"question": "What is the weather?"'),
index=0,
)
@ -293,9 +247,7 @@ def test_handle_json_mode_chunk_streaming_response_format_tool():
third_chunk = ChatCompletionToolCallChunk(
id=None,
type="function",
function=ChatCompletionToolCallFunctionChunk(
name=None, arguments=', "answer": "It is sunny"}'
),
function=ChatCompletionToolCallFunctionChunk(name=None, arguments=', "answer": "It is sunny"}'),
index=0,
)
@ -326,9 +278,7 @@ def test_handle_json_mode_chunk_streaming_response_format_tool():
def test_handle_json_mode_chunk_streaming_regular_tool():
model_response_iterator = ModelResponseIterator(
streaming_response=MagicMock(), sync_stream=True, json_mode=True
)
model_response_iterator = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=True, json_mode=True)
# First chunk: regular tool with id and name, but no arguments
first_chunk = ChatCompletionToolCallChunk(
@ -342,9 +292,7 @@ def test_handle_json_mode_chunk_streaming_regular_tool():
second_chunk = ChatCompletionToolCallChunk(
id=None,
type="function",
function=ChatCompletionToolCallFunctionChunk(
name=None, arguments='{"location": "San Francisco, CA"}'
),
function=ChatCompletionToolCallFunctionChunk(name=None, arguments='{"location": "San Francisco, CA"}'),
index=0,
)
@ -369,27 +317,19 @@ def test_handle_json_mode_chunk_streaming_regular_tool():
def test_response_format_tool_finish_reason():
model_response_iterator = ModelResponseIterator(
streaming_response=MagicMock(), sync_stream=True, json_mode=True
)
model_response_iterator = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=True, json_mode=True)
# First chunk: response_format tool
response_format_tool = ChatCompletionToolCallChunk(
id="tool_123",
type="function",
function=ChatCompletionToolCallFunctionChunk(
name=RESPONSE_FORMAT_TOOL_NAME, arguments='{"answer": "test"}'
),
function=ChatCompletionToolCallFunctionChunk(name=RESPONSE_FORMAT_TOOL_NAME, arguments='{"answer": "test"}'),
index=0,
)
# Process the tool call (should set converted_response_format_tool flag)
text, tool_use = model_response_iterator._handle_json_mode_chunk(
"", response_format_tool
)
print(
f"\n\nconverted_response_format_tool flag: {model_response_iterator.converted_response_format_tool}\n\n"
)
text, tool_use = model_response_iterator._handle_json_mode_chunk("", response_format_tool)
print(f"\n\nconverted_response_format_tool flag: {model_response_iterator.converted_response_format_tool}\n\n")
# Simulate message_delta chunk with tool_use stop_reason
message_delta_chunk = {
@ -408,25 +348,19 @@ def test_response_format_tool_finish_reason():
def test_regular_tool_finish_reason():
model_response_iterator = ModelResponseIterator(
streaming_response=MagicMock(), sync_stream=True, json_mode=True
)
model_response_iterator = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=True, json_mode=True)
# First chunk: regular tool (not response_format)
regular_tool = ChatCompletionToolCallChunk(
id="tool_456",
type="function",
function=ChatCompletionToolCallFunctionChunk(
name="get_weather", arguments='{"location": "San Francisco, CA"}'
),
function=ChatCompletionToolCallFunctionChunk(name="get_weather", arguments='{"location": "San Francisco, CA"}'),
index=0,
)
# Process the tool call (should NOT set converted_response_format_tool flag)
text, tool_use = model_response_iterator._handle_json_mode_chunk("", regular_tool)
print(
f"\n\nconverted_response_format_tool flag: {model_response_iterator.converted_response_format_tool}\n\n"
)
print(f"\n\nconverted_response_format_tool flag: {model_response_iterator.converted_response_format_tool}\n\n")
# Simulate message_delta chunk with tool_use stop_reason
message_delta_chunk = {
@ -486,9 +420,7 @@ def test_text_only_streaming_has_index_zero():
for chunk in chunks:
parsed = iterator.chunk_parser(chunk)
if parsed.choices:
assert (
parsed.choices[0].index == 0
), f"Expected index=0, got {parsed.choices[0].index}"
assert parsed.choices[0].index == 0, f"Expected index=0, got {parsed.choices[0].index}"
def test_streaming_thinking_deltas_count_reasoning_tokens_in_usage():
@ -665,9 +597,7 @@ def test_anthropic_completion_streaming_usage_matches_non_streaming_with_thinkin
]
self._write_response(
content_type="text/event-stream",
body="".join(
f"data: {json.dumps(event)}\n\n" for event in events
).encode("utf-8"),
body="".join(f"data: {json.dumps(event)}\n\n" for event in events).encode("utf-8"),
)
return
@ -748,13 +678,9 @@ def test_anthropic_completion_streaming_usage_matches_non_streaming_with_thinkin
assert content_chunks == [answer_text]
assert stream_usage is not None
stream_completion_details = stream_usage["completion_tokens_details"]
assert (
stream_completion_details["reasoning_tokens"]
== non_stream_details.reasoning_tokens
)
assert stream_completion_details["reasoning_tokens"] == non_stream_details.reasoning_tokens
assert stream_completion_details["text_tokens"] == (
stream_usage["completion_tokens"]
- stream_completion_details["reasoning_tokens"]
stream_usage["completion_tokens"] - stream_completion_details["reasoning_tokens"]
)
assert requests_seen == [
{
@ -846,9 +772,9 @@ def test_text_and_tool_streaming_has_index_zero():
for chunk in chunks:
parsed = iterator.chunk_parser(chunk)
if parsed.choices:
assert (
parsed.choices[0].index == 0
), f"Expected index=0 for chunk type {chunk.get('type')}, got {parsed.choices[0].index}"
assert parsed.choices[0].index == 0, (
f"Expected index=0 for chunk type {chunk.get('type')}, got {parsed.choices[0].index}"
)
def test_multiple_tools_streaming_has_index_zero():
@ -901,15 +827,11 @@ def test_multiple_tools_streaming_has_index_zero():
for chunk in chunks:
parsed = iterator.chunk_parser(chunk)
if parsed.choices:
assert (
parsed.choices[0].index == 0
), f"Expected index=0, got {parsed.choices[0].index}"
assert parsed.choices[0].index == 0, f"Expected index=0, got {parsed.choices[0].index}"
def test_streaming_chunks_have_stable_ids():
iterator = ModelResponseIterator(
streaming_response=MagicMock(), sync_stream=False, json_mode=False
)
iterator = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=False, json_mode=False)
first_chunk = {
"type": "content_block_delta",
"index": 0,
@ -934,9 +856,7 @@ def test_partial_json_chunk_accumulation():
This tests the fix for https://github.com/BerriAI/litellm/issues/17473
where network fragmentation can cause SSE data to arrive in partial chunks.
"""
iterator = ModelResponseIterator(
streaming_response=MagicMock(), sync_stream=True, json_mode=False
)
iterator = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=True, json_mode=False)
partial_chunk_1 = '{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hel'
partial_chunk_2 = 'lo"}}'
@ -944,31 +864,21 @@ def test_partial_json_chunk_accumulation():
# First partial chunk should return None (still accumulating)
result1 = iterator._parse_sse_data(f"data:{partial_chunk_1}")
assert result1 is None, "First partial chunk should return None while accumulating"
assert (
iterator.chunk_type == "accumulated_json"
), "Should switch to accumulated_json mode"
assert (
iterator.accumulated_json == partial_chunk_1
), "Should have accumulated first part"
assert iterator.chunk_type == "accumulated_json", "Should switch to accumulated_json mode"
assert iterator.accumulated_json == partial_chunk_1, "Should have accumulated first part"
# Second partial chunk should complete the JSON and return a parsed result
result2 = iterator._parse_sse_data(f"data:{partial_chunk_2}")
assert result2 is not None, "Second chunk should return parsed result"
assert (
iterator.accumulated_json == ""
), "Buffer should be cleared after successful parse"
assert (
result2.choices[0].delta.content == "Hello"
), f"Expected 'Hello', got '{result2.choices[0].delta.content}'"
assert iterator.accumulated_json == "", "Buffer should be cleared after successful parse"
assert result2.choices[0].delta.content == "Hello", f"Expected 'Hello', got '{result2.choices[0].delta.content}'"
def test_complete_json_chunk_no_accumulation():
"""
Test that complete JSON chunks are parsed immediately without accumulation.
"""
iterator = ModelResponseIterator(
streaming_response=MagicMock(), sync_stream=True, json_mode=False
)
iterator = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=True, json_mode=False)
complete_chunk = '{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}'
@ -976,18 +886,14 @@ def test_complete_json_chunk_no_accumulation():
assert result is not None, "Complete chunk should return parsed result immediately"
assert iterator.chunk_type == "valid_json", "Should remain in valid_json mode"
assert iterator.accumulated_json == "", "Buffer should remain empty"
assert (
result.choices[0].delta.content == "Hello"
), f"Expected 'Hello', got '{result.choices[0].delta.content}'"
assert result.choices[0].delta.content == "Hello", f"Expected 'Hello', got '{result.choices[0].delta.content}'"
def test_multiple_partial_chunks_accumulation():
"""
Test that multiple partial chunks can be accumulated across several iterations.
"""
iterator = ModelResponseIterator(
streaming_response=MagicMock(), sync_stream=True, json_mode=False
)
iterator = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=True, json_mode=False)
# Split a JSON chunk into three parts
part1 = '{"type":"content_block_del'
@ -1018,9 +924,7 @@ def test_web_search_tool_result_no_extra_tool_calls():
The issue was that web_search_tool_result blocks have input_json_delta events with {}
that were incorrectly being converted to tool calls.
"""
iterator = ModelResponseIterator(
streaming_response=MagicMock(), sync_stream=True, json_mode=False
)
iterator = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=True, json_mode=False)
# Simulate the streaming sequence:
# 1. server_tool_use block starts (web_search)
@ -1095,9 +999,7 @@ def test_web_search_tool_result_no_extra_tool_calls():
# Should have exactly 2 tool calls:
# 1. From content_block_start (server_tool_use) with id and name
# 2. From content_block_delta with the actual query
assert (
len(tool_calls_emitted) == 2
), f"Expected 2 tool calls, got {len(tool_calls_emitted)}"
assert len(tool_calls_emitted) == 2, f"Expected 2 tool calls, got {len(tool_calls_emitted)}"
# First tool call should have the id and name
assert tool_calls_emitted[0]["id"] == "srvtoolu_01ABC123"
@ -1113,9 +1015,7 @@ def test_current_content_block_type_tracking():
"""
Test that current_content_block_type is properly tracked and reset.
"""
iterator = ModelResponseIterator(
streaming_response=MagicMock(), sync_stream=True, json_mode=False
)
iterator = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=True, json_mode=False)
# Initially should be None
assert iterator.current_content_block_type is None
@ -1168,9 +1068,7 @@ def test_web_search_tool_result_captured_in_provider_specific_fields():
The web_search_tool_result content comes ALL AT ONCE in content_block_start,
not in deltas, so we need to capture it there.
"""
iterator = ModelResponseIterator(
streaming_response=MagicMock(), sync_stream=True, json_mode=False
)
iterator = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=True, json_mode=False)
# Simulate the streaming sequence with web_search_tool_result
chunks = [
@ -1241,23 +1139,15 @@ def test_web_search_tool_result_captured_in_provider_specific_fields():
and parsed.choices[0].delta.provider_specific_fields
and "web_search_results" in parsed.choices[0].delta.provider_specific_fields
):
web_search_results = parsed.choices[0].delta.provider_specific_fields[
"web_search_results"
]
web_search_results = parsed.choices[0].delta.provider_specific_fields["web_search_results"]
# Verify web_search_results was captured
assert web_search_results is not None, "web_search_results should be captured"
assert len(web_search_results) == 1, "Should have 1 web_search_tool_result block"
assert (
web_search_results[0]["type"] == "web_search_tool_result"
), "Block type should be web_search_tool_result"
assert (
web_search_results[0]["tool_use_id"] == "srvtoolu_01ABC123"
), "tool_use_id should match"
assert web_search_results[0]["type"] == "web_search_tool_result", "Block type should be web_search_tool_result"
assert web_search_results[0]["tool_use_id"] == "srvtoolu_01ABC123", "tool_use_id should match"
assert len(web_search_results[0]["content"]) == 2, "Should have 2 search results"
assert (
web_search_results[0]["content"][0]["title"] == "Fun Otter Facts"
), "First result title should match"
assert web_search_results[0]["content"][0]["title"] == "Fun Otter Facts", "First result title should match"
def test_web_fetch_tool_result_captured_in_provider_specific_fields():
@ -1271,9 +1161,7 @@ def test_web_fetch_tool_result_captured_in_provider_specific_fields():
The web_fetch_tool_result content comes ALL AT ONCE in content_block_start,
not in deltas, so we need to capture it there.
"""
iterator = ModelResponseIterator(
streaming_response=MagicMock(), sync_stream=True, json_mode=False
)
iterator = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=True, json_mode=False)
# Simulate the streaming sequence with web_fetch_tool_result
chunks = [
@ -1344,25 +1232,15 @@ def test_web_fetch_tool_result_captured_in_provider_specific_fields():
and parsed.choices[0].delta.provider_specific_fields
and "web_search_results" in parsed.choices[0].delta.provider_specific_fields
):
web_search_results = parsed.choices[0].delta.provider_specific_fields[
"web_search_results"
]
web_search_results = parsed.choices[0].delta.provider_specific_fields["web_search_results"]
# Verify web_fetch_tool_result was captured (stored in web_search_results list)
assert web_search_results is not None, "web_search_results should be captured"
assert len(web_search_results) == 1, "Should have 1 web_fetch_tool_result block"
assert (
web_search_results[0]["type"] == "web_fetch_tool_result"
), "Block type should be web_fetch_tool_result"
assert (
web_search_results[0]["tool_use_id"] == "srvtoolu_01ABC123"
), "tool_use_id should match"
assert (
web_search_results[0]["content"]["url"] == "https://example.com"
), "URL should match"
assert (
web_search_results[0]["content"]["content"]["title"] == "Example Page"
), "Title should match"
assert web_search_results[0]["type"] == "web_fetch_tool_result", "Block type should be web_fetch_tool_result"
assert web_search_results[0]["tool_use_id"] == "srvtoolu_01ABC123", "tool_use_id should match"
assert web_search_results[0]["content"]["url"] == "https://example.com", "URL should match"
assert web_search_results[0]["content"]["content"]["title"] == "Example Page", "Title should match"
def test_web_fetch_tool_result_no_extra_tool_calls():
@ -1375,9 +1253,7 @@ def test_web_fetch_tool_result_no_extra_tool_calls():
The issue was that web_fetch_tool_result blocks have input_json_delta events with {}
that were incorrectly being converted to tool calls.
"""
iterator = ModelResponseIterator(
streaming_response=MagicMock(), sync_stream=True, json_mode=False
)
iterator = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=True, json_mode=False)
# to verify it doesn't emit tool calls
chunks = [
@ -1421,9 +1297,9 @@ def test_web_fetch_tool_result_no_extra_tool_calls():
tool_call_count += 1
# Should have 0 tool calls - web_fetch_tool_result should not emit tool calls
assert (
tool_call_count == 0
), f"Expected 0 tool calls, got {tool_call_count}. web_fetch_tool_result should not emit tool calls"
assert tool_call_count == 0, (
f"Expected 0 tool calls, got {tool_call_count}. web_fetch_tool_result should not emit tool calls"
)
def test_container_in_provider_specific_fields_streaming():
@ -1433,9 +1309,7 @@ def test_container_in_provider_specific_fields_streaming():
When container with skills is used, the container field should be present in
the provider_specific_fields of the message_delta chunk.
"""
iterator = ModelResponseIterator(
streaming_response=MagicMock(), sync_stream=True, json_mode=False
)
iterator = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=True, json_mode=False)
# Simulate streaming chunks
chunks = [
@ -1503,20 +1377,12 @@ def test_container_in_provider_specific_fields_streaming():
and parsed.choices[0].delta.provider_specific_fields
and "container" in parsed.choices[0].delta.provider_specific_fields
):
container_field = parsed.choices[0].delta.provider_specific_fields[
"container"
]
container_field = parsed.choices[0].delta.provider_specific_fields["container"]
# Verify container was captured
assert (
container_field is not None
), "container should be captured in provider_specific_fields"
assert (
container_field["id"] == "container_011CW9hA9zpZ8xD3bjjShy4p"
), "container id should match"
assert (
container_field["expires_at"] == "2025-12-16T04:57:16.913181Z"
), "expires_at should match"
assert container_field is not None, "container should be captured in provider_specific_fields"
assert container_field["id"] == "container_011CW9hA9zpZ8xD3bjjShy4p", "container id should match"
assert container_field["expires_at"] == "2025-12-16T04:57:16.913181Z", "expires_at should match"
assert len(container_field["skills"]) == 1, "Should have 1 skill"
assert container_field["skills"][0]["skill_id"] == "pptx", "skill_id should be pptx"
assert container_field["skills"][0]["version"] == "20251013", "version should match"
@ -1529,9 +1395,7 @@ def test_container_in_provider_specific_fields_non_streaming():
When container with skills is used in non-streaming, the container field should be
present in the provider_specific_fields of the response.
"""
iterator = ModelResponseIterator(
streaming_response=MagicMock(), sync_stream=False, json_mode=False
)
iterator = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=False, json_mode=False)
# Simulate a message_delta chunk with container (as it would appear in non-streaming)
message_delta_chunk = {
@ -1567,21 +1431,13 @@ def test_container_in_provider_specific_fields_non_streaming():
# Verify container is in provider_specific_fields
assert model_response.choices[0].delta.provider_specific_fields is not None
assert "container" in model_response.choices[0].delta.provider_specific_fields
container_field = model_response.choices[0].delta.provider_specific_fields[
"container"
]
container_field = model_response.choices[0].delta.provider_specific_fields["container"]
assert container_field["id"] == "container_abc123xyz", "container id should match"
assert (
container_field["expires_at"] == "2025-12-20T10:30:00.000000Z"
), "expires_at should match"
assert container_field["expires_at"] == "2025-12-20T10:30:00.000000Z", "expires_at should match"
assert len(container_field["skills"]) == 2, "Should have 2 skills"
assert (
container_field["skills"][0]["skill_id"] == "code_execution"
), "First skill_id should be code_execution"
assert (
container_field["skills"][1]["skill_id"] == "pptx"
), "Second skill_id should be pptx"
assert container_field["skills"][0]["skill_id"] == "code_execution", "First skill_id should be code_execution"
assert container_field["skills"][1]["skill_id"] == "pptx", "Second skill_id should be pptx"
def test_container_absent_when_not_provided():
@ -1590,9 +1446,7 @@ def test_container_absent_when_not_provided():
This ensures we don't add empty or None container fields.
"""
iterator = ModelResponseIterator(
streaming_response=MagicMock(), sync_stream=False, json_mode=False
)
iterator = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=False, json_mode=False)
# message_delta without container
message_delta_chunk = {
@ -1611,9 +1465,9 @@ def test_container_absent_when_not_provided():
# Verify container is NOT in provider_specific_fields when not provided
if model_response.choices[0].delta.provider_specific_fields:
assert (
"container" not in model_response.choices[0].delta.provider_specific_fields
), "container should not be present when not provided in delta"
assert "container" not in model_response.choices[0].delta.provider_specific_fields, (
"container should not be present when not provided in delta"
)
def test_streaming_code_execution_produces_code_interpreter_results():
@ -1809,8 +1663,7 @@ def test_streaming_multiple_code_executions_no_duplicates():
# Second (final) emission: cumulative list with BOTH results
# This is what stream_chunk_builder will pick as "last value wins"
assert len(emissions[1]) == 2, (
f"Expected final emission to have 2 results, got {len(emissions[1])}. "
f"IDs: {[r.id for r in emissions[1]]}"
f"Expected final emission to have 2 results, got {len(emissions[1])}. IDs: {[r.id for r in emissions[1]]}"
)
assert emissions[1][0].id == "srvtoolu_01AAA"
assert emissions[1][0].code == "echo first"
@ -1974,9 +1827,7 @@ def test_empty_output_produces_null_outputs():
assert code_results is not None, "No code_interpreter_results emitted"
assert len(code_results) == 1
assert code_results[0].id == "srvtoolu_01AAA"
assert (
code_results[0].outputs is None
), f"Expected outputs=None for empty execution, got {code_results[0].outputs}"
assert code_results[0].outputs is None, f"Expected outputs=None for empty execution, got {code_results[0].outputs}"
def test_non_bash_tool_result_skipped():
@ -2039,12 +1890,10 @@ def test_non_bash_tool_result_skipped():
code_results = psf["code_interpreter_results"]
# code_interpreter_results should be emitted but empty (no bash results)
assert (
code_results is not None
), "Expected code_interpreter_results key to be emitted"
assert (
len(code_results) == 0
), f"Expected 0 code_interpreter_results for text_editor result, got {len(code_results)}"
assert code_results is not None, "Expected code_interpreter_results key to be emitted"
assert len(code_results) == 0, (
f"Expected 0 code_interpreter_results for text_editor result, got {len(code_results)}"
)
class TestRustChatCompletionsHook:
@ -2081,13 +1930,9 @@ class TestRustChatCompletionsHook:
from litellm.rust_bridge import chat_completions as bridge
monkeypatch.delenv("LITELLM_RUST", raising=False)
bridge.set_rust_chat_completions(
chat_completions=None, achat_completions=None, decline=None
)
bridge.set_rust_chat_completions(chat_completions=None, achat_completions=None, decline=None)
yield
bridge.set_rust_chat_completions(
chat_completions=None, achat_completions=None, decline=None
)
bridge.set_rust_chat_completions(chat_completions=None, achat_completions=None, decline=None)
@staticmethod
def _completion_kwargs(**overrides):
@ -2185,9 +2030,7 @@ class TestRustChatCompletionsHook:
from litellm.llms.anthropic.chat.handler import AnthropicChatCompletion
seen = self._inject()
AnthropicChatCompletion().completion(
**self._completion_kwargs(optional_params={"max_tokens": 7})
)
AnthropicChatCompletion().completion(**self._completion_kwargs(optional_params={"max_tokens": 7}))
assert seen["call"][0]["optional_params"]["max_tokens"] == 7
def test_without_the_opt_in_the_core_is_never_consulted(self):
@ -2195,15 +2038,14 @@ class TestRustChatCompletionsHook:
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
seen = self._inject()
with patch.object(
AnthropicConfig, "transform_request", return_value={"model": "m", "messages": []}
) as transform, patch.object(
AnthropicChatCompletion, "acompletion_function"
with (
patch.object(
AnthropicConfig, "transform_request", return_value={"model": "m", "messages": []}
) as transform,
patch.object(AnthropicChatCompletion, "acompletion_function"),
):
try:
AnthropicChatCompletion().completion(
**self._completion_kwargs(litellm_params={})
)
AnthropicChatCompletion().completion(**self._completion_kwargs(litellm_params={}))
except Exception:
# The Python path goes on to make an HTTP call; reaching it is
# the assertion, so the network failure below is expected.
@ -2217,9 +2059,7 @@ class TestRustChatCompletionsHook:
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
seen = self._inject(decline_reason="unrecognized request parameter")
with patch.object(
AnthropicConfig, "transform_request", return_value={"model": "m", "messages": []}
):
with patch.object(AnthropicConfig, "transform_request", return_value={"model": "m", "messages": []}):
try:
AnthropicChatCompletion().completion(**self._completion_kwargs())
except Exception:
@ -2232,9 +2072,7 @@ class TestRustChatCompletionsHook:
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
seen = self._inject()
with patch.object(
AnthropicConfig, "transform_request", return_value={"model": "m", "messages": []}
):
with patch.object(AnthropicConfig, "transform_request", return_value={"model": "m", "messages": []}):
try:
AnthropicChatCompletion().completion(
**self._completion_kwargs(optional_params={"max_tokens": 16, "stream": True})
@ -2248,9 +2086,7 @@ class TestRustChatCompletionsHook:
seen = self._inject()
logging_obj = MagicMock()
AnthropicChatCompletion().completion(
**self._completion_kwargs(logging_obj=logging_obj)
)
AnthropicChatCompletion().completion(**self._completion_kwargs(logging_obj=logging_obj))
assert logging_obj.pre_call.call_count == 1
assert len(seen["call"]) == 1
@ -2264,9 +2100,7 @@ class TestRustChatCompletionsHook:
self._inject()
logging_obj = MagicMock()
AnthropicChatCompletion().completion(
**self._completion_kwargs(logging_obj=logging_obj)
)
AnthropicChatCompletion().completion(**self._completion_kwargs(logging_obj=logging_obj))
assert logging_obj.post_call.call_count == 1
logged = logging_obj.post_call.call_args.kwargs["original_response"]
@ -2291,18 +2125,12 @@ class TestRustChatCompletionsHook:
raise _Declined("blank message text")
monkeypatch.setattr(bridge, "get_native_bridge", lambda: _FakeNative())
bridge.set_rust_chat_completions(
decline=lambda **_kwargs: None, chat_completions=declining_native
)
bridge.set_rust_chat_completions(decline=lambda **_kwargs: None, chat_completions=declining_native)
logging_obj, calls = self._recording_logging_obj()
with patch.object(
AnthropicConfig, "transform_request", return_value={"model": "m", "messages": []}
):
with patch.object(AnthropicConfig, "transform_request", return_value={"model": "m", "messages": []}):
try:
AnthropicChatCompletion().completion(
**self._completion_kwargs(logging_obj=logging_obj)
)
AnthropicChatCompletion().completion(**self._completion_kwargs(logging_obj=logging_obj))
except Exception:
# The Python path goes on to make an HTTP call; the log count is
# the assertion, so a failure past this point is expected.
@ -2327,21 +2155,15 @@ class TestRustChatCompletionsHook:
async def declining_native(**_kwargs):
raise _Declined("blank message text")
bridge.set_rust_chat_completions(
decline=lambda **_kwargs: None, achat_completions=declining_native
)
bridge.set_rust_chat_completions(decline=lambda **_kwargs: None, achat_completions=declining_native)
sentinel = object()
async def python_path(**_kwargs):
return sentinel
with patch.object(
AnthropicChatCompletion, "acompletion_function", side_effect=python_path
) as python_call:
result = await AnthropicChatCompletion().completion(
**self._completion_kwargs(acompletion=True)
)
with patch.object(AnthropicChatCompletion, "acompletion_function", side_effect=python_path) as python_call:
result = await AnthropicChatCompletion().completion(**self._completion_kwargs(acompletion=True))
assert result is sentinel
assert python_call.called, "a failing rust call must re-enter the python path"
@ -2354,20 +2176,15 @@ class TestRustChatCompletionsHook:
async def native(**_kwargs):
return dict(self.RUST_RESPONSE)
bridge.set_rust_chat_completions(
decline=lambda **_kwargs: None, achat_completions=native
)
bridge.set_rust_chat_completions(decline=lambda **_kwargs: None, achat_completions=native)
with patch.object(AnthropicChatCompletion, "acompletion_function") as python_call:
result = await AnthropicChatCompletion().completion(
**self._completion_kwargs(acompletion=True)
)
result = await AnthropicChatCompletion().completion(**self._completion_kwargs(acompletion=True))
assert result.choices[0].message.content == "hello from rust"
assert result._hidden_params["additional_headers"] == {"x-litellm-rust": "true"}
assert not python_call.called
def test_pre_call_logging_fires_once_when_the_sync_rust_call_declines(self, monkeypatch):
"""One request, one pre_call, on the synchronous path too. Without the
suppression the Python path logs a second time for the same attempt."""
@ -2387,27 +2204,19 @@ class TestRustChatCompletionsHook:
def declining_native(**_kwargs):
raise _Declined("blank message text")
bridge.set_rust_chat_completions(
decline=lambda **_kwargs: None, chat_completions=declining_native
)
bridge.set_rust_chat_completions(decline=lambda **_kwargs: None, chat_completions=declining_native)
logging_obj, calls = self._recording_logging_obj()
with patch.object(
AnthropicConfig, "transform_request", return_value={"model": "m", "messages": []}
):
with patch.object(AnthropicConfig, "transform_request", return_value={"model": "m", "messages": []}):
try:
AnthropicChatCompletion().completion(
**self._completion_kwargs(logging_obj=logging_obj)
)
AnthropicChatCompletion().completion(**self._completion_kwargs(logging_obj=logging_obj))
except Exception:
# The Python path goes on to make an HTTP call; the log count is
# the assertion, so a failure past this point is expected.
pass
assert len(calls["pre_call"]) == 1
assert calls["pre_call"][0]["additional_args"]["complete_input_dict"]["model"] == (
"claude-sonnet-4-5"
)
assert calls["pre_call"][0]["additional_args"]["complete_input_dict"]["model"] == ("claude-sonnet-4-5")
def test_pre_call_logging_still_fires_when_rust_is_not_involved(self, monkeypatch):
"""The suppression must not swallow the log on the ordinary path."""
@ -2416,9 +2225,7 @@ class TestRustChatCompletionsHook:
self._inject()
logging_obj, calls = self._recording_logging_obj()
with patch.object(
AnthropicConfig, "transform_request", return_value={"model": "m", "messages": []}
):
with patch.object(AnthropicConfig, "transform_request", return_value={"model": "m", "messages": []}):
try:
AnthropicChatCompletion().completion(
**self._completion_kwargs(litellm_params={}, logging_obj=logging_obj)

View file

@ -1,4 +1,3 @@
import pytest
from unittest.mock import MagicMock, patch
@ -33,13 +32,9 @@ def test_response_format_transformation_unit_test():
"additionalProperties": False,
}
result = config._create_json_tool_call_for_response_format(
json_schema=response_format_json_schema
)
result = config._create_json_tool_call_for_response_format(json_schema=response_format_json_schema)
assert result["input_schema"]["properties"] == {
"agent_doing": {"title": "Agent Doing", "type": "string"}
}
assert result["input_schema"]["properties"] == {"agent_doing": {"title": "Agent Doing", "type": "string"}}
print(result)
@ -508,9 +503,7 @@ def test_extract_response_content_with_citations():
},
}
_, citations, _, _, _, _, _, _ = config.extract_response_content(
completion_response
)
_, citations, _, _, _, _, _, _ = config.extract_response_content(completion_response)
assert citations == [
[
{
@ -583,12 +576,8 @@ def test_web_search_tool_transformation():
assert anthropic_web_search_tool["user_location"]["city"] == "San Francisco"
@pytest.mark.parametrize(
"search_context_size, expected_max_uses", [("low", 1), ("medium", 5), ("high", 10)]
)
def test_web_search_tool_transformation_with_search_context_size(
search_context_size, expected_max_uses
):
@pytest.mark.parametrize("search_context_size, expected_max_uses", [("low", 1), ("medium", 5), ("high", 10)])
def test_web_search_tool_transformation_with_search_context_size(search_context_size, expected_max_uses):
from litellm.types.llms.openai import OpenAIWebSearchOptions
config = AnthropicConfig()
@ -763,10 +752,7 @@ def test_web_search_tool_result_in_provider_specific_fields():
assert "web_search_results" in provider_fields
assert len(provider_fields["web_search_results"]) == 1
assert provider_fields["web_search_results"][0]["type"] == "web_search_tool_result"
assert (
provider_fields["web_search_results"][0]["tool_use_id"]
== "srvtoolu_provider_test"
)
assert provider_fields["web_search_results"][0]["tool_use_id"] == "srvtoolu_provider_test"
def test_multiple_web_search_tool_results():
@ -990,10 +976,7 @@ def test_transform_response_with_prefix_prompt():
)
assert result is not None
assert (
result.choices[0].message.content
== "You are a helpful assistant. The grass is green."
)
assert result.choices[0].message.content == "You are a helpful assistant. The grass is green."
def test_get_supported_params_thinking():
@ -1107,18 +1090,12 @@ def test_anthropic_beta_header_merging_with_output_format():
}
}
result_headers = config.update_headers_with_optional_anthropic_beta(
headers, optional_params
)
result_headers = config.update_headers_with_optional_anthropic_beta(headers, optional_params)
# Both beta headers should be present
beta_value = result_headers["anthropic-beta"]
assert (
"context-1m-2025-08-07" in beta_value
), f"User's context-1m beta header missing from: {beta_value}"
assert (
"structured-outputs-2025-11-13" in beta_value
), f"Structured output beta header missing from: {beta_value}"
assert "context-1m-2025-08-07" in beta_value, f"User's context-1m beta header missing from: {beta_value}"
assert "structured-outputs-2025-11-13" in beta_value, f"Structured output beta header missing from: {beta_value}"
def test_anthropic_beta_header_merging_with_multiple_features():
@ -1140,9 +1117,7 @@ def test_anthropic_beta_header_merging_with_multiple_features():
"tools": [{"type": "web_fetch_20250910", "name": "web_fetch"}],
}
result_headers = config.update_headers_with_optional_anthropic_beta(
headers, optional_params
)
result_headers = config.update_headers_with_optional_anthropic_beta(headers, optional_params)
beta_value = result_headers["anthropic-beta"]
@ -1185,9 +1160,7 @@ def test_anthropic_structured_output_beta_header():
"strict": True,
"schema": {
"description": 'Progress report for the thinking process\n\nThis model represents a snapshot of the agent\'s current progress during\nthe thinking process, providing a brief description of the current activity.\n\nAttributes:\n agent_doing: Brief description of what the agent is currently doing.\n Should be kept under 10 words. Example: "Learning about home automation"',
"properties": {
"agent_doing": {"title": "Agent Doing", "type": "string"}
},
"properties": {"agent_doing": {"title": "Agent Doing", "type": "string"}},
"required": ["agent_doing"],
"title": "ThinkingStep",
"type": "object",
@ -1201,10 +1174,7 @@ def test_anthropic_structured_output_beta_header():
assert response is not None
print(f"response: {response}")
print(f"raw_request_headers: {response['raw_request_headers']}")
assert (
"structured-outputs-2025-11-13"
in response["raw_request_headers"]["anthropic-beta"]
)
assert "structured-outputs-2025-11-13" in response["raw_request_headers"]["anthropic-beta"]
@pytest.mark.parametrize(
@ -1340,9 +1310,7 @@ def test_tool_search_regex_detection():
config = AnthropicModelInfo()
# Test with tool search regex tool
tools = [
{"type": "tool_search_tool_regex_20251119", "name": "tool_search_tool_regex"}
]
tools = [{"type": "tool_search_tool_regex_20251119", "name": "tool_search_tool_regex"}]
assert config.is_tool_search_used(tools) is True
# Test without tool search
@ -1357,9 +1325,7 @@ def test_tool_search_bm25_detection():
config = AnthropicModelInfo()
# Test with tool search BM25 tool
tools = [
{"type": "tool_search_tool_bm25_20251119", "name": "tool_search_tool_bm25"}
]
tools = [{"type": "tool_search_tool_bm25_20251119", "name": "tool_search_tool_bm25"}]
assert config.is_tool_search_used(tools) is True
@ -1551,9 +1517,7 @@ def test_tool_search_complete_response_parsing():
"tool_use_id": "srvtoolu_015i6aVA2niwzv4RG4DtnxDJ",
"content": {
"type": "tool_search_tool_search_result",
"tool_references": [
{"type": "tool_reference", "tool_name": "get_weather"}
],
"tool_references": [{"type": "tool_reference", "tool_name": "get_weather"}],
},
},
{"type": "text", "text": "Great! I found a weather tool."},
@ -1604,9 +1568,7 @@ def test_tool_search_complete_response_parsing():
assert usage.server_tool_use is not None
assert usage.server_tool_use.web_search_requests == 0
assert (
usage.server_tool_use.tool_search_requests == 1
) # Counted from server_tool_use blocks
assert usage.server_tool_use.tool_search_requests == 1 # Counted from server_tool_use blocks
def test_allowed_callers_field_preservation():
@ -1658,9 +1620,7 @@ def test_programmatic_tool_calling_beta_header():
assert is_programmatic is True
# Test header generation
headers = model_info.get_anthropic_headers(
api_key="test-key", programmatic_tool_calling_used=True
)
headers = model_info.get_anthropic_headers(api_key="test-key", programmatic_tool_calling_used=True)
assert "anthropic-beta" in headers
assert "advanced-tool-use-2025-11-20" in headers["anthropic-beta"]
@ -1804,9 +1764,7 @@ def test_input_examples_beta_header():
assert is_examples_used is True
# Test header generation
headers = model_info.get_anthropic_headers(
api_key="test-key", input_examples_used=True
)
headers = model_info.get_anthropic_headers(api_key="test-key", input_examples_used=True)
assert "anthropic-beta" in headers
assert "advanced-tool-use-2025-11-20" in headers["anthropic-beta"]
@ -1892,10 +1850,7 @@ def test_input_examples_empty_list_not_added():
transformed_tool, _ = config._map_tool_helper(tool)
assert transformed_tool is not None
# Empty list should not be added
assert (
"input_examples" not in transformed_tool
or len(transformed_tool.get("input_examples", [])) == 0
)
assert "input_examples" not in transformed_tool or len(transformed_tool.get("input_examples", [])) == 0
# ============ Effort Parameter Tests ============
@ -1955,9 +1910,7 @@ def test_effort_beta_header_injection():
effort_used = model_info.is_effort_used(optional_params=optional_params, custom_llm_provider="anthropic")
assert effort_used is True
headers = model_info.get_anthropic_headers(
api_key="test-key", effort_used=effort_used
)
headers = model_info.get_anthropic_headers(api_key="test-key", effort_used=effort_used)
assert "anthropic-beta" in headers
assert "effort-2025-11-24" in headers["anthropic-beta"]
@ -1983,9 +1936,7 @@ def test_effort_validation():
optional_params = {"output_config": {"effort": "invalid"}}
with pytest.raises(
litellm.exceptions.BadRequestError, match="Invalid effort value"
):
with pytest.raises(litellm.exceptions.BadRequestError, match="Invalid effort value"):
config.transform_request(
model="claude-opus-4-5-20251101",
messages=messages,
@ -2221,16 +2172,8 @@ def test_anthropic_model_supports_speed_param_rejects_non_anthropic_providers(
):
"""Fast mode is direct-Anthropic-only. Vertex/Azure/Bedrock strip their prefix
before the shared transform runs, so the bare Opus id must still be rejected."""
assert (
AnthropicConfig._model_supports_speed_param(
"claude-opus-4-8", custom_llm_provider
)
is False
)
assert (
AnthropicConfig._model_supports_speed_param("claude-opus-4-8", "anthropic")
is True
)
assert AnthropicConfig._model_supports_speed_param("claude-opus-4-8", custom_llm_provider) is False
assert AnthropicConfig._model_supports_speed_param("claude-opus-4-8", "anthropic") is True
def test_vertex_anthropic_drops_speed_for_opus_with_drop_params(monkeypatch):
@ -2529,9 +2472,7 @@ def test_supports_effort_level_handles_provider_prefixes(model, level, expected)
("claude-opus-4-5-20251101", None, False),
],
)
def test_validate_effort_for_model_centralises_per_model_gating(
model, effort, expect_error
):
def test_validate_effort_for_model_centralises_per_model_gating(model, effort, expect_error):
err = AnthropicConfig._validate_effort_for_model(model, effort, "anthropic")
if expect_error:
assert err is not None
@ -2580,11 +2521,7 @@ def test_transform_request_injects_dummy_tool_without_tools_param():
litellm.modify_params = prev_modify_params
assert "tools" in result
names = [
t.get("name")
for t in result["tools"]
if isinstance(t, dict) and t.get("name") is not None
]
names = [t.get("name") for t in result["tools"] if isinstance(t, dict) and t.get("name") is not None]
assert "dummy_tool" in names
@ -2673,13 +2610,9 @@ def test_calculate_usage_completion_tokens_details_with_reasoning():
"output_tokens": 500,
}
# Simulating reasoning content that would count as ~50 tokens
reasoning_content = (
"Let me think about this step by step. " * 10
) # Roughly 50 tokens
reasoning_content = "Let me think about this step by step. " * 10 # Roughly 50 tokens
usage = config.calculate_usage(
usage_object=usage_object, reasoning_content=reasoning_content
)
usage = config.calculate_usage(usage_object=usage_object, reasoning_content=reasoning_content)
# completion_tokens_details should be populated with both reasoning and text tokens
assert usage.completion_tokens_details is not None
@ -2730,9 +2663,7 @@ def test_reasoning_effort_maps_to_adaptive_thinking_for_claude_4_6_models():
# reasoning_effort should not be in the result (it's transformed to thinking)
assert "reasoning_effort" not in result
# Should set output_config with the mapped effort value
assert (
"output_config" in result
), f"output_config missing for {model} with effort={effort}"
assert "output_config" in result, f"output_config missing for {model} with effort={effort}"
assert result["output_config"]["effort"] == effort_map[effort]
@ -2808,7 +2739,6 @@ def test_raw_adaptive_thinking_untouched_for_46_plus_model():
assert result["thinking"] == {"type": "adaptive"}
@pytest.mark.parametrize(
"model, expected",
[
@ -2834,9 +2764,7 @@ def test_raw_adaptive_thinking_untouched_for_46_plus_model():
("gpt-4o", False),
],
)
def test_is_adaptive_thinking_model_is_sourced_from_cost_map(
local_model_cost_map, model, expected
):
def test_is_adaptive_thinking_model_is_sourced_from_cost_map(local_model_cost_map, model, expected):
"""Adaptive thinking resolves from the cost map first (an explicit
supports_adaptive_thinking entry, or the anthropic-claude fallback rule for unmapped
future Claudes), then from a date-safe opus/sonnet/haiku >= 4.6 name version as a
@ -2952,9 +2880,7 @@ def test_reasoning_effort_sets_output_config_for_46_models():
drop_params=False,
)
assert (
"output_config" in result
), f"output_config missing for {model} with effort={effort}"
assert "output_config" in result, f"output_config missing for {model} with effort={effort}"
assert result["output_config"]["effort"] == effort
@ -2993,9 +2919,7 @@ def test_reasoning_effort_does_not_set_output_config_for_older_models():
drop_params=False,
)
assert (
"output_config" not in result
), f"output_config should not be set for {model}"
assert "output_config" not in result, f"output_config should not be set for {model}"
@pytest.mark.parametrize(
@ -3035,14 +2959,10 @@ def test_reasoning_effort_accepts_dict_shape_for_adaptive_model(reasoning_effort
)
# thinking must be set (adaptive for 4.6+)
assert (
"thinking" in result
), f"thinking missing for reasoning_effort={reasoning_effort_value!r}"
assert "thinking" in result, f"thinking missing for reasoning_effort={reasoning_effort_value!r}"
assert result["thinking"]["type"] == "adaptive"
# output_config must carry the mapped effort
assert (
"output_config" in result
), f"output_config missing for reasoning_effort={reasoning_effort_value!r}"
assert "output_config" in result, f"output_config missing for reasoning_effort={reasoning_effort_value!r}"
assert result["output_config"]["effort"] == "low"
@ -3071,16 +2991,13 @@ def test_reasoning_effort_accepts_dict_shape_for_non_adaptive_model(
drop_params=False,
)
assert (
"thinking" in result
), f"thinking missing for reasoning_effort={reasoning_effort_value!r}"
assert "thinking" in result, f"thinking missing for reasoning_effort={reasoning_effort_value!r}"
assert result["thinking"]["type"] == "enabled"
assert "budget_tokens" in result["thinking"]
assert result["thinking"]["budget_tokens"] > 0
# Older models must not get adaptive-thinking output_config
assert "output_config" not in result, (
f"output_config should not be set for non-adaptive model "
f"(reasoning_effort={reasoning_effort_value!r})"
f"output_config should not be set for non-adaptive model (reasoning_effort={reasoning_effort_value!r})"
)
@ -3106,12 +3023,8 @@ def test_reasoning_effort_unparseable_dict_is_dropped(bad_value):
model="claude-sonnet-4-6-20260219",
drop_params=False,
)
assert (
"thinking" not in result
), f"thinking should not be set for bad value {bad_value!r}"
assert (
"output_config" not in result
), f"output_config should not be set for bad value {bad_value!r}"
assert "thinking" not in result, f"thinking should not be set for bad value {bad_value!r}"
assert "output_config" not in result, f"output_config should not be set for bad value {bad_value!r}"
@pytest.mark.parametrize(
@ -3242,9 +3155,7 @@ def test_reasoning_effort_garbage_raises_bad_request(effort):
("max", DEFAULT_REASONING_EFFORT_MAX_THINKING_BUDGET),
],
)
def test_reasoning_effort_xhigh_max_maps_to_budget_on_budget_model(
effort, expected_budget
):
def test_reasoning_effort_xhigh_max_maps_to_budget_on_budget_model(effort, expected_budget):
"""``xhigh`` / ``max`` extend the budget_tokens progression on budget-mode models."""
config = AnthropicConfig()
@ -3391,17 +3302,11 @@ def test_code_execution_tool_results_extraction():
# Verify first tool call
assert transformed_response.choices[0].message.tool_calls[0].id == "srvtoolu_01ABC"
assert (
transformed_response.choices[0].message.tool_calls[0].function.name
== "bash_code_execution"
)
assert transformed_response.choices[0].message.tool_calls[0].function.name == "bash_code_execution"
# Verify second tool call
assert transformed_response.choices[0].message.tool_calls[1].id == "srvtoolu_01DEF"
assert (
transformed_response.choices[0].message.tool_calls[1].function.name
== "text_editor_code_execution"
)
assert transformed_response.choices[0].message.tool_calls[1].function.name == "text_editor_code_execution"
# Verify tool results are in provider_specific_fields
provider_fields = transformed_response.choices[0].message.provider_specific_fields
@ -3424,10 +3329,7 @@ def test_code_execution_tool_results_extraction():
assert editor_result["content"]["is_file_update"] is False
# Verify text content is properly concatenated
assert (
"I'll calculate that for you."
in transformed_response.choices[0].message.content
)
assert "I'll calculate that for you." in transformed_response.choices[0].message.content
assert "Done!" in transformed_response.choices[0].message.content
@ -3495,10 +3397,7 @@ def test_code_execution_tool_results_in_hidden_params():
assert "provider_specific_fields" in hidden
assert "tool_results" in hidden["provider_specific_fields"]
assert len(hidden["provider_specific_fields"]["tool_results"]) == 1
assert (
hidden["provider_specific_fields"]["tool_results"][0]["content"]["stdout"]
== "hello\n"
)
assert hidden["provider_specific_fields"]["tool_results"][0]["content"]["stdout"] == "hello\n"
def test_tool_search_tool_result_not_in_tool_results():
@ -3694,10 +3593,7 @@ def test_compaction_block_in_provider_specific_fields():
assert "compaction_blocks" in provider_fields
assert len(provider_fields["compaction_blocks"]) == 1
assert provider_fields["compaction_blocks"][0]["type"] == "compaction"
assert (
"Summary of the conversation"
in provider_fields["compaction_blocks"][0]["content"]
)
assert "Summary of the conversation" in provider_fields["compaction_blocks"][0]["content"]
def test_multiple_compaction_blocks():
@ -3745,9 +3641,7 @@ def test_compaction_block_request_transformation():
{"role": "user", "content": "What is the weather in San Francisco?"},
{
"role": "assistant",
"content": [
{"type": "text", "text": "I don't have access to real-time data."}
],
"content": [{"type": "text", "text": "I don't have access to real-time data."}],
"provider_specific_fields": {
"compaction_blocks": [
{
@ -3760,9 +3654,7 @@ def test_compaction_block_request_transformation():
{"role": "user", "content": "What about New York?"},
]
result = anthropic_messages_pt(
messages=messages, model="claude-opus-4-6", llm_provider="anthropic"
)
result = anthropic_messages_pt(messages=messages, model="claude-opus-4-6", llm_provider="anthropic")
# Find the assistant message
assistant_message = None
@ -3876,9 +3768,7 @@ def test_map_openai_context_management_to_anthropic():
"instructions": "Focus on preserving code snippets",
}
]
result = config.map_openai_context_management_to_anthropic(
openai_format_with_instructions
)
result = config.map_openai_context_management_to_anthropic(openai_format_with_instructions)
assert result is not None
assert result["edits"][0]["trigger"]["value"] == 150000
@ -3905,9 +3795,7 @@ def test_map_openai_params_with_context_management():
config = AnthropicConfig()
# Test with OpenAI list format
non_default_params = {
"context_management": [{"type": "compaction", "compact_threshold": 200000}]
}
non_default_params = {"context_management": [{"type": "compaction", "compact_threshold": 200000}]}
optional_params = {}
result = config.map_openai_params(
@ -3944,10 +3832,7 @@ def test_map_openai_params_with_context_management():
)
assert "context_management" in result
assert (
result["context_management"]
== non_default_params_anthropic["context_management"]
)
assert result["context_management"] == non_default_params_anthropic["context_management"]
def test_cache_control_in_supported_params():
@ -4058,10 +3943,7 @@ def test_compaction_block_empty_list_not_added():
# Verify compaction_blocks is not in provider_specific_fields when there are none
provider_fields = result.choices[0].message.provider_specific_fields
if provider_fields:
assert (
"compaction_blocks" not in provider_fields
or provider_fields.get("compaction_blocks") is None
)
assert "compaction_blocks" not in provider_fields or provider_fields.get("compaction_blocks") is None
def test_fast_mode_beta_header():
@ -4110,9 +3992,7 @@ def test_fast_mode_usage_calculation():
"output_tokens": 500,
}
usage = config.calculate_usage(
usage_object=usage_object, reasoning_content=None, speed="fast"
)
usage = config.calculate_usage(usage_object=usage_object, reasoning_content=None, speed="fast")
assert usage.prompt_tokens == 1000
assert usage.completion_tokens == 500
@ -4133,9 +4013,7 @@ def test_fast_mode_cost_calculation():
base_completion = 0.025
with (
patch(
"litellm.llms.anthropic.cost_calculation.generic_cost_per_token"
) as mock_cost,
patch("litellm.llms.anthropic.cost_calculation.generic_cost_per_token") as mock_cost,
patch("litellm.get_model_info") as mock_info,
):
mock_cost.return_value = (base_prompt, base_completion)
@ -4175,9 +4053,7 @@ def test_fast_mode_with_inference_geo():
base_completion = 0.025
with (
patch(
"litellm.llms.anthropic.cost_calculation.generic_cost_per_token"
) as mock_cost,
patch("litellm.llms.anthropic.cost_calculation.generic_cost_per_token") as mock_cost,
patch("litellm.get_model_info") as mock_info,
):
mock_cost.return_value = (base_prompt, base_completion)
@ -4368,9 +4244,7 @@ def test_map_tool_helper_enforces_object_type_when_missing():
"name": "search_code",
"description": "Search for code patterns",
"parameters": {
"properties": {
"query": {"type": "string", "description": "Search query"}
},
"properties": {"query": {"type": "string", "description": "Search query"}},
"required": ["query"],
},
},
@ -4383,9 +4257,9 @@ def test_map_tool_helper_enforces_object_type_when_missing():
assert "properties" in result["input_schema"]
assert "query" in result["input_schema"]["properties"]
# Original parameters dict must not be modified in place
assert (
tool["function"]["parameters"] == original_params
), "parameters dict was mutated; _map_tool_helper should not modify caller data"
assert tool["function"]["parameters"] == original_params, (
"parameters dict was mutated; _map_tool_helper should not modify caller data"
)
def test_map_tool_helper_enforces_object_type_when_wrong_type():
@ -4411,13 +4285,13 @@ def test_map_tool_helper_enforces_object_type_when_wrong_type():
result, _ = config._map_tool_helper(tool)
assert result is not None
assert result["input_schema"]["type"] == "object"
assert (
result["input_schema"].get("properties") == {}
), "properties should be injected as {} when schema has non-object type and no properties key"
assert result["input_schema"].get("properties") == {}, (
"properties should be injected as {} when schema has non-object type and no properties key"
)
# Original parameters dict must not be modified in place
assert (
tool["function"]["parameters"] == original_params
), "parameters dict was mutated; _map_tool_helper should not modify caller data"
assert tool["function"]["parameters"] == original_params, (
"parameters dict was mutated; _map_tool_helper should not modify caller data"
)
def test_map_tool_helper_preserves_valid_object_schema():
@ -4484,12 +4358,8 @@ def test_extract_response_content_thinking_block_null_thinking():
{"type": "text", "text": "Hello"},
]
}
text, _, thinking_blocks, _, _, _, _, _ = config.extract_response_content(
completion_response_null
)
assert (
thinking_blocks is not None
), "thinking blocks should not be None when thinking=null"
text, _, thinking_blocks, _, _, _, _, _ = config.extract_response_content(completion_response_null)
assert thinking_blocks is not None, "thinking blocks should not be None when thinking=null"
assert len(thinking_blocks) == 1
assert "Hello" in text
@ -4500,12 +4370,8 @@ def test_extract_response_content_thinking_block_null_thinking():
{"type": "text", "text": "World"},
]
}
text, _, thinking_blocks, _, _, _, _, _ = config.extract_response_content(
completion_response_missing
)
assert (
thinking_blocks is not None
), "thinking blocks should not be None when thinking key is absent"
text, _, thinking_blocks, _, _, _, _, _ = config.extract_response_content(completion_response_missing)
assert thinking_blocks is not None, "thinking blocks should not be None when thinking key is absent"
assert len(thinking_blocks) == 1
assert "World" in text
@ -4516,9 +4382,7 @@ def test_extract_response_content_thinking_block_null_thinking():
{"type": "text", "text": "Done"},
]
}
text, _, thinking_blocks, _, _, _, _, _ = config.extract_response_content(
completion_response_text
)
text, _, thinking_blocks, _, _, _, _, _ = config.extract_response_content(completion_response_text)
assert thinking_blocks is not None
assert len(thinking_blocks) == 1
assert thinking_blocks[0]["thinking"] == "Let me think..."
@ -4577,12 +4441,8 @@ def test_advisor_beta_header_injected():
}
]
}
result = config.update_headers_with_optional_anthropic_beta(
headers, optional_params
)
assert ANTHROPIC_BETA_HEADER_VALUES.ADVISOR_TOOL_2026_03_01.value in result.get(
"anthropic-beta", ""
)
result = config.update_headers_with_optional_anthropic_beta(headers, optional_params)
assert ANTHROPIC_BETA_HEADER_VALUES.ADVISOR_TOOL_2026_03_01.value in result.get("anthropic-beta", "")
def test_advisor_beta_header_not_injected_without_tool():
@ -4590,9 +4450,7 @@ def test_advisor_beta_header_not_injected_without_tool():
config = AnthropicConfig()
headers: dict = {}
optional_params: dict = {"tools": []}
result = config.update_headers_with_optional_anthropic_beta(
headers, optional_params
)
result = config.update_headers_with_optional_anthropic_beta(headers, optional_params)
assert "advisor-tool-2026-03-01" not in result.get("anthropic-beta", "")
@ -4619,9 +4477,7 @@ def test_advisor_tool_result_preserved_in_response():
{"type": "text", "text": "Here is the implementation."},
]
}
text, _, _, _, tool_calls, _, tool_results, _ = config.extract_response_content(
completion_response
)
text, _, _, _, tool_calls, _, tool_results, _ = config.extract_response_content(completion_response)
assert "Consulting advisor." in text
assert "Here is the implementation." in text
# server_tool_use (advisor) should be a tool_call
@ -4736,9 +4592,7 @@ def test_basic_sanitize_anthropic_tool_name_replaces_invalid_chars():
)
assert (
_basic_sanitize_anthropic_tool_name(
"github_openapi_mcp-actions/download-job-logs-for-workflow-run"
)
_basic_sanitize_anthropic_tool_name("github_openapi_mcp-actions/download-job-logs-for-workflow-run")
== "github_openapi_mcp-actions_download-job-logs-for-workflow-run"
)
# other punctuation
@ -4767,9 +4621,7 @@ def test_build_anthropic_tool_name_maps_no_collisions():
]
)
assert forward == {
"actions/download-job-logs-for-workflow-run": (
"actions_download-job-logs-for-workflow-run"
),
"actions/download-job-logs-for-workflow-run": ("actions_download-job-logs-for-workflow-run"),
"pulls/list-files": "pulls_list-files",
}
assert reverse == {v: k for k, v in forward.items()}
@ -4820,9 +4672,7 @@ def test_build_anthropic_tool_name_maps_three_way_collision():
_build_anthropic_tool_name_maps,
)
forward, reverse = _build_anthropic_tool_name_maps(
["foo_bar", "foo/bar", "foo.bar"]
)
forward, reverse = _build_anthropic_tool_name_maps(["foo_bar", "foo/bar", "foo.bar"])
assert "foo_bar" not in forward # untouched
assert forward["foo/bar"] == "foo_bar_2"
assert forward["foo.bar"] == "foo_bar_3"
@ -4895,16 +4745,13 @@ def test_map_openai_params_does_not_pollute_optional_params_with_internal_keys()
)
# No internal keys may appear in optional_params for ANY input.
for key in optional_params:
assert not key.startswith(
"_anthropic_tool_name"
), f"optional_params leaked internal key {key!r}: {optional_params}"
assert not key.startswith("_anthropic_tool_name"), (
f"optional_params leaked internal key {key!r}: {optional_params}"
)
# And no key starting with `_` either; optional_params should only
# contain documented Anthropic Messages API parameters.
for key in optional_params:
assert not key.startswith("_"), (
f"optional_params leaked underscore-prefixed key {key!r}: "
f"{optional_params}"
)
assert not key.startswith("_"), f"optional_params leaked underscore-prefixed key {key!r}: {optional_params}"
def test_map_openai_params_no_maps_when_all_names_already_valid():
@ -4933,11 +4780,7 @@ def test_map_openai_params_no_maps_when_all_names_already_valid():
def test_rewrite_tool_names_in_messages_uses_forward_map():
config = AnthropicConfig()
forward_map = {
"actions/download-job-logs-for-workflow-run": (
"actions_download-job-logs-for-workflow-run"
)
}
forward_map = {"actions/download-job-logs-for-workflow-run": ("actions_download-job-logs-for-workflow-run")}
messages = [
{"role": "user", "content": "go"},
{
@ -4960,15 +4803,9 @@ def test_rewrite_tool_names_in_messages_uses_forward_map():
out = config._rewrite_tool_names_in_messages(messages, forward_map)
# input list must not be mutated
assert (
messages[1]["tool_calls"][0]["function"]["name"]
== "actions/download-job-logs-for-workflow-run"
)
assert messages[1]["tool_calls"][0]["function"]["name"] == "actions/download-job-logs-for-workflow-run"
# output rewritten according to forward map
assert (
out[1]["tool_calls"][0]["function"]["name"]
== "actions_download-job-logs-for-workflow-run"
)
assert out[1]["tool_calls"][0]["function"]["name"] == "actions_download-job-logs-for-workflow-run"
# non-tool-call messages pass through unchanged (same object)
assert out[0] is messages[0]
assert out[2] is messages[2]
@ -5044,9 +4881,7 @@ def test_sanitize_tool_names_in_request_does_not_mutate_caller_tool_dicts():
caller_tools = [caller_tool]
optional_params: dict = {"tools": caller_tools}
forward, reverse = config._sanitize_tool_names_in_request(
optional_params=optional_params
)
forward, reverse = config._sanitize_tool_names_in_request(optional_params=optional_params)
assert forward.get(original_name)
sanitized = forward[original_name]
@ -5195,10 +5030,7 @@ def test_streaming_iterator_reverse_maps_tool_use_name():
parsed = iterator.chunk_parser(chunk=chunk)
tool_calls = parsed.choices[0].delta.tool_calls
assert tool_calls is not None and len(tool_calls) == 1
assert (
tool_calls[0]["function"]["name"]
== "actions/download-job-logs-for-workflow-run"
)
assert tool_calls[0]["function"]["name"] == "actions/download-job-logs-for-workflow-run"
def test_streaming_iterator_passthrough_when_name_not_in_map():
@ -5294,9 +5126,9 @@ def test_transform_request_does_not_leak_internal_keys_into_body():
for tool in data.get("tools", []):
name = tool.get("name")
assert isinstance(name, str)
assert _re.fullmatch(
r"[a-zA-Z0-9_-]{1,128}", name
), f"sanitized tool name {name!r} still violates Anthropic regex"
assert _re.fullmatch(r"[a-zA-Z0-9_-]{1,128}", name), (
f"sanitized tool name {name!r} still violates Anthropic regex"
)
# Sent name for the bad tool is the disambiguated form, valid name passes through.
sent_names = {t["name"] for t in data["tools"]}
@ -5432,9 +5264,7 @@ def test_transform_request_rewrites_tool_names_in_history():
for block in content:
if isinstance(block, dict) and block.get("type") == "tool_use":
tool_use_names.append(block.get("name"))
assert (
tool_use_names
), "expected at least one tool_use block in transformed messages"
assert tool_use_names, "expected at least one tool_use block in transformed messages"
for name in tool_use_names:
assert name == "actions_download-job-logs-for-workflow-run", (
f"history tool_use.name {name!r} not rewritten -- Anthropic will "
@ -5458,19 +5288,12 @@ def test_sanitize_tool_names_in_request_skips_hosted_tools():
}
forward, reverse = AnthropicConfig._sanitize_tool_names_in_request(optional_params)
# Only the custom tool was rewritten.
assert forward == {
"actions/download-job-logs-for-workflow-run": "actions_download-job-logs-for-workflow-run"
}
assert reverse == {
"actions_download-job-logs-for-workflow-run": "actions/download-job-logs-for-workflow-run"
}
assert forward == {"actions/download-job-logs-for-workflow-run": "actions_download-job-logs-for-workflow-run"}
assert reverse == {"actions_download-job-logs-for-workflow-run": "actions/download-job-logs-for-workflow-run"}
# Hosted tool's name unchanged.
assert optional_params["tools"][0]["name"] == "web_search"
# Custom tool's name updated in place.
assert (
optional_params["tools"][1]["name"]
== "actions_download-job-logs-for-workflow-run"
)
assert optional_params["tools"][1]["name"] == "actions_download-job-logs-for-workflow-run"
def test_sanitize_tool_names_in_request_no_tools_is_noop():
@ -5704,9 +5527,7 @@ def test_translate_system_message_keeps_billing_header_for_first_party_anthropic
assert config.should_strip_billing_metadata() is False
result = config.translate_system_message(
messages=_system_with_billing_header(
"You are Claude Code, Anthropic's official CLI for Claude."
)
messages=_system_with_billing_header("You are Claude Code, Anthropic's official CLI for Claude.")
)
texts = [block["text"] for block in result]
@ -5722,9 +5543,7 @@ def test_translate_system_message_strips_billing_header_for_bedrock():
config = BedrockClaudePlatformConfig()
assert config.should_strip_billing_metadata() is True
result = config.translate_system_message(
messages=_system_with_billing_header("real system prompt")
)
result = config.translate_system_message(messages=_system_with_billing_header("real system prompt"))
texts = [block["text"] for block in result]
assert all(not t.startswith("x-anthropic-billing-header:") for t in texts)
@ -5790,9 +5609,7 @@ def test_translate_system_message_strips_billing_header_for_bedrock_invoke():
config = AmazonAnthropicClaudeConfig()
assert config.should_strip_billing_metadata() is True
result = config.translate_system_message(
messages=_system_with_billing_header("real system prompt")
)
result = config.translate_system_message(messages=_system_with_billing_header("real system prompt"))
texts = [block["text"] for block in result]
assert all(not t.startswith("x-anthropic-billing-header:") for t in texts)
@ -5846,9 +5663,7 @@ def test_translate_system_message_strips_billing_header_for_bedrock_invoke():
),
],
)
def test_should_strip_billing_metadata_by_provider(
module_path, class_name, expected_strip
):
def test_should_strip_billing_metadata_by_provider(module_path, class_name, expected_strip):
import importlib
config_cls = getattr(importlib.import_module(module_path), class_name)
@ -6020,12 +5835,8 @@ def test_sampling_param_gating_driven_by_model_map_flag(monkeypatch):
"""The drop/raise decision must come from ``supports_sampling_params`` in
the model map, not just name matching: a flagged entry gates a model whose
name says nothing, and an explicit ``true`` overrides the name fallback."""
monkeypatch.setitem(
litellm.model_cost, "claude-zeta-9", {"supports_sampling_params": False}
)
monkeypatch.setitem(
litellm.model_cost, "claude-fable-5-test", {"supports_sampling_params": True}
)
monkeypatch.setitem(litellm.model_cost, "claude-zeta-9", {"supports_sampling_params": False})
monkeypatch.setitem(litellm.model_cost, "claude-fable-5-test", {"supports_sampling_params": True})
config = AnthropicConfig()
flagged_off = config.map_openai_params(
@ -6144,9 +5955,7 @@ def test_is_anthropic_usage_object_rejects_responses_api_usage():
("claude-sonnet-4-5-20250929", False),
],
)
def test_disabled_thinking_omitted_only_for_always_on_models(
local_model_cost_map, model, expected_dropped
):
def test_disabled_thinking_omitted_only_for_always_on_models(local_model_cost_map, model, expected_dropped):
"""``thinking={"type": "disabled"}`` is omitted for always-on-thinking models
(Fable/Mythos, which 400 on it: the API remedy is to omit the param) and is
forwarded verbatim for every model that accepts it."""

View file

@ -136,22 +136,14 @@ def test_in_place_substitution_preserves_ordering():
responses_output = [msg_item, fc_exec1, fc_regular, fc_exec2]
# Apply the same logic as _transform_chat_completion_choices_to_responses_output
tool_result_items = (
LiteLLMCompletionResponsesConfig._extract_tool_result_output_items(resp)
)
tool_result_items = LiteLLMCompletionResponsesConfig._extract_tool_result_output_items(resp)
if tool_result_items:
result_by_id = {
(item.get("id") if isinstance(item, dict) else item.id): item
for item in tool_result_items
}
result_by_id = {(item.get("id") if isinstance(item, dict) else item.id): item for item in tool_result_items}
replaced_ids = set(result_by_id.keys())
responses_output = [
(
result_by_id[getattr(item, "call_id", None)]
if (
getattr(item, "type", None) == "function_call"
and getattr(item, "call_id", None) in replaced_ids
)
if (getattr(item, "type", None) == "function_call" and getattr(item, "call_id", None) in replaced_ids)
else item
)
for item in responses_output
@ -255,9 +247,7 @@ def test_end_to_end_streaming_chunks_to_code_interpreter_output():
assert code_results[0]["code"] == "echo e2e_test"
# Step 3: Extract via _extract_tool_result_output_items (Responses API layer)
tool_result_items = (
LiteLLMCompletionResponsesConfig._extract_tool_result_output_items(assembled)
)
tool_result_items = LiteLLMCompletionResponsesConfig._extract_tool_result_output_items(assembled)
assert len(tool_result_items) == 1
item = tool_result_items[0]
# Items are reconstructed as Pydantic OutputCodeInterpreterCall objects

View file

@ -5,7 +5,6 @@ import pytest
import litellm
from litellm.litellm_core_utils.prompt_templates.common_utils import (
TOOL_RESULT_IMAGE_PLACEHOLDER,
)
@ -50,9 +49,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,
)
@ -66,9 +63,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_content_block():
(
block_type,
content_block_start,
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic_content_block(
choices=choices
)
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic_content_block(choices=choices)
print(content_block_start)
@ -98,9 +93,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,
)
@ -114,9 +107,7 @@ def test_translate_streaming_openai_chunk_strips_gemini_thought_from_tool_call_i
(
block_type,
content_block_start,
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic_content_block(
choices=choices
)
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic_content_block(choices=choices)
assert block_type == "tool_use"
assert content_block_start["id"] == base
@ -161,9 +152,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_thinking_content_block():
(
block_type,
content_block_start,
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic_content_block(
choices=choices
)
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic_content_block(choices=choices)
assert block_type == "thinking"
assert content_block_start == {
@ -199,9 +188,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_reasoning_content_only_co
(
block_type,
content_block_start,
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic_content_block(
choices=choices
)
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic_content_block(choices=choices)
assert block_type == "thinking"
assert content_block_start == {
@ -247,9 +234,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_thinking_signature_block(
(
block_type,
content_block_start,
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic_content_block(
choices=choices
)
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic_content_block(choices=choices)
assert block_type == "thinking"
assert content_block_start == {
@ -302,9 +287,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_content_block_thinking_an
(
block_type,
content_block_start,
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic_content_block(
choices=choices
)
) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic_content_block(choices=choices)
assert block_type == "thinking"
@ -347,10 +330,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"
@ -411,9 +391,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(
@ -634,9 +612,7 @@ def test_translate_anthropic_to_openai_orders_top_level_and_midturn_system():
]
def _translate_with_metadata(
model: str, metadata: dict[str, Any], custom_llm_provider: str | None
) -> dict[str, Any]:
def _translate_with_metadata(model: str, metadata: dict[str, Any], custom_llm_provider: str | None) -> dict[str, Any]:
openai_request, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai(
anthropic_message_request={
"model": model,
@ -688,9 +664,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": "session-abc"}, "litellm_proxy")
assert openai_request["user"] == "session-abc"
assert "prompt_cache_key" not in openai_request
@ -735,7 +709,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
),
)
],
@ -750,9 +725,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"
def test_translate_openai_content_to_anthropic_text_and_tool_calls():
@ -872,9 +845,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"}'),
)
],
),
@ -884,9 +855,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
@ -927,9 +896,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)
@ -1004,9 +971,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"
@ -1049,9 +1014,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"
@ -1115,9 +1078,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_emits_signature_when_thin
(
block_type,
content_block_start,
) = adapter._translate_streaming_openai_chunk_to_anthropic_content_block(
choices=choices
)
) = adapter._translate_streaming_openai_chunk_to_anthropic_content_block(choices=choices)
assert block_type == "thinking"
@ -1157,9 +1118,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"]
@ -1197,18 +1156,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=[
@ -1360,9 +1315,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"
@ -1370,9 +1323,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"
@ -1418,10 +1369,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():
@ -1479,9 +1427,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
@ -1497,12 +1443,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"
@ -1551,17 +1493,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"
@ -1610,9 +1549,7 @@ def test_streaming_chunk_with_both_text_and_tool_calls_issue_18238():
(
block_type,
content_block_start,
) = adapter._translate_streaming_openai_chunk_to_anthropic_content_block(
choices=choices
)
) = adapter._translate_streaming_openai_chunk_to_anthropic_content_block(choices=choices)
assert block_type == "tool_use"
assert content_block_start["name"] == "Bash"
@ -1656,9 +1593,7 @@ def test_streaming_chunk_with_text_and_empty_tool_calls_returns_text_delta():
(
block_type,
content_block_start,
) = adapter._translate_streaming_openai_chunk_to_anthropic_content_block(
choices=choices
)
) = adapter._translate_streaming_openai_chunk_to_anthropic_content_block(choices=choices)
assert block_type == "text"
assert content_block_start == {"type": "text", "text": ""}
@ -1669,15 +1604,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"
)
@ -1693,9 +1625,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
@ -1711,9 +1641,7 @@ def test_should_not_add_cache_control_for_non_anthropic_model():
"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
@ -1728,9 +1656,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
@ -1741,9 +1667,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
@ -1849,12 +1773,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():
@ -2322,9 +2241,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"
@ -2356,9 +2273,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
@ -2371,9 +2286,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"
@ -2425,9 +2338,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)
@ -2447,9 +2358,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},
@ -2474,9 +2383,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
@ -2498,9 +2405,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
@ -2514,9 +2419,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}
@ -2548,9 +2451,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"]
@ -2716,9 +2617,7 @@ def test_translate_openai_usage_to_anthropic_cache_tokens_from_dict_details_with
"cache_write_tokens": 20.0,
}
anthropic_usage = LiteLLMAnthropicMessagesAdapter._translate_openai_usage_to_anthropic_usage_delta(
usage
)
anthropic_usage = LiteLLMAnthropicMessagesAdapter._translate_openai_usage_to_anthropic_usage_delta(usage)
assert anthropic_usage["input_tokens"] == 70
assert anthropic_usage["output_tokens"] == 50
@ -2737,9 +2636,7 @@ def test_translate_openai_usage_to_anthropic_ignores_fractional_cache_tokens():
"cache_creation_tokens": 20.25,
}
anthropic_usage = LiteLLMAnthropicMessagesAdapter._translate_openai_usage_to_anthropic_usage_delta(
usage
)
anthropic_usage = LiteLLMAnthropicMessagesAdapter._translate_openai_usage_to_anthropic_usage_delta(usage)
assert anthropic_usage["input_tokens"] == 120
assert anthropic_usage["output_tokens"] == 50
@ -2756,9 +2653,7 @@ def test_translate_openai_usage_to_anthropic_ignores_bool_cache_tokens():
usage.cache_read_input_tokens = True
usage.cache_creation_input_tokens = True
anthropic_usage = LiteLLMAnthropicMessagesAdapter._translate_openai_usage_to_anthropic_usage_delta(
usage
)
anthropic_usage = LiteLLMAnthropicMessagesAdapter._translate_openai_usage_to_anthropic_usage_delta(usage)
assert anthropic_usage["input_tokens"] == 120
assert anthropic_usage["output_tokens"] == 50
@ -2977,9 +2872,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")
# =====================================================================
@ -3154,15 +3047,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 = {
@ -3237,19 +3123,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:
@ -3453,9 +3329,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
@ -3487,9 +3361,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
@ -3544,13 +3416,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")
@ -3652,7 +3520,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"
@ -3660,8 +3530,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
],
)
@ -3781,9 +3650,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()]}),
]
)
@ -3878,7 +3745,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 == [

View file

@ -34,9 +34,7 @@ import pytest
# Anchor sys.path to this file's location — not the working-directory-relative
# pattern Greptile flagged on PR #23706. Resolves correctly regardless of
# where pytest is invoked from.
sys.path.insert(
0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../../.."))
)
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../../..")))
from litellm.llms.anthropic.experimental_pass_through.adapters.handler import (
ANTHROPIC_ONLY_REQUEST_KEYS,
@ -174,9 +172,7 @@ class TestOutputConfigStrippedFromCompletionKwargs:
result = _call_prepare(
extra_kwargs={
"custom_llm_provider": "azure",
"output_config": {
"format": {"type": "json_schema", "schema": losing_schema}
},
"output_config": {"format": {"type": "json_schema", "schema": losing_schema}},
},
output_format={"type": "json_schema", "schema": winning_schema},
)

View file

@ -31,9 +31,7 @@ from litellm.types.utils import (
)
def _build_fake_stream(
content: str, finish_reason: str = "stop"
) -> MockResponseIterator:
def _build_fake_stream(content: str, finish_reason: str = "stop") -> MockResponseIterator:
"""Mimic a Vertex Gemma `:predict` fake stream: one collapsed chunk."""
model_response = ModelResponse()
model_response.choices = [
@ -133,9 +131,7 @@ def test_delayed_usage_chunk_preserves_cache_tokens():
wrapper = AnthropicStreamWrapper(completion_stream=iter(chunks), model="gpt-4o")
events = list(wrapper)
message_delta = next(
event for event in events if event.get("type") == "message_delta"
)
message_delta = next(event for event in events if event.get("type") == "message_delta")
assert message_delta["usage"]["input_tokens"] == 70
assert message_delta["usage"]["output_tokens"] == 5
@ -145,13 +141,7 @@ def test_delayed_usage_chunk_preserves_cache_tokens():
def test_splitter_passes_through_non_combined_chunks():
"""A chunk with content but no finish_reason is not split."""
chunk = ModelResponseStream(
choices=[
StreamingChoices(
index=0, delta=Delta(content="partial"), finish_reason=None
)
]
)
chunk = ModelResponseStream(choices=[StreamingChoices(index=0, delta=Delta(content="partial"), finish_reason=None)])
chunks = list(_CombinedChunkSplitter(iter([chunk])))
assert len(chunks) == 1
assert chunks[0].choices[0].delta.content == "partial"
@ -159,11 +149,7 @@ def test_splitter_passes_through_non_combined_chunks():
def test_splitter_splits_combined_chunk_into_content_then_finish():
"""A chunk with both content and finish_reason becomes two chunks."""
chunk = ModelResponseStream(
choices=[
StreamingChoices(index=0, delta=Delta(content="done"), finish_reason="stop")
]
)
chunk = ModelResponseStream(choices=[StreamingChoices(index=0, delta=Delta(content="done"), finish_reason="stop")])
content_chunk, finish_chunk = list(_CombinedChunkSplitter(iter([chunk])))
assert content_chunk.choices[0].delta.content == "done"
@ -193,9 +179,7 @@ def test_split_clears_reasoning_and_thinking_on_finish_chunk():
reasoning_content="some reasoning",
thinking_blocks=[{"type": "thinking"}],
)
chunk = SimpleNamespace(
choices=[SimpleNamespace(finish_reason="stop", delta=delta)]
)
chunk = SimpleNamespace(choices=[SimpleNamespace(finish_reason="stop", delta=delta)])
content_chunk, finish_chunk = _CombinedChunkSplitter._split(chunk)

View file

@ -22,9 +22,7 @@ def _make_text_chunk(
StreamingChoices(
finish_reason=finish_reason,
index=0,
delta=Delta(
content=text, role="assistant" if text else None, tool_calls=None
),
delta=Delta(content=text, role="assistant" if text else None, tool_calls=None),
logprobs=None,
)
]
@ -73,34 +71,23 @@ async def test_stream_emits_compaction_block_before_text():
compaction_start = next(
e
for e in events
if e.get("type") == "content_block_start"
and e.get("content_block", {}).get("type") == "compaction"
if e.get("type") == "content_block_start" and e.get("content_block", {}).get("type") == "compaction"
)
assert compaction_start["index"] == 0
compaction_delta = next(
e
for e in events
if e.get("type") == "content_block_delta"
and e.get("delta", {}).get("type") == "compaction_delta"
if e.get("type") == "content_block_delta" and e.get("delta", {}).get("type") == "compaction_delta"
)
assert compaction_delta["index"] == 0
assert (
compaction_delta["delta"]["content"] == "Summary of prior conversation turns."
)
assert compaction_delta["delta"]["content"] == "Summary of prior conversation turns."
compaction_stop = next(
e
for e in events
if e.get("type") == "content_block_stop" and e.get("index") == 0
)
compaction_stop = next(e for e in events if e.get("type") == "content_block_stop" and e.get("index") == 0)
assert compaction_stop is not None
text_start = next(
e
for e in events
if e.get("type") == "content_block_start"
and e.get("content_block", {}).get("type") == "text"
e for e in events if e.get("type") == "content_block_start" and e.get("content_block", {}).get("type") == "text"
)
assert text_start["index"] == 1
@ -177,14 +164,9 @@ async def test_stream_without_compaction_block_unchanged():
events = await _collect_events_async(wrapper)
assert not any(
e.get("content_block", {}).get("type") == "compaction"
for e in events
if e.get("type") == "content_block_start"
e.get("content_block", {}).get("type") == "compaction" for e in events if e.get("type") == "content_block_start"
)
text_start = next(
e
for e in events
if e.get("type") == "content_block_start"
and e.get("content_block", {}).get("type") == "text"
e for e in events if e.get("type") == "content_block_start" and e.get("content_block", {}).get("type") == "text"
)
assert text_start["index"] == 0

View file

@ -19,9 +19,7 @@ from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices, Us
def _text_chunk(text: str) -> ModelResponseStream:
return ModelResponseStream(
choices=[StreamingChoices(index=0, delta=Delta(content=text), finish_reason=None)]
)
return ModelResponseStream(choices=[StreamingChoices(index=0, delta=Delta(content=text), finish_reason=None)])
def _finish_chunk() -> ModelResponseStream:
@ -61,9 +59,7 @@ def test_leading_metadata_chunk_without_choices_does_not_kill_stream():
wrapper = AnthropicStreamWrapper(completion_stream=iter(chunks), model="mock-model")
events = list(wrapper)
text = "".join(
event["delta"]["text"] for event in events if event.get("type") == "content_block_delta"
)
text = "".join(event["delta"]["text"] for event in events if event.get("type") == "content_block_delta")
assert text == "Hello there"
assert events[-1]["type"] == "message_stop"

View file

@ -507,11 +507,7 @@ def _thinking_first_chunks() -> List[MagicMock]:
def _assert_thinking_first_block_opens_at_index_zero(events: List[dict]) -> None:
starts = [
(e["index"], e["content_block"]["type"])
for e in events
if e.get("type") == "content_block_start"
]
starts = [(e["index"], e["content_block"]["type"]) for e in events if e.get("type") == "content_block_start"]
assert starts == [(0, "thinking"), (1, "text")], starts
assert "" not in _text_deltas(events)
assert _thinking_deltas(events) == ["Let me think", "about it."]
@ -980,9 +976,7 @@ def test_tool_block_start_emitted_without_awaiting_the_next_chunk_sync():
"name": "Write",
"input": {},
}
assert stream.pulled == 1, (
f"content_block_start was withheld until {stream.pulled} upstream chunks had arrived"
)
assert stream.pulled == 1, f"content_block_start was withheld until {stream.pulled} upstream chunks had arrived"
@pytest.mark.asyncio
@ -997,9 +991,7 @@ async def test_tool_block_start_emitted_without_awaiting_the_next_chunk_async():
start = await wrapper.__anext__()
assert start["type"] == "content_block_start"
assert start["content_block"]["name"] == "Write"
assert stream.pulled == 1, (
f"content_block_start was withheld until {stream.pulled} upstream chunks had arrived"
)
assert stream.pulled == 1, f"content_block_start was withheld until {stream.pulled} upstream chunks had arrived"
@pytest.mark.parametrize("is_async", [False, True])

View file

@ -127,21 +127,17 @@ async def test_async_stream_emits_input_json_delta_for_bundled_tool_args():
):
input_json_delta_idx = i
assert (
tool_start_idx is not None
), f"Expected content_block_start with type=tool_use; events: {event_types}"
assert (
input_json_delta_idx is not None
), f"Expected content_block_delta with input_json_delta; events: {event_types}"
assert (
input_json_delta_idx == tool_start_idx + 1
), "input_json_delta should immediately follow the tool_use content_block_start"
assert tool_start_idx is not None, f"Expected content_block_start with type=tool_use; events: {event_types}"
assert input_json_delta_idx is not None, (
f"Expected content_block_delta with input_json_delta; events: {event_types}"
)
assert input_json_delta_idx == tool_start_idx + 1, (
"input_json_delta should immediately follow the tool_use content_block_start"
)
# Verify the delta carries the tool arguments
delta_event = events[input_json_delta_idx]
assert delta_event["delta"][
"partial_json"
], "input_json_delta should have non-empty partial_json"
assert delta_event["delta"]["partial_json"], "input_json_delta should have non-empty partial_json"
@pytest.mark.asyncio
@ -230,8 +226,7 @@ async def test_async_stream_no_extra_delta_when_tool_args_empty():
and e["delta"].get("type") == "input_json_delta"
]
assert len(input_json_deltas) == 1, (
f"Expected exactly 1 input_json_delta (from the follow-up chunk), "
f"got {len(input_json_deltas)}"
f"Expected exactly 1 input_json_delta (from the follow-up chunk), got {len(input_json_deltas)}"
)
assert input_json_deltas[0]["delta"]["partial_json"] == '{"location": "NYC"}'
@ -291,15 +286,13 @@ def test_sync_stream_emits_input_json_delta_for_bundled_tool_args():
):
input_json_delta_idx = i
assert (
tool_start_idx is not None
), f"Expected content_block_start with type=tool_use; events: {event_types}"
assert (
input_json_delta_idx is not None
), f"Expected content_block_delta with input_json_delta; events: {event_types}"
assert (
input_json_delta_idx == tool_start_idx + 1
), "input_json_delta should immediately follow the tool_use content_block_start"
assert tool_start_idx is not None, f"Expected content_block_start with type=tool_use; events: {event_types}"
assert input_json_delta_idx is not None, (
f"Expected content_block_delta with input_json_delta; events: {event_types}"
)
assert input_json_delta_idx == tool_start_idx + 1, (
"input_json_delta should immediately follow the tool_use content_block_start"
)
assert events[input_json_delta_idx]["delta"]["partial_json"]
@ -343,9 +336,7 @@ def test_sync_stream_no_extra_delta_when_tool_args_empty():
)
wrapper = AnthropicStreamWrapper(
completion_stream=iter(
[text_chunk, tool_name_chunk, tool_args_chunk, finish_chunk]
),
completion_stream=iter([text_chunk, tool_name_chunk, tool_args_chunk, finish_chunk]),
model="test-model",
)
@ -374,7 +365,6 @@ def test_sync_stream_no_extra_delta_when_tool_args_empty():
and e["delta"].get("type") == "input_json_delta"
]
assert len(input_json_deltas) == 1, (
f"Expected exactly 1 input_json_delta (from the follow-up chunk), "
f"got {len(input_json_deltas)}"
f"Expected exactly 1 input_json_delta (from the follow-up chunk), got {len(input_json_deltas)}"
)
assert input_json_deltas[0]["delta"]["partial_json"] == '{"location": "NYC"}'

View file

@ -258,9 +258,7 @@ def test_tool_result_list_content_shape_preserved():
{"role": "user", "content": "Hi"},
{
"role": "assistant",
"content": [
{"type": "tool_use", "id": "toolu_a", "name": "f", "input": {}}
],
"content": [{"type": "tool_use", "id": "toolu_a", "name": "f", "input": {}}],
},
{
"role": "user",
@ -274,9 +272,7 @@ def test_tool_result_list_content_shape_preserved():
},
{
"role": "assistant",
"content": [
{"type": "tool_use", "id": "toolu_b", "name": "f", "input": {}}
],
"content": [{"type": "tool_use", "id": "toolu_b", "name": "f", "input": {}}],
},
{
"role": "user",

View file

@ -193,10 +193,7 @@ def test_select_last_user_question_strips_tool_result_from_mixed_turn():
content = selected[0]["content"]
assert isinstance(content, list)
assert all(b.get("type") != "tool_result" for b in content)
assert any(
b.get("type") == "text" and b.get("text") == "follow-up question"
for b in content
)
assert any(b.get("type") == "text" and b.get("text") == "follow-up question" for b in content)
def test_select_last_user_question_skips_pure_tool_result_turn():
@ -425,9 +422,7 @@ def test_client_compaction_block_history_without_context_management():
def test_client_compaction_block_history_no_compaction_returns_none():
result = apply_client_compaction_block_history(
messages=_simple_messages(), system="base"
)
result = apply_client_compaction_block_history(messages=_simple_messages(), system="base")
assert result is None
@ -512,9 +507,7 @@ async def test_slice_only_no_compaction_block_under_threshold():
async def test_full_summary_path():
"""Over threshold: summary call fires, compaction_block and iterations_usage returned."""
messages = _simple_messages()
mock_response = _make_mock_response(
"<summary>Condensed history</summary>", prompt_tokens=200, completion_tokens=50
)
mock_response = _make_mock_response("<summary>Condensed history</summary>", prompt_tokens=200, completion_tokens=50)
with (
patch(
@ -1068,13 +1061,11 @@ async def test_summary_call_does_not_emit_consecutive_user_turns():
)
summary_messages = captured_calls[0]["summary_messages"]
user_indices = [
idx for idx, msg in enumerate(summary_messages) if msg.get("role") == "user"
]
user_indices = [idx for idx, msg in enumerate(summary_messages) if msg.get("role") == "user"]
# No two adjacent indices.
assert all(
b - a > 1 for a, b in zip(user_indices, user_indices[1:])
), f"two consecutive user turns produced: {summary_messages}"
assert all(b - a > 1 for a, b in zip(user_indices, user_indices[1:])), (
f"two consecutive user turns produced: {summary_messages}"
)
async def test_summary_call_sends_default_max_tokens():
@ -1157,9 +1148,9 @@ def test_summary_max_tokens_setting_falls_back_for_invalid_values():
"litellm.proxy.proxy_server.general_settings",
{"context_management_summary_max_tokens": bad},
):
assert (
_read_summary_max_tokens_setting() == COMPACT_SUMMARY_MAX_TOKENS
), f"expected default for invalid override {bad!r}"
assert _read_summary_max_tokens_setting() == COMPACT_SUMMARY_MAX_TOKENS, (
f"expected default for invalid override {bad!r}"
)
async def test_summary_call_sends_default_timeout():
@ -1282,9 +1273,7 @@ async def test_summary_model_denied_when_team_not_in_allowlist():
tools=None,
system=None,
edit_spec=_EDIT_SPEC_DEFAULT,
user_api_key_auth=_fake_user_api_key_auth(
key_models=["all-proxy-models"], team_models=["gpt-4o"]
),
user_api_key_auth=_fake_user_api_key_auth(key_models=["all-proxy-models"], team_models=["gpt-4o"]),
)
mock_call.assert_not_awaited()
@ -1313,9 +1302,7 @@ async def test_summary_model_allowed_when_in_key_allowlist():
tools=None,
system=None,
edit_spec=_EDIT_SPEC_DEFAULT,
user_api_key_auth=_fake_user_api_key_auth(
key_models=["claude-haiku-4-5", "gpt-4o"]
),
user_api_key_auth=_fake_user_api_key_auth(key_models=["claude-haiku-4-5", "gpt-4o"]),
)
mock_call.assert_awaited_once()
@ -1521,9 +1508,7 @@ async def test_summary_model_denied_when_key_over_model_budget():
limiter = MagicMock()
limiter.is_key_within_model_budget = AsyncMock(
side_effect=litellm.BudgetExceededError(
message="over budget", current_cost=10, max_budget=5
)
side_effect=litellm.BudgetExceededError(message="over budget", current_cost=10, max_budget=5)
)
with (
@ -1574,9 +1559,7 @@ async def test_summary_model_denied_when_user_over_model_budget():
limiter = MagicMock()
limiter.is_user_within_model_budget = AsyncMock(
side_effect=litellm.BudgetExceededError(
message="over budget", current_cost=10, max_budget=5
)
side_effect=litellm.BudgetExceededError(message="over budget", current_cost=10, max_budget=5)
)
with (
@ -1617,9 +1600,7 @@ async def test_summary_model_denied_when_user_over_model_budget():
_PROXY_VirtualKeyModelMaxBudgetLimiter,
)
real_params = inspect.signature(
_PROXY_VirtualKeyModelMaxBudgetLimiter.is_user_within_model_budget
).parameters
real_params = inspect.signature(_PROXY_VirtualKeyModelMaxBudgetLimiter.is_user_within_model_budget).parameters
for kwarg in ("user_id", "user_model_max_budget", "model"):
assert kwarg in real_params, f"compact.py passes {kwarg}=, which the limiter no longer accepts"
@ -1641,9 +1622,7 @@ async def test_summary_model_denied_when_end_user_over_model_budget():
limiter = MagicMock()
limiter.is_key_within_model_budget = AsyncMock(return_value=True)
limiter.is_end_user_within_model_budget = AsyncMock(
side_effect=litellm.BudgetExceededError(
message="over budget", current_cost=10, max_budget=5
)
side_effect=litellm.BudgetExceededError(message="over budget", current_cost=10, max_budget=5)
)
with (
@ -1956,9 +1935,7 @@ async def test_model_budget_metadata_propagated_to_summary_call():
parent_litellm_metadata = {
"user_api_key": "sk-test",
"user_api_key_model_max_budget": {"claude-haiku-4-5": {"budget_limit": 5}},
"user_api_key_end_user_model_max_budget": {
"claude-haiku-4-5": {"budget_limit": 2}
},
"user_api_key_end_user_model_max_budget": {"claude-haiku-4-5": {"budget_limit": 2}},
}
with (
@ -1983,12 +1960,8 @@ async def test_model_budget_metadata_propagated_to_summary_call():
)
propagated = mock_call.call_args.kwargs["metadata"]
assert propagated["user_api_key_model_max_budget"] == {
"claude-haiku-4-5": {"budget_limit": 5}
}
assert propagated["user_api_key_end_user_model_max_budget"] == {
"claude-haiku-4-5": {"budget_limit": 2}
}
assert propagated["user_api_key_model_max_budget"] == {"claude-haiku-4-5": {"budget_limit": 5}}
assert propagated["user_api_key_end_user_model_max_budget"] == {"claude-haiku-4-5": {"budget_limit": 2}}
async def test_summary_call_propagates_allowed_model_region():
@ -2460,9 +2433,7 @@ def test_endpoint_returns_anthropic_400_on_context_management_error():
mock_proxy_server.version = "test"
with patch.dict(sys.modules, {"litellm.proxy.proxy_server": mock_proxy_server}):
with patch(
"litellm.proxy.anthropic_endpoints.endpoints.ProxyBaseLLMRequestProcessing"
) as mock_cls:
with patch("litellm.proxy.anthropic_endpoints.endpoints.ProxyBaseLLMRequestProcessing") as mock_cls:
mock_instance = MagicMock()
mock_instance.base_process_llm_request = AsyncMock(
side_effect=AnthropicContextManagementError(
@ -2521,9 +2492,7 @@ def test_endpoint_runs_failure_hook_on_500_context_management_error():
mock_proxy_server.version = "test"
with patch.dict(sys.modules, {"litellm.proxy.proxy_server": mock_proxy_server}):
with patch(
"litellm.proxy.anthropic_endpoints.endpoints.ProxyBaseLLMRequestProcessing"
) as mock_cls:
with patch("litellm.proxy.anthropic_endpoints.endpoints.ProxyBaseLLMRequestProcessing") as mock_cls:
mock_instance = MagicMock()
mock_instance.base_process_llm_request = AsyncMock(
side_effect=AnthropicContextManagementError(

View file

@ -50,9 +50,7 @@ async def test_unknown_edit_type_is_noop():
messages=messages,
tools=None,
system=None,
context_management_spec={
"edits": [{"type": "totally_not_a_real_edit_20999999"}]
},
context_management_spec={"edits": [{"type": "totally_not_a_real_edit_20999999"}]},
)
assert result.applied_edits == []
assert result.messages == messages

View file

@ -39,9 +39,7 @@ def _text_resp(text: str, model: str = "gpt-4o-mini") -> Dict:
}
def _advisor_call_resp(
question: str = "How do I approach this?", tool_id: str = "tid_01"
) -> Dict:
def _advisor_call_resp(question: str = "How do I approach this?", tool_id: str = "tid_01") -> Dict:
return {
"id": "msg_int_test",
"type": "message",
@ -106,14 +104,10 @@ async def test_full_dispatch_interceptor_fires_and_loop_completes():
assert isinstance(result, dict)
content = result.get("content", [])
text_blocks = [b for b in content if b.get("type") == "text"]
advisor_uses = [
b for b in content if b.get("type") == "tool_use" and b.get("name") == "advisor"
]
advisor_uses = [b for b in content if b.get("type") == "tool_use" and b.get("name") == "advisor"]
assert len(text_blocks) >= 1, "Final response must have text"
assert (
len(advisor_uses) == 0
), "No advisor tool_use blocks must appear in final output"
assert len(advisor_uses) == 0, "No advisor tool_use blocks must appear in final output"
# ---------------------------------------------------------------------------
@ -221,9 +215,7 @@ async def test_named_params_forwarded_into_advisor_executor_subcall():
captured_executor_kwargs: Dict = {}
async def mock_handler(
model, messages, tools, stream, max_tokens, custom_llm_provider, **kwargs
):
async def mock_handler(model, messages, tools, stream, max_tokens, custom_llm_provider, **kwargs):
# First call is the executor sub-call (returns advisor tool_use).
# Capture its kwargs so we can assert the forwarded params.
if not captured_executor_kwargs:
@ -267,8 +259,7 @@ async def test_named_params_forwarded_into_advisor_executor_subcall():
)
assert captured_executor_kwargs["thinking"] == {"type": "adaptive"}, (
"thinking must be forwarded into executor sub-call — see "
"anthropic_messages.handler interceptor invocation."
"thinking must be forwarded into executor sub-call — see anthropic_messages.handler interceptor invocation."
)
# The advisor enriches metadata with `advisor_sub_call` / `parent_request_id`,
# but the original caller fields must survive into the executor sub-call.
@ -304,9 +295,7 @@ async def test_pre_request_hook_override_does_not_collide_with_explicit_kwargs()
captured: Dict = {}
async def mock_handler(
model, messages, tools, stream, max_tokens, custom_llm_provider, **kwargs
):
async def mock_handler(model, messages, tools, stream, max_tokens, custom_llm_provider, **kwargs):
if not captured:
captured.update(
{
@ -320,9 +309,7 @@ async def test_pre_request_hook_override_does_not_collide_with_explicit_kwargs()
return _text_resp("Some advice.", model="claude-opus-4-6")
return _text_resp("Final answer.")
async def fake_pre_request_hooks(
model, messages, tools, stream, custom_llm_provider, **hook_kwargs
):
async def fake_pre_request_hooks(model, messages, tools, stream, custom_llm_provider, **hook_kwargs):
# Simulate a CustomLogger.async_pre_request_hook that overrides several
# named params on its way through. Without the request_kwargs.pop()
# extraction in handler.py, these would collide with the explicit

View file

@ -70,9 +70,7 @@ def _build_simple_text_stream() -> List[bytes]:
},
)
)
chunks.append(
_sse_event("content_block_stop", {"type": "content_block_stop", "index": 0})
)
chunks.append(_sse_event("content_block_stop", {"type": "content_block_stop", "index": 0}))
chunks.append(
_sse_event(
"message_delta",
@ -145,9 +143,7 @@ def _build_tool_use_stream() -> List[bytes]:
},
)
)
chunks.append(
_sse_event("content_block_stop", {"type": "content_block_stop", "index": 0})
)
chunks.append(_sse_event("content_block_stop", {"type": "content_block_stop", "index": 0}))
# tool_use block
chunks.append(
_sse_event(
@ -187,9 +183,7 @@ def _build_tool_use_stream() -> List[bytes]:
},
)
)
chunks.append(
_sse_event("content_block_stop", {"type": "content_block_stop", "index": 1})
)
chunks.append(_sse_event("content_block_stop", {"type": "content_block_stop", "index": 1}))
chunks.append(
_sse_event(
"message_delta",
@ -234,9 +228,7 @@ class MockAsyncStream:
class TestParseSSEEvents:
def test_should_parse_single_event(self):
raw = _sse_event(
"message_start", {"type": "message_start", "message": {"id": "1"}}
)
raw = _sse_event("message_start", {"type": "message_start", "message": {"id": "1"}})
events = _parse_sse_events(raw)
assert len(events) == 1
assert events[0][0] == "message_start"
@ -407,9 +399,7 @@ class TestHandleMessageDelta:
class TestRebuildAnthropicResponse:
def test_should_rebuild_simple_text_response(self):
raw_bytes = _build_simple_text_stream()
result = AgenticAnthropicStreamingIterator._rebuild_anthropic_response_from_sse(
raw_bytes
)
result = AgenticAnthropicStreamingIterator._rebuild_anthropic_response_from_sse(raw_bytes)
assert result is not None
assert result["id"] == "msg_123"
assert result["model"] == "claude-sonnet-4-20250514"
@ -422,9 +412,7 @@ class TestRebuildAnthropicResponse:
def test_should_rebuild_tool_use_response(self):
raw_bytes = _build_tool_use_stream()
result = AgenticAnthropicStreamingIterator._rebuild_anthropic_response_from_sse(
raw_bytes
)
result = AgenticAnthropicStreamingIterator._rebuild_anthropic_response_from_sse(raw_bytes)
assert result is not None
assert result["id"] == "msg_tool_456"
assert result["stop_reason"] == "tool_use"
@ -452,23 +440,17 @@ class TestRebuildAnthropicResponse:
},
)
]
result = AgenticAnthropicStreamingIterator._rebuild_anthropic_response_from_sse(
raw_bytes
)
result = AgenticAnthropicStreamingIterator._rebuild_anthropic_response_from_sse(raw_bytes)
assert result is None
def test_should_handle_empty_bytes(self):
result = AgenticAnthropicStreamingIterator._rebuild_anthropic_response_from_sse(
[]
)
result = AgenticAnthropicStreamingIterator._rebuild_anthropic_response_from_sse([])
assert result is None
def test_should_handle_multi_event_chunks(self):
"""When multiple SSE events arrive in a single bytes chunk."""
combined = b"".join(_build_simple_text_stream())
result = AgenticAnthropicStreamingIterator._rebuild_anthropic_response_from_sse(
[combined]
)
result = AgenticAnthropicStreamingIterator._rebuild_anthropic_response_from_sse([combined])
assert result is not None
assert result["content"][0]["text"] == "Hello, world!"
@ -500,9 +482,7 @@ class TestRebuildAnthropicResponse:
),
_sse_event("message_stop", {"type": "message_stop"}),
]
result = AgenticAnthropicStreamingIterator._rebuild_anthropic_response_from_sse(
raw_bytes
)
result = AgenticAnthropicStreamingIterator._rebuild_anthropic_response_from_sse(raw_bytes)
assert result is not None
assert result["usage"]["cache_creation_input_tokens"] == 50
assert result["usage"]["cache_read_input_tokens"] == 30
@ -543,9 +523,7 @@ class TestRebuildAnthropicResponse:
),
_sse_event("message_stop", {"type": "message_stop"}),
]
result = AgenticAnthropicStreamingIterator._rebuild_anthropic_response_from_sse(
raw_bytes
)
result = AgenticAnthropicStreamingIterator._rebuild_anthropic_response_from_sse(raw_bytes)
assert result is not None
assert result["content"][0]["type"] == "redacted_thinking"
@ -672,9 +650,7 @@ class TestAgenticStreamingIteratorPhase2:
}
mock_handler = MagicMock()
mock_handler._call_agentic_completion_hooks = AsyncMock(
return_value=fake_response
)
mock_handler._call_agentic_completion_hooks = AsyncMock(return_value=fake_response)
iterator = AgenticAnthropicStreamingIterator(
completion_stream=mock_stream,
@ -707,9 +683,7 @@ class TestAgenticStreamingIteratorErrorHandling:
mock_stream = MockAsyncStream(chunks)
mock_handler = MagicMock()
mock_handler._call_agentic_completion_hooks = AsyncMock(
side_effect=RuntimeError("hook exploded")
)
mock_handler._call_agentic_completion_hooks = AsyncMock(side_effect=RuntimeError("hook exploded"))
mock_logging = MagicMock()
mock_logging.litellm_call_id = "test_call_123"

View file

@ -290,21 +290,15 @@ async def test_bedrock_converse_budget_tokens_preserved():
mock_acompletion.assert_called_once()
call_kwargs = mock_acompletion.call_args.kwargs
print(
"acompletion call kwargs: ", json.dumps(call_kwargs, indent=4, default=str)
)
print("acompletion call kwargs: ", json.dumps(call_kwargs, indent=4, default=str))
# Verify thinking parameter is passed through with budget_tokens preserved
thinking_param = call_kwargs.get("thinking")
assert (
thinking_param is not None
), "thinking parameter should be passed to acompletion"
assert (
thinking_param.get("type") == "enabled"
), "thinking.type should be 'enabled'"
assert (
thinking_param.get("budget_tokens") == 1024
), f"thinking.budget_tokens should be 1024, but got {thinking_param.get('budget_tokens')}"
assert thinking_param is not None, "thinking parameter should be passed to acompletion"
assert thinking_param.get("type") == "enabled", "thinking.type should be 'enabled'"
assert thinking_param.get("budget_tokens") == 1024, (
f"thinking.budget_tokens should be 1024, but got {thinking_param.get('budget_tokens')}"
)
def test_openai_model_with_thinking_converts_to_reasoning():
@ -336,23 +330,18 @@ def test_openai_model_with_thinking_converts_to_reasoning():
call_kwargs = mock_responses.call_args.kwargs
# Verify reasoning is set (converted from thinking)
assert (
"reasoning" in call_kwargs
), "reasoning should be passed to litellm.responses"
assert "reasoning" in call_kwargs, "reasoning should be passed to litellm.responses"
# budget_tokens=1024 -> effort="low" (at the LOW budget threshold)
# reasoning_auto_summary is False by default, so no summary key
expected_reasoning = {"effort": "low"}
assert call_kwargs["reasoning"] == expected_reasoning, (
f"reasoning should be {expected_reasoning} for budget_tokens=1024, "
f"got {call_kwargs.get('reasoning')}"
f"reasoning should be {expected_reasoning} for budget_tokens=1024, got {call_kwargs.get('reasoning')}"
)
assert "summary" not in call_kwargs["reasoning"]
# Verify thinking is NOT passed directly to the Responses API
assert (
"thinking" not in call_kwargs
), "thinking should NOT be passed directly to litellm.responses"
assert "thinking" not in call_kwargs, "thinking should NOT be passed directly to litellm.responses"
class TestThinkingParameterTransformation:
@ -405,9 +394,7 @@ class TestThinkingParameterTransformation:
thinking=thinking,
model="openai/gpt-5.2",
)
assert result == {
"reasoning_effort": {"effort": "high", "summary": "detailed"}
}
assert result == {"reasoning_effort": {"effort": "high", "summary": "detailed"}}
finally:
litellm.reasoning_auto_summary = original
@ -605,9 +592,9 @@ class TestThinkingSummaryPreservation:
mock_responses.assert_called_once()
call_kwargs = mock_responses.call_args.kwargs
reasoning = call_kwargs["reasoning"]
assert (
reasoning["summary"] == "concise"
), f"Expected summary='concise', got summary='{reasoning.get('summary')}'"
assert reasoning["summary"] == "concise", (
f"Expected summary='concise', got summary='{reasoning.get('summary')}'"
)
def test_responses_adapter_preserves_summary(self):
"""translate_thinking_to_reasoning should include summary when user provides it."""
@ -616,9 +603,7 @@ class TestThinkingSummaryPreservation:
)
thinking = {"type": "enabled", "budget_tokens": 5000, "summary": "concise"}
result = LiteLLMAnthropicToResponsesAPIAdapter.translate_thinking_to_reasoning(
thinking
)
result = LiteLLMAnthropicToResponsesAPIAdapter.translate_thinking_to_reasoning(thinking)
assert result == {"effort": "high", "summary": "concise"}
def test_responses_adapter_no_summary_by_default(self):
@ -632,11 +617,7 @@ class TestThinkingSummaryPreservation:
try:
litellm.reasoning_auto_summary = False
thinking = {"type": "enabled", "budget_tokens": 5000}
result = (
LiteLLMAnthropicToResponsesAPIAdapter.translate_thinking_to_reasoning(
thinking
)
)
result = LiteLLMAnthropicToResponsesAPIAdapter.translate_thinking_to_reasoning(thinking)
assert result == {"effort": "high"}
assert result is not None and "summary" not in result
finally:
@ -653,9 +634,7 @@ class TestThinkingSummaryPreservation:
thinking=thinking,
model="openai/gpt-5.2",
)
assert result == {
"reasoning_effort": {"effort": "high", "summary": "concise"}
}
assert result == {"reasoning_effort": {"effort": "high", "summary": "concise"}}
def test_translate_thinking_for_model_disabled_stays_plain_string_when_auto_summary_enabled(self):
"""Disabled thinking must stay a plain string even when reasoning_auto_summary is on."""
@ -801,9 +780,7 @@ def test_presanitized_flag_not_leaked_to_provider_params():
def fake_base_handler(*args, **kwargs):
captured.update(kwargs)
captured["optional"] = kwargs.get(
"anthropic_messages_optional_request_params", {}
)
captured["optional"] = kwargs.get("anthropic_messages_optional_request_params", {})
return "stub"
with patch.object(
@ -981,9 +958,7 @@ def test_first_party_claude_4_8_plus_cost_map_entries_carry_mid_conversation_sys
import litellm
cost_map_path = os.path.join(
os.path.dirname(litellm.__file__), "model_prices_and_context_window_backup.json"
)
cost_map_path = os.path.join(os.path.dirname(litellm.__file__), "model_prices_and_context_window_backup.json")
with open(cost_map_path) as f:
cost_map = json.load(f)
rules = cost_map["fallback_generalizations"]["rules"]
@ -1022,9 +997,7 @@ def test_first_party_claude_4_8_plus_cost_map_entries_carry_mid_conversation_sys
("perplexity/sonar", "sonar", "https://api.perplexity.ai/chat/completions"),
],
)
async def test_messages_strips_provider_prefix_exactly_once(
requested_model, expected_wire_model, expected_url
):
async def test_messages_strips_provider_prefix_exactly_once(requested_model, expected_wire_model, expected_url):
"""
BerriAI/litellm#37716: only the leading provider segment may be stripped on the way upstream.

View file

@ -91,13 +91,11 @@ def test_messages_drops_speed_for_vertex_opus_with_drop_params(monkeypatch):
"""Regression: a vertex_ai Opus passthrough must drop ``speed`` even though the
prefix-stripped model id maps to a fast-mode-capable direct-Anthropic entry."""
monkeypatch.setattr(litellm, "drop_params", True)
optional_params = (
AnthropicMessagesRequestUtils.get_requested_anthropic_messages_optional_param(
params={"max_tokens": 1024, "speed": "fast"},
model="claude-opus-4-8",
drop_params=False,
custom_llm_provider="vertex_ai",
)
optional_params = AnthropicMessagesRequestUtils.get_requested_anthropic_messages_optional_param(
params={"max_tokens": 1024, "speed": "fast"},
model="claude-opus-4-8",
drop_params=False,
custom_llm_provider="vertex_ai",
)
assert "speed" not in optional_params

View file

@ -13,9 +13,7 @@ def test_output_format_supported_and_transforms_correctly():
config = AnthropicMessagesConfig()
# 1. Verify it's in supported parameters
supported_params = config.get_supported_anthropic_messages_params(
"claude-sonnet-4-5"
)
supported_params = config.get_supported_anthropic_messages_params("claude-sonnet-4-5")
assert "output_format" in supported_params
# 2. Verify transformation preserves output_format and adds beta header

View file

@ -30,26 +30,14 @@ class MockCompletionStreamWithContentAfterStopReason:
self.responses = [
# Initial text content
ModelResponseStream(
choices=[
StreamingChoices(
delta=Delta(content="Hello"), index=0, finish_reason=None
)
],
choices=[StreamingChoices(delta=Delta(content="Hello"), index=0, finish_reason=None)],
),
ModelResponseStream(
choices=[
StreamingChoices(
delta=Delta(content=" world"), index=0, finish_reason=None
)
],
choices=[StreamingChoices(delta=Delta(content=" world"), index=0, finish_reason=None)],
),
# Message delta with stop_reason AND usage (this is how it actually comes from the API)
ModelResponseStream(
choices=[
StreamingChoices(
delta=Delta(content=""), index=0, finish_reason="stop"
)
],
choices=[StreamingChoices(delta=Delta(content=""), index=0, finish_reason="stop")],
usage=Usage(prompt_tokens=230, completion_tokens=65, total_tokens=295),
),
# Additional content after the stop_reason - this simulates the scenario
@ -118,9 +106,9 @@ def test_anthropic_stream_wrapper_content_after_stop_reason():
print(f"Expected chunk types: {expected_types}")
# Verify we have the expected number of chunks
assert len(chunk_types) >= len(
expected_types
), f"Expected at least {len(expected_types)} chunks, got {len(chunk_types)}"
assert len(chunk_types) >= len(expected_types), (
f"Expected at least {len(expected_types)} chunks, got {len(chunk_types)}"
)
# Verify key chunk types are present
assert "message_start" in chunk_types
@ -143,15 +131,9 @@ def test_anthropic_stream_wrapper_content_after_stop_reason():
delta = message_delta_chunk.get("delta", {})
usage = message_delta_chunk.get("usage", {})
assert (
delta.get("stop_reason") == "end_turn"
), f"Expected stop_reason 'end_turn', got {delta.get('stop_reason')}"
assert (
usage.get("input_tokens") == 230
), f"Expected input_tokens 230, got {usage.get('input_tokens')}"
assert (
usage.get("output_tokens") == 65
), f"Expected output_tokens 65, got {usage.get('output_tokens')}"
assert delta.get("stop_reason") == "end_turn", f"Expected stop_reason 'end_turn', got {delta.get('stop_reason')}"
assert usage.get("input_tokens") == 230, f"Expected input_tokens 230, got {usage.get('input_tokens')}"
assert usage.get("output_tokens") == 65, f"Expected output_tokens 65, got {usage.get('output_tokens')}"
# Verify content_block_stop comes before message_delta
content_block_stop_index = None
@ -165,9 +147,7 @@ def test_anthropic_stream_wrapper_content_after_stop_reason():
assert content_block_stop_index is not None, "content_block_stop not found"
assert message_delta_index is not None, "message_delta not found"
assert (
content_block_stop_index < message_delta_index
), "content_block_stop should come before message_delta"
assert content_block_stop_index < message_delta_index, "content_block_stop should come before message_delta"
@pytest.mark.asyncio
@ -210,15 +190,9 @@ async def test_async_anthropic_stream_wrapper_content_after_stop_reason():
delta = message_delta_chunk.get("delta", {})
usage = message_delta_chunk.get("usage", {})
assert (
delta.get("stop_reason") == "end_turn"
), f"Expected stop_reason 'end_turn', got {delta.get('stop_reason')}"
assert (
usage.get("input_tokens") == 230
), f"Expected input_tokens 230, got {usage.get('input_tokens')}"
assert (
usage.get("output_tokens") == 65
), f"Expected output_tokens 65, got {usage.get('output_tokens')}"
assert delta.get("stop_reason") == "end_turn", f"Expected stop_reason 'end_turn', got {delta.get('stop_reason')}"
assert usage.get("input_tokens") == 230, f"Expected input_tokens 230, got {usage.get('input_tokens')}"
assert usage.get("output_tokens") == 65, f"Expected output_tokens 65, got {usage.get('output_tokens')}"
def test_usage_merging_behavior():
@ -234,18 +208,10 @@ def test_usage_merging_behavior():
for chunk in wrapper:
chunks.append(chunk)
# If this is a message_delta with stop_reason, verify it has usage
if (
chunk.get("type") == "message_delta"
and chunk.get("delta", {}).get("stop_reason") is not None
):
if chunk.get("type") == "message_delta" and chunk.get("delta", {}).get("stop_reason") is not None:
usage = chunk.get("usage", {})
assert (
usage.get("input_tokens") is not None
), "Usage should be merged with stop_reason chunk"
assert (
usage.get("output_tokens") is not None
), "Usage should be merged with stop_reason chunk"
assert usage.get("input_tokens") is not None, "Usage should be merged with stop_reason chunk"
assert usage.get("output_tokens") is not None, "Usage should be merged with stop_reason chunk"
break
@ -273,12 +239,8 @@ def test_sse_wrapper_with_content_after_stop_reason():
lines = chunk_str.split("\n")
# Should have event and data lines
assert any(
line.startswith("event: ") for line in lines
), f"Missing event line in: {chunk_str}"
assert any(
line.startswith("data: ") for line in lines
), f"Missing data line in: {chunk_str}"
assert any(line.startswith("event: ") for line in lines), f"Missing event line in: {chunk_str}"
assert any(line.startswith("data: ") for line in lines), f"Missing data line in: {chunk_str}"
@pytest.mark.asyncio
@ -306,12 +268,8 @@ async def test_async_sse_wrapper_with_content_after_stop_reason():
lines = chunk_str.split("\n")
# Should have event and data lines
assert any(
line.startswith("event: ") for line in lines
), f"Missing event line in: {chunk_str}"
assert any(
line.startswith("data: ") for line in lines
), f"Missing data line in: {chunk_str}"
assert any(line.startswith("event: ") for line in lines), f"Missing event line in: {chunk_str}"
assert any(line.startswith("data: ") for line in lines), f"Missing data line in: {chunk_str}"
if __name__ == "__main__":

View file

@ -58,7 +58,7 @@ def test_anthropic_messages_handler_skips_the_gateway_on_recursion():
"litellm.llms.anthropic.experimental_pass_through.messages.mcp_handler.anthropic_messages_with_mcp",
new=AsyncMock(return_value={"routed": True}),
) as routed:
with pytest.raises(ValueError, match='anthropic_messages_handler is not implemented for sync calls'):
with pytest.raises(ValueError, match="anthropic_messages_handler is not implemented for sync calls"):
anthropic_messages_handler(
max_tokens=100,
messages=[{"role": "user", "content": "hi"}],
@ -77,7 +77,7 @@ def test_anthropic_messages_handler_leaves_native_tools_alone():
"litellm.llms.anthropic.experimental_pass_through.messages.mcp_handler.anthropic_messages_with_mcp",
new=AsyncMock(return_value={"routed": True}),
) as routed:
with pytest.raises(ValueError, match='anthropic_messages_handler is not implemented for sync calls'):
with pytest.raises(ValueError, match="anthropic_messages_handler is not implemented for sync calls"):
anthropic_messages_handler(
max_tokens=100,
messages=[{"role": "user", "content": "hi"}],
@ -114,9 +114,7 @@ def test_build_tool_result_message_uses_anthropic_tool_result_blocks():
message = _build_tool_result_message([{"tool_call_id": "toolu_1", "result": "9 sections", "name": "read_wiki"}])
assert message["role"] == "user"
assert list(message["content"]) == [
{"type": "tool_result", "tool_use_id": "toolu_1", "content": "9 sections"}
]
assert list(message["content"]) == [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "9 sections"}]
@pytest.mark.asyncio
@ -155,19 +153,22 @@ async def test_anthropic_messages_with_mcp_forwards_the_callers_mcp_credentials(
{"stop_reason": "end_turn", "content": [{"type": "text", "text": "done"}]},
]
with patch.object(MCPRequestContext, "resolve", return_value=context), patch.object(
mcp_handler.LiteLLM_Proxy_MCP_Handler
if hasattr(mcp_handler, "LiteLLM_Proxy_MCP_Handler")
else __import__(
"litellm.responses.mcp.litellm_proxy_mcp_handler", fromlist=["LiteLLM_Proxy_MCP_Handler"]
).LiteLLM_Proxy_MCP_Handler,
"_process_mcp_tools_without_openai_transform",
new=process,
), patch(
"litellm.responses.mcp.litellm_proxy_mcp_handler.LiteLLM_Proxy_MCP_Handler._execute_tool_calls",
new=execute,
), patch(
"litellm.anthropic_messages", new=AsyncMock(side_effect=responses)
with (
patch.object(MCPRequestContext, "resolve", return_value=context),
patch.object(
mcp_handler.LiteLLM_Proxy_MCP_Handler
if hasattr(mcp_handler, "LiteLLM_Proxy_MCP_Handler")
else __import__(
"litellm.responses.mcp.litellm_proxy_mcp_handler", fromlist=["LiteLLM_Proxy_MCP_Handler"]
).LiteLLM_Proxy_MCP_Handler,
"_process_mcp_tools_without_openai_transform",
new=process,
),
patch(
"litellm.responses.mcp.litellm_proxy_mcp_handler.LiteLLM_Proxy_MCP_Handler._execute_tool_calls",
new=execute,
),
patch("litellm.anthropic_messages", new=AsyncMock(side_effect=responses)),
):
await mcp_handler.anthropic_messages_with_mcp(
max_tokens=100,
@ -216,16 +217,17 @@ async def test_anthropic_messages_with_mcp_stops_when_every_tool_call_is_skipped
}
anthropic_messages_mock = AsyncMock(return_value=tool_use_response)
with patch.object(
MCPRequestContext, "resolve", return_value=MCPRequestContext(user_api_key_auth="auth")
), patch(
"litellm.responses.mcp.litellm_proxy_mcp_handler.LiteLLM_Proxy_MCP_Handler._process_mcp_tools_without_openai_transform",
new=AsyncMock(return_value=([], {})),
), patch(
"litellm.responses.mcp.litellm_proxy_mcp_handler.LiteLLM_Proxy_MCP_Handler._execute_tool_calls",
new=AsyncMock(return_value=[]),
), patch(
"litellm.anthropic_messages", new=anthropic_messages_mock
with (
patch.object(MCPRequestContext, "resolve", return_value=MCPRequestContext(user_api_key_auth="auth")),
patch(
"litellm.responses.mcp.litellm_proxy_mcp_handler.LiteLLM_Proxy_MCP_Handler._process_mcp_tools_without_openai_transform",
new=AsyncMock(return_value=([], {})),
),
patch(
"litellm.responses.mcp.litellm_proxy_mcp_handler.LiteLLM_Proxy_MCP_Handler._execute_tool_calls",
new=AsyncMock(return_value=[]),
),
patch("litellm.anthropic_messages", new=anthropic_messages_mock),
):
result = await mcp_handler.anthropic_messages_with_mcp(
max_tokens=100,

View file

@ -1,7 +1,6 @@
from typing import List
from litellm.llms.anthropic.experimental_pass_through.adapters.streaming_iterator import (
AnthropicStreamWrapper,
)
@ -53,9 +52,7 @@ def construct_text_chunk(text: str) -> ModelResponseStream:
)
def construct_split_tool_call(
id: str, function_name: str, function_arg_parts: List[str]
) -> List[ModelResponseStream]:
def construct_split_tool_call(id: str, function_name: str, function_arg_parts: List[str]) -> List[ModelResponseStream]:
return [
# https://platform.openai.com/docs/guides/function-calling#streaming
ModelResponseStream(
@ -144,10 +141,7 @@ def test_anthropic_stream_wrapper_single_tool_call():
get_weather_calls = 0
for chunk in chunks:
if (
chunk.get("type") == "content_block_start"
and chunk["content_block"]["type"] == "tool_use"
):
if chunk.get("type") == "content_block_start" and chunk["content_block"]["type"] == "tool_use":
if chunk["content_block"]["name"] == "get_weather":
get_weather_calls += 1
@ -203,10 +197,7 @@ def test_anthropic_stream_wrapper_back_to_back_tool_calls():
get_weather_calls = 0
for chunk in chunks:
if (
chunk.get("type") == "content_block_start"
and chunk["content_block"]["type"] == "tool_use"
):
if chunk.get("type") == "content_block_start" and chunk["content_block"]["type"] == "tool_use":
if chunk["content_block"]["name"] == "get_weather":
get_weather_calls += 1
@ -218,9 +209,7 @@ def test_anthropic_stream_wrapper_interleaved_tool_calls_and_text():
*construct_split_tool_call("tooluse_foo", "get_weather", ['{"city":', '"NY"}']),
construct_text_chunk("The weather is nice today."),
*construct_split_tool_call("tooluse_bar", "get_weather", ['{"city":', '"SF"}']),
*construct_split_tool_call(
"tooluse_bar", "get_weather", ['{"city":', '"CHI"}']
),
*construct_split_tool_call("tooluse_bar", "get_weather", ['{"city":', '"CHI"}']),
construct_text_chunk("The weather is not so nice today."),
ModelResponseStream(
choices=[
@ -280,8 +269,7 @@ def test_anthropic_stream_wrapper_interleaved_tool_calls_and_text():
text_deltas = [
chunk["delta"]["text"]
for chunk in chunks
if chunk.get("type") == "content_block_delta"
and chunk["delta"].get("type") == "text_delta"
if chunk.get("type") == "content_block_delta" and chunk["delta"].get("type") == "text_delta"
]
assert text_deltas == [
"The weather is nice today.",
@ -291,10 +279,7 @@ def test_anthropic_stream_wrapper_interleaved_tool_calls_and_text():
get_weather_calls = 0
for chunk in chunks:
if (
chunk.get("type") == "content_block_start"
and chunk["content_block"]["type"] == "tool_use"
):
if chunk.get("type") == "content_block_start" and chunk["content_block"]["type"] == "tool_use":
if chunk["content_block"]["name"] == "get_weather":
get_weather_calls += 1

View file

@ -29,13 +29,12 @@ def _call_handler_and_capture_optional_params(thinking=None, **extra_kwargs):
"""
captured = {}
with patch(
"litellm.llms.anthropic.experimental_pass_through.messages.handler."
"base_llm_http_handler"
) as mock_handler, patch(
"litellm.llms.anthropic.experimental_pass_through.messages.handler."
"ProviderConfigManager"
) as mock_pcm:
with (
patch(
"litellm.llms.anthropic.experimental_pass_through.messages.handler.base_llm_http_handler"
) as mock_handler,
patch("litellm.llms.anthropic.experimental_pass_through.messages.handler.ProviderConfigManager") as mock_pcm,
):
# Make get_provider_anthropic_messages_config return a non-None config
# so the handler takes the native Anthropic path
mock_pcm.get_provider_anthropic_messages_config.return_value = MagicMock()
@ -71,9 +70,7 @@ class TestReasoningAutoSummaryMessages:
def test_adaptive_thinking_gets_display_summarized(self):
"""reasoning_auto_summary=True + thinking.type='adaptive' -> display='summarized'."""
with patch.object(litellm, "reasoning_auto_summary", True):
params = _call_handler_and_capture_optional_params(
thinking={"type": "adaptive", "budget_tokens": 5000}
)
params = _call_handler_and_capture_optional_params(thinking={"type": "adaptive", "budget_tokens": 5000})
thinking = params.get("thinking", {})
assert thinking.get("display") == "summarized"
assert thinking.get("type") == "adaptive"
@ -82,9 +79,7 @@ class TestReasoningAutoSummaryMessages:
def test_enabled_thinking_gets_display_summarized(self):
"""reasoning_auto_summary=True + thinking.type='enabled' -> display='summarized'."""
with patch.object(litellm, "reasoning_auto_summary", True):
params = _call_handler_and_capture_optional_params(
thinking={"type": "enabled", "budget_tokens": 10000}
)
params = _call_handler_and_capture_optional_params(thinking={"type": "enabled", "budget_tokens": 10000})
thinking = params.get("thinking", {})
assert thinking.get("display") == "summarized"
assert thinking.get("type") == "enabled"
@ -92,18 +87,14 @@ class TestReasoningAutoSummaryMessages:
def test_disabled_thinking_no_display(self):
"""reasoning_auto_summary=True + thinking.type='disabled' -> display NOT set."""
with patch.object(litellm, "reasoning_auto_summary", True):
params = _call_handler_and_capture_optional_params(
thinking={"type": "disabled"}
)
params = _call_handler_and_capture_optional_params(thinking={"type": "disabled"})
thinking = params.get("thinking", {})
assert "display" not in thinking
def test_no_injection_when_flag_false(self):
"""reasoning_auto_summary=False + active thinking -> display NOT set."""
with patch.object(litellm, "reasoning_auto_summary", False):
params = _call_handler_and_capture_optional_params(
thinking={"type": "enabled", "budget_tokens": 10000}
)
params = _call_handler_and_capture_optional_params(thinking={"type": "enabled", "budget_tokens": 10000})
thinking = params.get("thinking", {})
assert "display" not in thinking
@ -117,12 +108,11 @@ class TestReasoningAutoSummaryMessages:
def test_env_var_enables_auto_summary(self):
"""LITELLM_REASONING_AUTO_SUMMARY=true env var enables the feature."""
with patch.object(litellm, "reasoning_auto_summary", False), patch.dict(
os.environ, {"LITELLM_REASONING_AUTO_SUMMARY": "true"}
with (
patch.object(litellm, "reasoning_auto_summary", False),
patch.dict(os.environ, {"LITELLM_REASONING_AUTO_SUMMARY": "true"}),
):
params = _call_handler_and_capture_optional_params(
thinking={"type": "adaptive", "budget_tokens": 5000}
)
params = _call_handler_and_capture_optional_params(thinking={"type": "adaptive", "budget_tokens": 5000})
thinking = params.get("thinking", {})
assert thinking.get("display") == "summarized"

View file

@ -17,7 +17,6 @@ from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_tran
)
@pytest.mark.parametrize(
"reasoning_effort,expected_effort",
[
@ -29,9 +28,7 @@ from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_tran
("max", "max"),
],
)
def test_reasoning_effort_maps_to_output_config_for_adaptive_model(
reasoning_effort, expected_effort
):
def test_reasoning_effort_maps_to_output_config_for_adaptive_model(reasoning_effort, expected_effort):
config = AnthropicMessagesConfig()
optional_params = {"max_tokens": 1024, "reasoning_effort": reasoning_effort}
@ -141,9 +138,7 @@ def test_reasoning_effort_unsupported_tier_raises_400_messages(model, bad_effort
("invoke/us.anthropic.claude-opus-4-7", "xhigh", "xhigh"),
],
)
def test_bedrock_invoke_messages_clamps_effort_to_ceiling(
local_model_cost_map, model, effort, expected_effort
):
def test_bedrock_invoke_messages_clamps_effort_to_ceiling(local_model_cost_map, model, effort, expected_effort):
"""Bedrock Invoke /v1/messages degrades effort to the model's ceiling.
Claude Code "goal mode" sends ``xhigh``; Opus 4.6 must clamp to ``max``
@ -189,9 +184,7 @@ def test_bedrock_invoke_messages_rejects_xhigh_without_ceiling(local_model_cost_
"bedrock/invoke/us.anthropic.claude-sonnet-4-6",
],
)
def test_reasoning_effort_max_accepted_on_sonnet_46_messages(
local_model_cost_map, model
):
def test_reasoning_effort_max_accepted_on_sonnet_46_messages(local_model_cost_map, model):
config = AnthropicMessagesConfig()
optional_params = {"max_tokens": 1024, "reasoning_effort": "max"}
@ -249,9 +242,7 @@ def test_explicit_thinking_wins_over_reasoning_effort():
def test_reasoning_effort_in_supported_params():
config = AnthropicMessagesConfig()
assert "reasoning_effort" in config.get_supported_anthropic_messages_params(
"claude-opus-4-7"
)
assert "reasoning_effort" in config.get_supported_anthropic_messages_params("claude-opus-4-7")
@pytest.mark.parametrize(
@ -265,9 +256,7 @@ def test_reasoning_effort_in_supported_params():
"vertex_ai/claude-opus-4-6",
],
)
def test_legacy_thinking_high_budget_clamps_to_high_when_xhigh_unsupported(
local_model_cost_map, model
):
def test_legacy_thinking_high_budget_clamps_to_high_when_xhigh_unsupported(local_model_cost_map, model):
"""Claude Code sends ``thinking.budget_tokens=31999``; Sonnet 4.6 and Opus 4.6
have no ``xhigh`` tier, so the translator must emit ``high`` rather than the
provider-invalid ``xhigh`` (regression for issue #29282)."""
@ -317,9 +306,7 @@ def test_legacy_thinking_high_budget_keeps_xhigh_when_supported():
"bedrock/invoke/us.anthropic.claude-opus-4-8",
],
)
def test_legacy_thinking_translates_to_adaptive_for_opus_48(
model, local_model_cost_map
):
def test_legacy_thinking_translates_to_adaptive_for_opus_48(model, local_model_cost_map):
"""Regression for issue #29188: Opus 4.8 requires adaptive thinking, but the
legacy ``thinking.type='enabled'`` shape was passed through unchanged for
Bedrock 4.8 (its cost-map entry lacked ``supports_adaptive_thinking`` and the
@ -419,9 +406,7 @@ def test_legacy_thinking_left_untouched_on_non_adaptive_model():
("claude-sonnet-4-5", False),
],
)
def test_disabled_thinking_omitted_for_always_on_models_messages(
local_model_cost_map, model, expected_dropped
):
def test_disabled_thinking_omitted_for_always_on_models_messages(local_model_cost_map, model, expected_dropped):
"""/v1/messages: ``thinking={"type": "disabled"}`` is omitted for always-on-thinking
models and forwarded verbatim for models that accept it."""
config = AnthropicMessagesConfig()

View file

@ -23,11 +23,7 @@ def test_optional_param_filtering_unchanged():
"not_a_real_param": "drop me", # invalid key dropped
"stream": True,
}
result = (
AnthropicMessagesRequestUtils.get_requested_anthropic_messages_optional_param(
params
)
)
result = AnthropicMessagesRequestUtils.get_requested_anthropic_messages_optional_param(params)
assert result == {"temperature": 0.5, "tools": [{"name": "x"}], "stream": True}
assert "top_p" not in result
assert "not_a_real_param" not in result
@ -37,9 +33,7 @@ def test_valid_keys_are_memoized():
_anthropic_messages_optional_param_keys.cache_clear()
first = _anthropic_messages_optional_param_keys()
for _ in range(50):
AnthropicMessagesRequestUtils.get_requested_anthropic_messages_optional_param(
{"temperature": 0.1}
)
AnthropicMessagesRequestUtils.get_requested_anthropic_messages_optional_param({"temperature": 0.1})
info = _anthropic_messages_optional_param_keys.cache_info()
# Resolved exactly once despite many calls.
assert info.misses == 1
@ -51,23 +45,16 @@ def test_valid_keys_are_memoized():
def test_empty_params():
assert (
AnthropicMessagesRequestUtils.get_requested_anthropic_messages_optional_param(
{}
)
== {}
)
assert AnthropicMessagesRequestUtils.get_requested_anthropic_messages_optional_param({}) == {}
def test_drop_params_strips_speed_for_unsupported_model():
original = litellm.drop_params
litellm.drop_params = True
try:
result = (
AnthropicMessagesRequestUtils.get_requested_anthropic_messages_optional_param(
params={"speed": "fast", "temperature": 0.5},
model="claude-sonnet-4-6",
)
result = AnthropicMessagesRequestUtils.get_requested_anthropic_messages_optional_param(
params={"speed": "fast", "temperature": 0.5},
model="claude-sonnet-4-6",
)
finally:
litellm.drop_params = original
@ -80,11 +67,9 @@ def test_drop_params_keeps_speed_for_supporting_model():
original = litellm.drop_params
litellm.drop_params = True
try:
result = (
AnthropicMessagesRequestUtils.get_requested_anthropic_messages_optional_param(
params={"speed": "fast"},
model="claude-opus-4-6",
)
result = AnthropicMessagesRequestUtils.get_requested_anthropic_messages_optional_param(
params={"speed": "fast"},
model="claude-opus-4-6",
)
finally:
litellm.drop_params = original

View file

@ -1,4 +1,3 @@
import pytest
from fastapi.testclient import TestClient
@ -14,25 +13,13 @@ class MockCompletionStream:
def __init__(self):
self.responses = [
ModelResponseStream(
choices=[
StreamingChoices(
delta=Delta(content="Hello"), index=0, finish_reason=None
)
],
choices=[StreamingChoices(delta=Delta(content="Hello"), index=0, finish_reason=None)],
),
ModelResponseStream(
choices=[
StreamingChoices(
delta=Delta(content=" World"), index=0, finish_reason=None
)
],
choices=[StreamingChoices(delta=Delta(content=" World"), index=0, finish_reason=None)],
),
ModelResponseStream(
choices=[
StreamingChoices(
delta=Delta(content=""), index=0, finish_reason="stop"
)
],
choices=[StreamingChoices(delta=Delta(content=""), index=0, finish_reason="stop")],
),
]
self.index = 0
@ -50,9 +37,7 @@ class MockCompletionStream:
def test_anthropic_sse_wrapper_format():
"""Test that the SSE wrapper produces proper event and data formatting"""
wrapper = AnthropicStreamWrapper(
completion_stream=MockCompletionStream(), model="claude-3"
)
wrapper = AnthropicStreamWrapper(completion_stream=MockCompletionStream(), model="claude-3")
# Get the first chunk from the SSE wrapper
first_chunk = next(wrapper.anthropic_sse_wrapper())
@ -73,9 +58,7 @@ def test_anthropic_sse_wrapper_format():
def test_anthropic_sse_wrapper_event_types():
"""Test that different chunk types produce correct event types"""
wrapper = AnthropicStreamWrapper(
completion_stream=MockCompletionStream(), model="claude-3"
)
wrapper = AnthropicStreamWrapper(completion_stream=MockCompletionStream(), model="claude-3")
chunks = []
for chunk in wrapper.anthropic_sse_wrapper():
@ -104,18 +87,10 @@ async def test_async_anthropic_sse_wrapper():
def __init__(self):
self.responses = [
ModelResponseStream(
choices=[
StreamingChoices(
delta=Delta(content="Hello"), index=0, finish_reason=None
)
],
choices=[StreamingChoices(delta=Delta(content="Hello"), index=0, finish_reason=None)],
),
ModelResponseStream(
choices=[
StreamingChoices(
delta=Delta(content=" World"), index=0, finish_reason=None
)
],
choices=[StreamingChoices(delta=Delta(content=" World"), index=0, finish_reason=None)],
),
]
self.index = 0
@ -130,9 +105,7 @@ async def test_async_anthropic_sse_wrapper():
self.index += 1
return response
wrapper = AnthropicStreamWrapper(
completion_stream=AsyncMockCompletionStream(), model="claude-3"
)
wrapper = AnthropicStreamWrapper(completion_stream=AsyncMockCompletionStream(), model="claude-3")
# Get the first chunk from the async SSE wrapper
first_chunk = None

View file

@ -138,7 +138,7 @@ async def test_async_sse_wrapper_treats_message_stop_bytes_as_complete():
def test_is_message_stop_chunk():
assert _is_message_stop_chunk({"type": "message_stop"}) is True
assert _is_message_stop_chunk({"type": "message_delta"}) is False
assert _is_message_stop_chunk(b'event: message_stop\ndata: {}\n\n') is True
assert _is_message_stop_chunk(b"event: message_stop\ndata: {}\n\n") is True
assert _is_message_stop_chunk(b"raw-bytes") is False
assert _is_message_stop_chunk("message_stop") is False
@ -150,7 +150,7 @@ def test_is_message_stop_chunk_ignores_substring_in_payload():
not be treated as a terminal stop event.
"""
delta_frame_with_substring = (
b'event: content_block_delta\n'
b"event: content_block_delta\n"
b'data: {"type": "content_block_delta", "delta": '
b'{"type": "input_json_delta", "partial_json": "\\"message_stop\\""}}\n\n'
)
@ -164,10 +164,11 @@ async def test_async_sse_wrapper_emits_error_when_bytes_stream_only_mentions_mes
payload text contains `message_stop` (but never emits the actual
`event: message_stop` frame) must still be flagged as incomplete.
"""
async def _byte_stream():
yield b'event: message_start\ndata: {"type": "message_start"}\n\n'
yield (
b'event: content_block_delta\n'
b"event: content_block_delta\n"
b'data: {"type": "content_block_delta", "delta": '
b'{"type": "input_json_delta", "partial_json": "\\"message_stop\\""}}\n\n'
)

View file

@ -1308,9 +1308,7 @@ class TestToolResultImages:
},
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "toolu_01", "content": tool_result_content}
],
"content": [{"type": "tool_result", "tool_use_id": "toolu_01", "content": tool_result_content}],
},
]
@ -1473,7 +1471,9 @@ class TestPromptCacheBreakpointToResponses:
]
def test_system_without_breakpoint_still_becomes_instructions(self):
request = _make_request(system=[{"type": "text", "text": "Be concise."}, {"type": "text", "text": "Be helpful."}])
request = _make_request(
system=[{"type": "text", "text": "Be concise."}, {"type": "text", "text": "Be helpful."}]
)
kwargs = _ADAPTER.translate_request(request)
assert kwargs["instructions"] == "Be concise.\nBe helpful."
assert kwargs["input"] == [

View file

@ -188,9 +188,7 @@ class TestNormalizeReasoningEffortValue:
"litellm.utils.get_model_info",
return_value=_mock_model_info(supports_minimal_reasoning_effort=True),
):
assert (
normalize_reasoning_effort_value("minimal", model="test") == "minimal"
)
assert normalize_reasoning_effort_value("minimal", model="test") == "minimal"
def test_minimal_degrades_to_low(self):
with patch(
@ -239,9 +237,7 @@ class TestAdapterAdaptiveThinking:
)
adapter = LiteLLMAnthropicMessagesAdapter()
result = adapter.translate_anthropic_thinking_to_reasoning_effort(
{"type": "adaptive"}
)
result = adapter.translate_anthropic_thinking_to_reasoning_effort({"type": "adaptive"})
assert result == "medium"
def test_messages_adapter_adaptive_overridden_by_output_config(self):

View file

@ -221,10 +221,7 @@ class TestAnthropicFilesConfig:
litellm_params={},
)
assert (
url
== f"{ANTHROPIC_FILES_API_BASE}/v1/files/..%2F..%2Fv1%2Fmessages%2Fbatches%3Flimit%3D1%23frag"
)
assert url == f"{ANTHROPIC_FILES_API_BASE}/v1/files/..%2F..%2Fv1%2Fmessages%2Fbatches%3Flimit%3D1%23frag"
assert params == {}
def test_transform_retrieve_file_response(self):

View file

@ -101,9 +101,7 @@ async def test_anthropic_native_interceptor_skipped():
)
h = AdvisorOrchestrationHandler()
assert not h.can_handle(
[ADVISOR_TOOL], "anthropic"
), "Interceptor must NOT trigger for anthropic provider"
assert not h.can_handle([ADVISOR_TOOL], "anthropic"), "Interceptor must NOT trigger for anthropic provider"
# ---------------------------------------------------------------------------
@ -204,9 +202,7 @@ async def test_loop_one_advisor_call():
assert "is_prime" in texts[0]["text"]
# No advisor tool_use blocks in final response
advisor_uses = [
b for b in content if b.get("type") == "tool_use" and b.get("name") == "advisor"
]
advisor_uses = [b for b in content if b.get("type") == "tool_use" and b.get("name") == "advisor"]
assert len(advisor_uses) == 0
@ -366,9 +362,7 @@ async def test_prior_advisor_blocks_replaced_in_history():
# Text block with advisor feedback must be present
text_blocks = [b for b in content if b.get("type") == "text"]
feedback_blocks = [
b for b in text_blocks if "advisor_feedback" in b.get("text", "")
]
feedback_blocks = [b for b in text_blocks if "advisor_feedback" in b.get("text", "")]
assert len(feedback_blocks) >= 1
assert "trial division" in feedback_blocks[0]["text"]
@ -707,11 +701,7 @@ async def test_advisor_ignores_tool_credentials_when_clientside_disabled():
with patch.dict(
sys.modules,
{
"litellm.proxy.proxy_server": _fake_proxy_server(
{"allow_client_side_credentials": False}
)
},
{"litellm.proxy.proxy_server": _fake_proxy_server({"allow_client_side_credentials": False})},
):
captured = await _run_advisor_and_capture_subcall_kwargs()
assert captured["api_key"] is None
@ -726,11 +716,7 @@ async def test_advisor_uses_tool_credentials_when_clientside_enabled():
with patch.dict(
sys.modules,
{
"litellm.proxy.proxy_server": _fake_proxy_server(
{"allow_client_side_credentials": True}
)
},
{"litellm.proxy.proxy_server": _fake_proxy_server({"allow_client_side_credentials": True})},
):
captured = await _run_advisor_and_capture_subcall_kwargs()
assert captured["api_key"] == "sk-other"

View file

@ -2316,6 +2316,31 @@ class TestWifServerOwnedAuthHeaderStrip:
assert all(caller_key not in value for value in headers.values())
assert headers["user-agent"] == "caller/1.0"
@pytest.mark.parametrize("header_name", PROXY_CREDENTIAL_HEADER_NAMES)
def test_files_surface_strips_caller_credentials_too(self, monkeypatch, wif_engine, header_name):
"""The files surface builds its own headers, so it needs the same strip the chat surface
has: without it a minted federation Bearer travels beside the caller's own credential."""
from litellm.llms.anthropic.files.transformation import AnthropicFilesConfig
for name, value in WIF_ENV.items():
monkeypatch.setenv(name, value)
caller_key = "sk-litellm-CALLER-VIRTUAL-KEY"
headers = AnthropicFilesConfig().validate_environment(
headers={header_name.title(): caller_key, "user-agent": "caller/1.0"},
model="claude-sonnet-4-5",
messages=[],
optional_params={},
litellm_params={},
api_key=None,
api_base=None,
)
assert headers["authorization"] == f"Bearer {FAKE_MINTED_TOKEN}"
assert header_name == "authorization" or header_name not in {name.lower() for name in headers}
assert all(caller_key not in value for value in headers.values())
assert headers["user-agent"] == "caller/1.0"
def test_no_mint_preserves_caller_supplied_authorization(self, monkeypatch, clean_anthropic_env):
"""No-regression: LiteLLM deliberately lets a caller-forwarded credential
header ride alongside a statically configured ANTHROPIC_API_KEY, because the
@ -3019,6 +3044,23 @@ class TestModelDiscovery:
WIF source configured in litellm_params, rather than the environment, could not
discover)."""
@pytest.mark.parametrize(
"configured_base", ["https://api.anthropic.com/v1", "https://api.anthropic.com/v1/messages"]
)
def test_discovery_does_not_double_the_version_segment(self, monkeypatch, clean_anthropic_env, configured_base):
"""Regression: /v1/models is appended here, so a base an operator already wrote as
.../v1 (or the chat URL they copied) would be asked for /v1/v1/models and 404."""
from litellm.llms.anthropic.common_utils import AnthropicModelInfo
monkeypatch.setenv("ANTHROPIC_API_KEY", FAKE_REGULAR_KEY)
client = RecordingModelsClient([{"data": [{"id": "claude-a"}], "has_more": False, "last_id": "claude-a"}])
monkeypatch.setattr("litellm.module_level_client", client)
models = AnthropicModelInfo().get_models(api_base=configured_base)
assert models == ["anthropic/claude-a"]
assert client.calls[0].url == "https://api.anthropic.com/v1/models"
def test_get_models_paginates_via_has_more_and_last_id(self, monkeypatch, clean_anthropic_env):
from litellm.llms.anthropic.common_utils import AnthropicModelInfo

View file

@ -1,4 +1,3 @@
from litellm.llms.anthropic.count_tokens.transformation import (
AnthropicCountTokensConfig,
)

View file

@ -3,8 +3,6 @@ Test that Azure AI Anthropic models have cache pricing configured.
Verifies the fix for issue #19532.
"""
import litellm
from litellm import get_model_info
from litellm.litellm_core_utils.get_model_cost_map import get_model_cost_map
@ -27,16 +25,11 @@ def reload_model_costs():
("claude-sonnet-4-5", 3.75e-06, 3e-07),
],
)
def test_azure_ai_claude_cache_pricing(
model, expected_cache_creation_cost, expected_cache_read_cost
):
def test_azure_ai_claude_cache_pricing(model, expected_cache_creation_cost, expected_cache_read_cost):
"""Test that Azure AI Claude models have correct cache pricing."""
model_info = get_model_info(model=model, custom_llm_provider="azure_ai")
assert model_info.get("cache_creation_input_token_cost") is not None
assert model_info.get("cache_read_input_token_cost") is not None
assert (
model_info.get("cache_creation_input_token_cost")
== expected_cache_creation_cost
)
assert model_info.get("cache_creation_input_token_cost") == expected_cache_creation_cost
assert model_info.get("cache_read_input_token_cost") == expected_cache_read_cost

View file

@ -5,7 +5,6 @@ being either a ``dict`` or a ``ServerToolUse`` pydantic instance.
See https://github.com/BerriAI/litellm/issues/26153.
"""
import pytest
@ -57,7 +56,8 @@ def test_get_cost_for_anthropic_web_search_with_dict_server_tool_use():
info = _make_model_info(cost_per_query=0.01)
cost = get_cost_for_anthropic_web_search(
model_info=info, usage=usage # type: ignore[arg-type]
model_info=info,
usage=usage, # type: ignore[arg-type]
)
assert cost == pytest.approx(0.03)
@ -68,7 +68,8 @@ def test_get_cost_for_anthropic_web_search_with_pydantic_server_tool_use():
info = _make_model_info(cost_per_query=0.01)
cost = get_cost_for_anthropic_web_search(
model_info=info, usage=usage # type: ignore[arg-type]
model_info=info,
usage=usage, # type: ignore[arg-type]
)
assert cost == pytest.approx(0.03)
@ -79,7 +80,8 @@ def test_get_cost_for_anthropic_web_search_with_none_server_tool_use():
info = _make_model_info(cost_per_query=0.01)
cost = get_cost_for_anthropic_web_search(
model_info=info, usage=usage # type: ignore[arg-type]
model_info=info,
usage=usage, # type: ignore[arg-type]
)
assert cost == 0.0

View file

@ -10,9 +10,7 @@ Regression test for https://github.com/BerriAI/litellm/issues/22040
import os
import sys
sys.path.insert(
0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../.."))
)
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../..")))
from litellm.llms.anthropic.count_tokens.transformation import (
AnthropicCountTokensConfig,
@ -78,9 +76,5 @@ class TestCountTokensOAuthHeaders:
headers = config.get_required_headers(FAKE_OAUTH_TOKEN)
beta_value = headers.get("anthropic-beta", "")
assert (
"token-counting" in beta_value
), f"token-counting beta missing from OAuth headers: {beta_value}"
assert (
"oauth-2025-04-20" in beta_value
), f"oauth beta missing from OAuth headers: {beta_value}"
assert "token-counting" in beta_value, f"token-counting beta missing from OAuth headers: {beta_value}"
assert "oauth-2025-04-20" in beta_value, f"oauth beta missing from OAuth headers: {beta_value}"

View file

@ -12,9 +12,7 @@ import sys
import os
# Add the parent directory to the path so we can import litellm
sys.path.insert(
0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../.."))
)
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../..")))
import litellm
from litellm.litellm_core_utils.prompt_templates.factory import (
@ -68,10 +66,7 @@ class TestMessageSanitization:
assert sanitized[1]["role"] == "assistant"
assert sanitized[2]["role"] == "tool"
assert sanitized[2]["tool_call_id"] == "toolu_01Kus2cC3ydjBW7UK4GJqBP4"
assert (
"skipped" in sanitized[2]["content"].lower()
or "interrupted" in sanitized[2]["content"].lower()
)
assert "skipped" in sanitized[2]["content"].lower() or "interrupted" in sanitized[2]["content"].lower()
assert "get_weather" in sanitized[2]["content"]
def test_case_a_orphaned_tool_call_multiple(self):
@ -115,12 +110,8 @@ class TestMessageSanitization:
assert len(sanitized) == 4
assert sanitized[0]["role"] == "user"
assert sanitized[1]["role"] == "assistant"
assert (
sanitized[2]["tool_call_id"] == "call_1"
) # Original tool result (first in tool_calls)
assert (
sanitized[3]["tool_call_id"] == "call_2"
) # Dummy added for missing call_2
assert sanitized[2]["tool_call_id"] == "call_1" # Original tool result (first in tool_calls)
assert sanitized[3]["tool_call_id"] == "call_2" # Dummy added for missing call_2
def test_case_b_orphaned_tool_result(self):
"""
@ -188,10 +179,7 @@ class TestMessageSanitization:
assert len(sanitized) == 2
assert sanitized[0]["role"] == "user"
assert (
sanitized[0]["content"]
== "[System: Empty message content sanitised to satisfy protocol]"
)
assert sanitized[0]["content"] == "[System: Empty message content sanitised to satisfy protocol]"
def test_case_c_whitespace_only_content(self):
"""
@ -206,14 +194,8 @@ class TestMessageSanitization:
sanitized = sanitize_messages_for_tool_calling(messages)
assert len(sanitized) == 2
assert (
sanitized[0]["content"]
== "[System: Empty message content sanitised to satisfy protocol]"
)
assert (
sanitized[1]["content"]
== "[System: Empty message content sanitised to satisfy protocol]"
)
assert sanitized[0]["content"] == "[System: Empty message content sanitised to satisfy protocol]"
assert sanitized[1]["content"] == "[System: Empty message content sanitised to satisfy protocol]"
def test_case_c_valid_content_preserved(self):
"""
@ -270,10 +252,7 @@ class TestMessageSanitization:
assert sanitized[2]["role"] == "tool"
assert sanitized[2]["tool_call_id"] == "call_1" # Dummy added
assert sanitized[3]["role"] == "user"
assert (
sanitized[3]["content"]
== "[System: Empty message content sanitised to satisfy protocol]"
)
assert sanitized[3]["content"] == "[System: Empty message content sanitised to satisfy protocol]"
assert sanitized[4]["role"] == "assistant"
def test_modify_params_false_no_sanitization(self):
@ -329,9 +308,7 @@ class TestMessageSanitization:
]
# This should not raise an error and should add dummy tool result
result = anthropic_messages_pt(
messages=messages, model="claude-sonnet-4-5", llm_provider="anthropic"
)
result = anthropic_messages_pt(messages=messages, model="claude-sonnet-4-5", llm_provider="anthropic")
# Should have at least 2 messages (user and assistant)
# The tool result will be merged into user content
@ -355,23 +332,17 @@ class TestMessageSanitization:
{"role": "user", "content": ""},
]
result = anthropic_messages_pt(
messages=messages, model="claude-sonnet-4-5", llm_provider="anthropic"
)
result = anthropic_messages_pt(messages=messages, model="claude-sonnet-4-5", llm_provider="anthropic")
# All three user messages get merged into one user turn for Anthropic.
assert len(result) == 1
assert result[0]["role"] == "user"
text_blocks = [
b for b in result[0]["content"] if isinstance(b, dict) and b.get("type") == "text"
]
text_blocks = [b for b in result[0]["content"] if isinstance(b, dict) and b.get("type") == "text"]
assert len(text_blocks) == 3
# No text block may be empty — that's the contract Anthropic enforces.
for block in text_blocks:
assert block["text"].strip() != ""
assert text_blocks[2]["text"] == (
"[System: Empty message content sanitised to satisfy protocol]"
)
assert text_blocks[2]["text"] == ("[System: Empty message content sanitised to satisfy protocol]")
def test_empty_text_block_in_list_content_sanitized(self):
"""
@ -392,14 +363,10 @@ class TestMessageSanitization:
},
]
result = anthropic_messages_pt(
messages=messages, model="claude-sonnet-4-5", llm_provider="anthropic"
)
result = anthropic_messages_pt(messages=messages, model="claude-sonnet-4-5", llm_provider="anthropic")
assert len(result) == 1
text_blocks = [
b for b in result[0]["content"] if isinstance(b, dict) and b.get("type") == "text"
]
text_blocks = [b for b in result[0]["content"] if isinstance(b, dict) and b.get("type") == "text"]
assert len(text_blocks) == 3
assert text_blocks[0]["text"] == "real content"
for block in text_blocks[1:]:
@ -418,9 +385,7 @@ class TestMessageSanitization:
{"role": "user", "content": "How are you?"},
]
result = anthropic_messages_pt(
messages=messages, model="claude-sonnet-4-5", llm_provider="anthropic"
)
result = anthropic_messages_pt(messages=messages, model="claude-sonnet-4-5", llm_provider="anthropic")
# Two user turns + one assistant turn (alternation preserved).
assert len(result) == 3