litellm/tests/unit/proxy/anthropic_endpoints/test_endpoints.py
devin-ai-integration[bot] 56388fd13d
fix(count_tokens): count a history that carries a compaction block (#44784)
* fix(count_tokens): count a compaction block instead of answering 500

A message history that replays Anthropic's signed compaction block made
/v1/messages/count_tokens answer HTTP 500: neither the Anthropic nor the
Vertex AI partner count-tokens request carried the compact-2026-09-04 beta,
the provider rejected the block with a 400, the proxy fell back to the local
tokenizer, and that raised on the unknown block type.

Both provider count paths now add the compaction beta whenever the history
carries a compaction block, and the local tokenizer counts the block's
summary text like a text block, skipping the signature.

* fix(token_counter): skip the tokenizer for empty thinking and compaction text

* fix(count_tokens): forward context_management on Anthropic counts and count chat compaction blocks locally

Vertex AI compaction support for inference and counting moves to its own PR, so the
Vertex count handler is back to main here

* fix(anthropic): map list-form context_management to edits for count_tokens and inference betas

Count requests forwarded the OpenAI list form of context_management unchanged, while inference
maps it to Anthropic edits, and the inference beta helper read the list as no edits. Both now
map it once. A replayed compaction block with an empty summary no longer reaches the tokenizer.

* fix(anthropic): forward count context_management only with edits, map Vertex list-form betas, and return provider context_management

* fix(anthropic): drop an unmappable context_management list under drop_params

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-10-30 00:11:55 -07:00

1110 lines
46 KiB
Python

"""
Test for anthropic_endpoints/endpoints.py, focusing on handling dictionary objects in streaming responses
"""
import json
import logging
import unittest
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Mapping
from types import MappingProxyType
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from fastapi import Request, Response
from fastapi.responses import StreamingResponse
from fastapi.testclient import TestClient
from starlette.types import Message
from litellm import Router
from litellm._logging import verbose_proxy_logger
from litellm.caching.dual_cache import DualCache
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
from litellm.types.guardrails import GuardrailEventHooks
from litellm.types.utils import ModelResponseStream, TokenCountResponse
class TestAnthropicEndpoints(unittest.TestCase):
@patch("litellm.litellm_core_utils.safe_json_dumps.safe_dumps")
@pytest.mark.asyncio
async def test_async_data_generator_anthropic_dict_handling(self, mock_safe_dumps):
"""Test async_data_generator_anthropic handles dictionary chunks properly"""
# Setup
mock_response = AsyncMock()
mock_response.__aiter__.return_value = [
{"type": "message_start", "message": {"id": "msg_123"}},
"text chunk data",
{"type": "content_block_delta", "delta": {"text": "more data"}},
"text chunk data again",
]
mock_user_api_key_dict = MagicMock()
mock_request_data = {}
mock_proxy_logging_obj = MagicMock()
mock_proxy_logging_obj.async_post_call_streaming_hook = AsyncMock(
side_effect=lambda **kwargs: kwargs["response"]
)
# Configure safe_dumps to return a properly formatted JSON string
mock_safe_dumps.side_effect = lambda chunk: json.dumps(chunk)
# Execute
result = [
chunk
async for chunk in ProxyBaseLLMRequestProcessing.async_sse_data_generator(
response=mock_response,
user_api_key_dict=mock_user_api_key_dict,
request_data=mock_request_data,
proxy_logging_obj=mock_proxy_logging_obj,
)
]
# Verify
expected_result = [
'data: {"type": "message_start", "message": {"id": "msg_123"}}\n\n',
"text chunk data",
'data: {"type": "content_block_delta", "delta": {"text": "more data"}}\n\n',
"text chunk data again",
]
self.assertEqual(result, expected_result)
# Assert safe_dumps was called for dictionary objects
mock_safe_dumps.assert_any_call({"type": "message_start", "message": {"id": "msg_123"}})
mock_safe_dumps.assert_any_call({"type": "content_block_delta", "delta": {"text": "more data"}})
assert mock_safe_dumps.call_count == 2 # Called twice, once for each dict object
_BLOCK_CALL_ID: Final = "messages-block"
_BLOCK_TEXT: Final = "blocked by guardrail"
class _BlockEveryRequest(CustomGuardrail):
async def async_pre_call_hook(
self, user_api_key_dict: UserAPIKeyAuth, cache: DualCache, data: dict[str, object], call_type: str
) -> None:
self.raise_passthrough_exception(violation_message=_BLOCK_TEXT, request_data=data)
class _RewriteEveryStreamedReply(CustomGuardrail):
async def async_post_call_streaming_iterator_hook(
self, user_api_key_dict: UserAPIKeyAuth, response: AsyncIterator[object], request_data: dict[str, object]
) -> AsyncGenerator[ModelResponseStream, None]:
async for _ in response:
yield ModelResponseStream(choices=[])
def _messages_request(body: Mapping[str, object]) -> Request:
async def receive() -> Message:
return {"type": "http.request", "body": json.dumps(body).encode(), "more_body": False}
return Request(
{
"type": "http",
"method": "POST",
"path": "/v1/messages",
"query_string": b"",
"headers": [(b"content-type", b"application/json"), (b"x-litellm-call-id", _BLOCK_CALL_ID.encode())],
},
receive,
)
async def _blocked_messages_reply(
body: Mapping[str, object], fastapi_response: Response, rails: tuple[CustomGuardrail, ...]
) -> tuple[object, str]:
import litellm
import litellm.proxy.anthropic_endpoints.endpoints as ep
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy.utils import ProxyLogging
blocker: Final = _BlockEveryRequest(
guardrail_name="deny-list", event_hook=GuardrailEventHooks.pre_call, default_on=True
)
with (
patch.object(
proxy_server, "proxy_logging_obj", new=ProxyLogging(user_api_key_cache=DualCache())
), # test-quality-ok: module global imported at call time; no injection seam
patch.object(litellm, "callbacks", new=[blocker, *rails]), # test-quality-ok: guardrails register through this global
):
response = await ep.anthropic_response(
fastapi_response=fastapi_response,
request=_messages_request(body),
user_api_key_dict=UserAPIKeyAuth(),
)
if not isinstance(response, StreamingResponse):
return response, ""
streamed: Final = b"".join(
[chunk if isinstance(chunk, bytes) else chunk.encode() async for chunk in response.body_iterator]
).decode()
return response, streamed
class TestBlockedResponseUsage:
"""Blocked responses report the blocked LLM response's real usage."""
def test_uses_original_response_usage(self):
from litellm.llms.base_llm.guardrail_translation.utils import blocked_response_usage as _blocked_response_usage
# original_response is the AnthropicMessagesResponse the LLM produced
# before the guardrail blocked it; its usage is real.
original = {"usage": {"input_tokens": 31, "output_tokens": 9}}
assert _blocked_response_usage(original) == {
"input_tokens": 31,
"output_tokens": 9,
}
def test_zero_usage_when_no_original_response(self):
from litellm.llms.base_llm.guardrail_translation.utils import blocked_response_usage as _blocked_response_usage
# Pre-call blocks never invoked the LLM -> nothing consumed.
assert _blocked_response_usage(None) == {
"input_tokens": 0,
"output_tokens": 0,
}
@pytest.mark.asyncio
async def test_blocked_endpoint_response_carries_original_usage(self):
"""The /v1/messages block handler reports the blocked response's real
usage, carried on ModifyResponseException.original_response."""
from unittest.mock import AsyncMock, MagicMock
import litellm.proxy.anthropic_endpoints.endpoints as ep
import litellm.proxy.proxy_server as proxy_server
from litellm.integrations.custom_guardrail import ModifyResponseException
exc = ModifyResponseException(
message="blocked by guardrail",
model="claude-3-5-sonnet-20240620",
request_data={"messages": [{"role": "user", "content": "hi"}]},
guardrail_name="rubrik",
original_response={"usage": {"input_tokens": 12, "output_tokens": 5}},
)
with (
patch.object(ep, "read_request_body", new=AsyncMock(return_value={})),
patch.object(
ep.ProxyBaseLLMRequestProcessing,
"base_process_llm_request",
new=AsyncMock(side_effect=exc),
),
patch.object(proxy_server, "proxy_logging_obj") as mock_logging,
):
mock_logging.post_call_failure_hook = AsyncMock()
response = await ep.anthropic_response(
fastapi_response=MagicMock(),
request=MagicMock(),
user_api_key_dict=MagicMock(),
)
assert response["content"][0]["text"] == "blocked by guardrail"
assert response["usage"] == {"input_tokens": 12, "output_tokens": 5}
mock_logging.post_call_failure_hook.assert_awaited_once()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"rails",
((), (_RewriteEveryStreamedReply(guardrail_name="post-call-rewrite", default_on=True),)),
ids=("no_rails", "post_call_rail"),
)
async def test_streaming_block_answers_anthropic_events_with_the_call_id(
self, rails: tuple[CustomGuardrail, ...]
) -> None:
response, streamed = await _blocked_messages_reply(
{"model": "claude-haiku-5-5", "stream": True}, Response(), rails
)
frames: Final = tuple(frame for frame in streamed.split("\n\n") if frame.strip())
events: Final = tuple(
(frame.split("\n")[0].removeprefix("event: "), json.loads(frame.split("\n")[1].removeprefix("data: ")))
for frame in frames
)
assert isinstance(response, StreamingResponse)
assert response.media_type == "text/event-stream"
assert response.headers["x-litellm-call-id"] == _BLOCK_CALL_ID
assert [name for name, _ in events] == [
"message_start",
"content_block_start",
"content_block_delta",
"content_block_stop",
"message_delta",
"message_stop",
]
assert all(payload["type"] == name for name, payload in events)
assert events[2][1]["delta"] == {"type": "text_delta", "text": _BLOCK_TEXT}
assert events[4][1]["delta"]["stop_reason"] == "end_turn"
@pytest.mark.asyncio
async def test_json_block_answers_with_the_call_id(self) -> None:
fastapi_response: Final = Response()
response, _ = await _blocked_messages_reply({"model": "claude-haiku-5-5"}, fastapi_response, ())
assert fastapi_response.headers["x-litellm-call-id"] == _BLOCK_CALL_ID
assert isinstance(response, Mapping)
assert response["content"] == [{"type": "text", "text": _BLOCK_TEXT}]
class TestProxyExceptionAnthropicEnvelope:
@pytest.mark.asyncio
async def test_anthropic_response_maps_proxy_exception_to_anthropic_envelope(self):
"""LIT-6468: a 400 ProxyException from request validation must surface as
Anthropic's documented {"type": "error", "error": {...}} envelope with the
original status and message, not the OpenAI {"error": {...}} envelope."""
import litellm.proxy.anthropic_endpoints.endpoints as ep
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy._types import ProxyErrorTypes, ProxyException
exc = ProxyException(
message="Invalid type for 'metadata': expected an object, but got a string instead.",
type=ProxyErrorTypes.bad_request_error,
param="metadata",
code=400,
)
request = MagicMock()
request.headers = {"x-request-id": "req_test_6468"}
with (
patch.object(ep, "read_request_body", new=AsyncMock(return_value={})),
patch.object(
ep.ProxyBaseLLMRequestProcessing,
"base_process_llm_request",
new=AsyncMock(side_effect=exc),
),
patch.object(proxy_server, "proxy_logging_obj") as mock_logging,
):
mock_logging.post_call_failure_hook = AsyncMock()
response = await ep.anthropic_response(
fastapi_response=MagicMock(),
request=request,
user_api_key_dict=MagicMock(),
)
assert response.status_code == 400
body = json.loads(response.body)
assert body == {
"type": "error",
"error": {
"type": "invalid_request_error",
"message": "Invalid type for 'metadata': expected an object, but got a string instead.",
},
"request_id": "req_test_6468",
}
mock_logging.post_call_failure_hook.assert_awaited_once()
@pytest.mark.asyncio
async def test_anthropic_response_maps_429_to_rate_limit_error(self):
"""The Anthropic error type follows the status code (429 -> rate_limit_error),
and a code-less exception falls back to 500 api_error."""
import litellm.proxy.anthropic_endpoints.endpoints as ep
from litellm.proxy._types import ProxyException
request = MagicMock()
request.headers = {}
response = ep._anthropic_error_json_response(
ProxyException(message="Rate limit exceeded", type="rate_limit_error", param=None, code=429),
request,
)
assert response.status_code == 429
assert json.loads(response.body)["error"]["type"] == "rate_limit_error"
fallback = ep._anthropic_error_json_response(
ProxyException(message="boom", type="None", param=None, code=None),
request,
)
assert fallback.status_code == 500
assert json.loads(fallback.body)["error"]["type"] == "api_error"
@staticmethod
def _call_id_error_response(general_settings, provider_specific_fields=None):
import litellm.proxy.anthropic_endpoints.endpoints as ep
from litellm.proxy._types import ProxyException
request = MagicMock()
request.headers = {}
exc = ProxyException(
message="Rate limit exceeded",
type="rate_limit_error",
param=None,
code=429,
headers={"x-litellm-call-id": "call-8302"},
provider_specific_fields=provider_specific_fields,
)
with patch("litellm.proxy.proxy_server.general_settings", general_settings):
return ep._anthropic_error_json_response(exc, request)
def test_anthropic_error_copies_the_call_id_into_the_error_when_opted_in(self):
"""With include_call_id_in_error_body on, error.litellm_call_id is byte-identical to
the x-litellm-call-id header and lives inside the error object, which is what the
Anthropic SDK keeps as e.body."""
response = self._call_id_error_response({"include_call_id_in_error_body": True})
assert response.headers["x-litellm-call-id"] == "call-8302"
assert json.loads(response.body) == {
"type": "error",
"error": {
"type": "rate_limit_error",
"message": "Rate limit exceeded",
"litellm_call_id": "call-8302",
},
}
def test_anthropic_error_keeps_provider_specific_fields_next_to_the_call_id(self):
response = self._call_id_error_response(
{"include_call_id_in_error_body": True},
provider_specific_fields={"guardrail": "keyword-block"},
)
assert json.loads(response.body)["error"] == {
"type": "rate_limit_error",
"message": "Rate limit exceeded",
"provider_specific_fields": {"guardrail": "keyword-block"},
"litellm_call_id": "call-8302",
}
def test_anthropic_error_leaves_the_envelope_alone_when_opted_out(self):
response = self._call_id_error_response({})
assert response.headers["x-litellm-call-id"] == "call-8302"
assert json.loads(response.body) == {
"type": "error",
"error": {"type": "rate_limit_error", "message": "Rate limit exceeded"},
}
class TestHttpExceptionDictDetail:
@pytest.mark.asyncio
async def test_anthropic_response_serializes_dict_detail_http_exception(self):
"""LIT-6466 + LIT-6468: a post_call guardrail's HTTPException(detail=<dict>)
must surface as Anthropic's {"type": "error", "error": {...}} envelope with
the guardrail's clean message plus provider_specific_fields, not the str()
of the exception and not the OpenAI envelope."""
from fastapi import HTTPException
import litellm.proxy.anthropic_endpoints.endpoints as ep
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy._types import UserAPIKeyAuth
detail = {
"error": "Content blocked: keyword 'kumquat' detected",
"keyword": "kumquat",
"guardrail": "keyword-block",
}
exc = HTTPException(status_code=400, detail=detail)
request = MagicMock()
request.headers = {}
with (
patch.object(
ep, "read_request_body", new=AsyncMock(return_value={})
), # test-quality-ok: endpoint reads the body via a module function; no injection seam
patch.object( # test-quality-ok: the guardrail raise happens deep inside this call; the test targets the endpoint's except block
ep.ProxyBaseLLMRequestProcessing,
"base_process_llm_request",
new=AsyncMock(side_effect=exc),
),
patch.object(
proxy_server, "proxy_logging_obj"
) as mock_logging, # test-quality-ok: module global imported at call time; no injection seam
):
mock_logging.post_call_failure_hook = AsyncMock()
response = await ep.anthropic_response(
fastapi_response=MagicMock(),
request=request,
user_api_key_dict=UserAPIKeyAuth(),
)
assert response.status_code == 400
body = json.loads(response.body)
assert body["type"] == "error"
assert body["error"]["type"] == "invalid_request_error"
assert body["error"]["message"] == "Content blocked: keyword 'kumquat' detected"
assert "{'error'" not in body["error"]["message"]
assert body["error"]["provider_specific_fields"] == detail
mock_logging.post_call_failure_hook.assert_awaited_once()
class TestFailureHookRequestData:
@pytest.mark.asyncio
async def test_failure_hook_gets_post_setup_data_with_logging_obj(self):
"""Request setup replaces the processor's data dict (adding the logging
object the failure hook needs to lift token usage from); the exception
handler must pass that replaced dict, not the raw request body dict."""
import litellm.proxy.anthropic_endpoints.endpoints as ep
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy._types import UserAPIKeyAuth
captured = {}
async def fake_process(self, **kwargs):
self.data = {**self.data, "litellm_logging_obj": "logging-obj-sentinel"}
captured["processor_data"] = self.data
raise RuntimeError("provider timeout")
request = MagicMock()
request.headers = {}
with (
patch.object(ep, "read_request_body", new=AsyncMock(return_value={"model": "claude-sonnet"})),
patch.object(ep.ProxyBaseLLMRequestProcessing, "base_process_llm_request", new=fake_process),
patch.object(proxy_server, "proxy_logging_obj") as mock_logging,
):
mock_logging.post_call_failure_hook = AsyncMock()
response = await ep.anthropic_response(
fastapi_response=MagicMock(),
request=request,
user_api_key_dict=UserAPIKeyAuth(),
)
assert response.status_code == 500
assert json.loads(response.body)["error"]["message"] == "provider timeout"
hook_request_data = mock_logging.post_call_failure_hook.await_args.kwargs["request_data"]
assert hook_request_data is captured["processor_data"]
assert hook_request_data["litellm_logging_obj"] == "logging-obj-sentinel"
class TestErrorLogCarriesCallId:
"""LIT-7836: the /v1/messages and /v1/messages/count_tokens error lines must carry
the request's litellm_call_id, rendered in the message and as a structured field."""
@pytest.fixture(autouse=True)
def propagating_proxy_logger(self):
verbose_proxy_logger.propagate = True
try:
yield
finally:
verbose_proxy_logger.propagate = False
@staticmethod
def _error_record(caplog: pytest.LogCaptureFixture) -> logging.LogRecord:
return next(r for r in caplog.records if "Exception occured" in r.getMessage())
@pytest.mark.asyncio
async def test_messages_failure_log_carries_call_id(self, caplog: pytest.LogCaptureFixture):
import litellm.proxy.anthropic_endpoints.endpoints as ep
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy._types import UserAPIKeyAuth
call_id = "messages-call-7836"
async def fake_process(self, **kwargs):
self.data = {**self.data, "litellm_call_id": call_id}
raise RuntimeError("provider timeout")
request = MagicMock()
request.headers = {}
with (
patch.object(
ep, "read_request_body", new=AsyncMock(return_value={"model": "claude-sonnet"})
), # test-quality-ok: endpoint reads the body via a module function; no injection seam
patch.object(
ep.ProxyBaseLLMRequestProcessing, "base_process_llm_request", new=fake_process
), # test-quality-ok: the provider failure happens inside this call; the test targets the endpoint's except block
patch.object(
proxy_server, "proxy_logging_obj"
) as mock_logging, # test-quality-ok: module global imported at call time; no injection seam
caplog.at_level(logging.ERROR, logger="LiteLLM Proxy"),
):
mock_logging.post_call_failure_hook = AsyncMock()
response = await ep.anthropic_response(
fastapi_response=MagicMock(),
request=request,
user_api_key_dict=UserAPIKeyAuth(),
)
assert response.status_code == 500
record = self._error_record(caplog)
assert record.litellm_call_id == call_id
assert call_id in record.getMessage()
@pytest.mark.asyncio
async def test_messages_already_shaped_failure_answers_with_the_call_id(self):
import litellm.proxy.anthropic_endpoints.endpoints as ep
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy._types import ProxyErrorTypes, ProxyException, UserAPIKeyAuth
call_id = "messages-call-7836-shaped"
async def fake_process(self, **kwargs):
self.data = {**self.data, "litellm_call_id": call_id}
raise ProxyException(message="budget exceeded", type=ProxyErrorTypes.budget_exceeded, param="key", code=402)
request = MagicMock()
request.headers = {}
with (
patch.object(
ep, "read_request_body", new=AsyncMock(return_value={"model": "claude-sonnet"})
), # test-quality-ok: endpoint reads the body via a module function; no injection seam
patch.object(
ep.ProxyBaseLLMRequestProcessing, "base_process_llm_request", new=fake_process
), # test-quality-ok: the proxy shaped failure happens inside this call; the test targets the endpoint's except block
patch.object(
proxy_server, "proxy_logging_obj"
) as mock_logging, # test-quality-ok: module global imported at call time; no injection seam
):
mock_logging.post_call_failure_hook = AsyncMock()
response = await ep.anthropic_response(
fastapi_response=MagicMock(),
request=request,
user_api_key_dict=UserAPIKeyAuth(),
)
assert response.status_code == 402
assert response.headers["x-litellm-call-id"] == call_id
@pytest.mark.asyncio
async def test_count_tokens_failure_log_carries_callers_call_id(self, caplog: pytest.LogCaptureFixture):
import litellm.proxy.anthropic_endpoints.endpoints as ep
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy._types import UserAPIKeyAuth
call_id = "count-tokens-call-7836"
request = MagicMock()
request.headers = {"x-litellm-call-id": call_id}
with (
patch.object( # test-quality-ok: endpoint reads the body via a module function; no injection seam
ep,
"read_request_body",
new=AsyncMock(return_value={"model": "claude-sonnet", "messages": [{"role": "user", "content": "hi"}]}),
),
patch.object(
proxy_server, "count_request_tokens", new=AsyncMock(side_effect=RuntimeError("tokenizer down"))
), # test-quality-ok: module global imported at call time; the test targets the endpoint's except block
caplog.at_level(logging.ERROR, logger="LiteLLM Proxy"),
):
response = await ep.count_tokens(request=request, user_api_key_dict=UserAPIKeyAuth())
assert response.status_code == 500
record = self._error_record(caplog)
assert record.litellm_call_id == call_id
assert call_id in record.getMessage()
class TestCountTokensCompactedHistory:
"""A history carrying Anthropic's signed compaction block used to fail /v1/messages/count_tokens once the count
ran on the local tokenizer, which rejected the block type."""
@pytest.mark.asyncio
async def test_count_tokens_counts_a_compaction_block_like_its_summary_text(self):
import litellm.proxy.anthropic_endpoints.endpoints as ep
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy._types import UserAPIKeyAuth
summary = "The user is building a recipe app and asked for one-sentence class descriptions."
follow_up = {"role": "user", "content": "Now do the same for Ingredient."}
compacted = {
"model": "claude-sonnet-5-5",
"messages": [
{
"role": "assistant",
"content": [{"type": "compaction", "content": summary, "signature": "EqQBCkYIBRgCKkBjZ2xhc3M" * 40}],
},
follow_up,
],
}
as_text = {
"model": "claude-sonnet-5-5",
"messages": [{"role": "assistant", "content": [{"type": "text", "text": summary}]}, follow_up],
}
request = MagicMock()
request.headers = {}
async def count(body):
with (
patch.object(ep, "read_request_body", new=AsyncMock(return_value=body)), # test-quality-ok: endpoint reads the body via a module function; no injection seam
patch.object(proxy_server, "llm_router", None), # test-quality-ok: module global; with no router the count runs on the local tokenizer, the path that raised
):
return await ep.count_tokens(request=request, user_api_key_dict=UserAPIKeyAuth())
counted = await count(compacted)
assert counted["input_tokens"] > 0
assert counted == await count(as_text)
def _claude_router() -> Router:
return Router(
model_list=[
{
"model_name": "claude-sonnet",
"litellm_params": {"model": "anthropic/claude-sonnet-5-5", "api_key": "sk-test"},
}
]
)
_MALFORMED_TOOL_ENTRY: Final = [{"name": "get_weather", "input_schema": "object"}]
_SEVEN_TOKENS: Final = TokenCountResponse(
total_tokens=7, request_model="claude-sonnet", model_used="claude-sonnet", tokenizer_type="test"
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("model", "tools"),
[
("claude-sonnet", 5),
("claude-sonnet", ""),
("claude-sonnet", ["get_weather"]),
("claude-sonnet", _MALFORMED_TOOL_ENTRY),
("team-public-name", 5),
],
ids=["int", "empty_string", "list_of_strings", "input_schema_string", "unnamed_model_int"],
)
async def test_count_tokens_answers_400_for_malformed_tools_without_counting(model: str, tools: object) -> None:
import litellm.proxy.anthropic_endpoints.endpoints as ep
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy._types import UserAPIKeyAuth
request: Final = MagicMock()
request.headers = {}
counter: Final = AsyncMock()
body: Final = {"model": model, "messages": [{"role": "user", "content": "hi"}], "tools": tools}
with (
patch.object(ep, "read_request_body", new=AsyncMock(return_value=body)), # test-quality-ok: endpoint reads the body via a module function; no injection seam
patch.object(proxy_server, "count_request_tokens", new=counter), # test-quality-ok: module global imported at call time; the test asserts it is never reached
patch.object(proxy_server, "llm_router", new=_claude_router()), # test-quality-ok: module global imported at call time; no injection seam on this route
):
response: Final = await ep.count_tokens(request=request, user_api_key_dict=UserAPIKeyAuth())
assert response.status_code == 400
assert json.loads(response.body)["error"]["type"] == "invalid_request_error"
counter.assert_not_awaited()
@pytest.mark.asyncio
async def test_count_tokens_counts_malformed_tool_entries_for_a_model_the_router_cannot_name() -> None:
import litellm.proxy.anthropic_endpoints.endpoints as ep
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy._types import UserAPIKeyAuth
request: Final = MagicMock()
request.headers = {}
counter: Final = AsyncMock(return_value=_SEVEN_TOKENS)
body: Final = {"model": "team-public-name", "messages": [{"role": "user", "content": "hi"}], "tools": _MALFORMED_TOOL_ENTRY}
with (
patch.object(ep, "read_request_body", new=AsyncMock(return_value=body)), # test-quality-ok: endpoint reads the body via a module function; no injection seam
patch.object(proxy_server, "count_request_tokens", new=counter), # test-quality-ok: module global imported at call time; the test asserts what it receives
patch.object(proxy_server, "llm_router", new=_claude_router()), # test-quality-ok: module global imported at call time; no injection seam on this route
):
response: Final = await ep.count_tokens(request=request, user_api_key_dict=UserAPIKeyAuth())
assert response == {"input_tokens": 7}
assert counter.await_args.kwargs["request"].tools == _MALFORMED_TOOL_ENTRY
@pytest.mark.asyncio
@pytest.mark.parametrize(
("dropping", "headers"),
[
({"drop_params": True}, {}),
({"additional_drop_params": ["tools"]}, {}),
({}, {"user-agent": "claude-cli/2.1.0 (external, cli)"}),
],
ids=["drop_params", "additional_drop_params", "claude_code_user_agent"],
)
async def test_count_tokens_drops_malformed_tools_when_drop_params_is_set(
dropping: dict[str, object], headers: dict[str, str]
) -> None:
import litellm.proxy.anthropic_endpoints.endpoints as ep
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy._types import UserAPIKeyAuth
request: Final = MagicMock()
request.headers = headers
counter: Final = AsyncMock(return_value=_SEVEN_TOKENS)
messages: Final = [{"role": "user", "content": "hi"}]
body: Final = {"model": "claude-sonnet", "messages": messages, "tools": 5, **dropping}
with (
patch.object(ep, "read_request_body", new=AsyncMock(return_value=body)), # test-quality-ok: endpoint reads the body via a module function; no injection seam
patch.object(proxy_server, "count_request_tokens", new=counter), # test-quality-ok: module global imported at call time; the test asserts what it receives
):
response: Final = await ep.count_tokens(request=request, user_api_key_dict=UserAPIKeyAuth())
assert response == {"input_tokens": 7}
counted: Final = counter.await_args.kwargs["request"]
assert counted.tools is None
assert counted.messages == messages
@pytest.mark.asyncio
async def test_count_tokens_keeps_claude_code_malformed_tools_when_operator_sets_drop_params_false() -> None:
import litellm.proxy.anthropic_endpoints.endpoints as ep
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy._types import UserAPIKeyAuth
operator_config: Final = proxy_server.ProxyConfig()
operator_config.update_config_state(config={"litellm_settings": {"drop_params": False}})
request: Final = MagicMock()
request.headers = {"user-agent": "claude-cli/2.1.0 (external, cli)"}
counter: Final = AsyncMock(return_value=_SEVEN_TOKENS)
body: Final = {"model": "claude-sonnet", "messages": [{"role": "user", "content": "hi"}], "tools": 5}
with (
patch.object(ep, "read_request_body", new=AsyncMock(return_value=body)), # test-quality-ok: endpoint reads the body via a module function; no injection seam
patch.object(proxy_server, "count_request_tokens", new=counter), # test-quality-ok: module global imported at call time; the test asserts it is never reached
patch.object(proxy_server, "llm_router", new=_claude_router()), # test-quality-ok: module global imported at call time; no injection seam on this route
patch.object(proxy_server, "proxy_config", new=operator_config), # test-quality-ok: module global imported at call time; no injection seam on this route
):
response: Final = await ep.count_tokens(request=request, user_api_key_dict=UserAPIKeyAuth())
assert response.status_code == 400
assert json.loads(response.body)["error"]["type"] == "invalid_request_error"
counter.assert_not_awaited()
@pytest.mark.asyncio
async def test_count_tokens_drops_malformed_tools_the_counted_deployment_drops_even_with_fallbacks() -> None:
import litellm.proxy.anthropic_endpoints.endpoints as ep
import litellm.proxy.proxy_server as proxy_server
from litellm import Router
from litellm.proxy._types import UserAPIKeyAuth
router: Final = Router(
model_list=[
{
"model_name": "claude-sonnet",
"litellm_params": {
"model": "anthropic/claude-sonnet-5-5",
"api_key": "sk-test",
"additional_drop_params": ["tools"],
},
},
{"model_name": "backup", "litellm_params": {"model": "anthropic/claude-haiku-5-5", "api_key": "sk-test"}},
],
fallbacks=[{"claude-sonnet": ["backup"]}],
)
request: Final = MagicMock()
request.headers = {}
counter: Final = AsyncMock(return_value=_SEVEN_TOKENS)
body: Final = {"model": "claude-sonnet", "messages": [{"role": "user", "content": "hi"}], "tools": 5}
with (
patch.object(ep, "read_request_body", new=AsyncMock(return_value=body)), # test-quality-ok: endpoint reads the body via a module function; no injection seam
patch.object(proxy_server, "count_request_tokens", new=counter), # test-quality-ok: module global imported at call time; the test asserts what it receives
patch.object(proxy_server, "llm_router", new=router), # test-quality-ok: module global imported at call time; no injection seam on this route
):
response: Final = await ep.count_tokens(request=request, user_api_key_dict=UserAPIKeyAuth())
assert response == {"input_tokens": 7}
assert counter.await_args.kwargs["request"].tools is None
def _count_on_claude(
monkeypatch: pytest.MonkeyPatch,
peer: Callable[[httpx.Request], httpx.Response],
headers: Mapping[str, str],
extra_body: Mapping[str, object] = MappingProxyType({}),
) -> httpx.Response:
from fastapi import FastAPI
import litellm.proxy.proxy_server as proxy_server
from litellm.llms.anthropic.count_tokens import handler as anthropic_count_tokens_handler
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.anthropic_endpoints.endpoints import router
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
claude: Final = {"model": "anthropic/claude-zeta-9", "api_key": "test-key", "api_base": "https://count-tokens.test"}
monkeypatch.setattr(
proxy_server,
"llm_router",
Router(
model_list=[
{
"model_name": "count-log-claude",
"litellm_params": {**claude, "order": 2},
"model_info": {"id": "count-log-fallback-deployment"},
},
{
"model_name": "count-log-claude",
"litellm_params": {**claude, "order": 1},
"model_info": {"id": "count-log-routed-deployment"},
},
]
),
)
monkeypatch.setattr(
anthropic_count_tokens_handler,
"get_async_httpx_client",
lambda *args, **kwargs: AsyncHTTPHandler(transport=httpx.MockTransport(peer)),
)
app: Final = FastAPI()
app.include_router(router)
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth()
return TestClient(app).post(
"/v1/messages/count_tokens",
json={"model": "count-log-claude", "messages": [{"role": "user", "content": "Hello"}], **extra_body},
headers=dict(headers),
)
def test_count_tokens_logs_one_line_with_the_callers_call_id_model_and_count(
monkeypatch: pytest.MonkeyPatch, token_count_log_lines: Callable[[], tuple[dict[str, object], ...]]
) -> None:
response: Final = _count_on_claude(
monkeypatch,
lambda request: httpx.Response(200, json={"input_tokens": 77}, request=request),
{"x-litellm-call-id": "count-log-msg-claude"},
)
assert response.json() == {"input_tokens": 77, "litellm_count_source": "provider"}, response.text
assert token_count_log_lines() == (
{
"litellm_call_id": "count-log-msg-claude",
"request_model": "count-log-claude",
"model_id": "count-log-routed-deployment",
"model_used": "claude-zeta-9",
"input_tokens": 77,
"count_source": "provider",
"status_code": 200,
},
)
def test_count_tokens_returns_the_context_management_the_provider_applied(monkeypatch: pytest.MonkeyPatch) -> None:
applied: Final = {"original_input_tokens": 640}
response: Final = _count_on_claude(
monkeypatch,
lambda request: httpx.Response(
200, json={"input_tokens": 576, "context_management": applied}, request=request
),
{},
{"context_management": {"edits": [{"type": "clear_tool_uses_20250919"}]}},
)
assert response.json() == {
"input_tokens": 576,
"context_management": applied,
"litellm_count_source": "provider",
}, response.text
def test_count_tokens_provider_rejection_logs_its_status_and_answers_the_same_error(
monkeypatch: pytest.MonkeyPatch, token_count_log_lines: Callable[[], tuple[dict[str, object], ...]]
) -> None:
rejection: Final = {
"type": "error",
"error": {"type": "invalid_request_error", "message": "messages.0.content.0: invalid base64 data"},
}
response: Final = _count_on_claude(
monkeypatch,
lambda request: httpx.Response(400, json=rejection, request=request),
{"x-litellm-call-id": "count-log-msg-error"},
)
assert response.status_code == 400, response.text
assert response.json()["error"] == rejection["error"]
assert token_count_log_lines() == (
{
"litellm_call_id": "count-log-msg-error",
"request_model": "count-log-claude",
"model_id": "count-log-routed-deployment",
"model_used": None,
"input_tokens": None,
"count_source": None,
"status_code": 400,
},
)
def _count_on_local_model(monkeypatch: pytest.MonkeyPatch, body: Mapping[str, object]) -> httpx.Response:
from fastapi import FastAPI
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.anthropic_endpoints.endpoints import router
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
monkeypatch.setattr(
proxy_server,
"llm_router",
Router(
model_list=[
{
"model_name": "count-log-local",
"litellm_params": {"model": "hosted_vllm/count-log-local", "api_base": "https://local.test/v1"},
"model_info": {"id": "count-log-local-deployment"},
}
]
),
)
app: Final = FastAPI()
app.include_router(router)
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth()
return TestClient(app).post(
"/v1/messages/count_tokens", json=dict(body), headers={"x-litellm-call-id": "count-log-local"}
)
def test_count_tokens_unsupported_content_logs_the_400_it_answers(
monkeypatch: pytest.MonkeyPatch, token_count_log_lines: Callable[[], tuple[dict[str, object], ...]]
) -> None:
response: Final = _count_on_local_model(
monkeypatch,
{"model": "count-log-local", "messages": [{"role": "user", "content": [{"type": "future_block"}]}]},
)
assert (response.status_code, response.json()["error"]["type"]) == (400, "invalid_request_error"), response.text
assert token_count_log_lines() == (
{
"litellm_call_id": "count-log-local",
"request_model": "count-log-local",
"model_id": "count-log-local-deployment",
"model_used": None,
"input_tokens": None,
"count_source": None,
"status_code": 400,
},
)
@pytest.mark.parametrize(
("body", "request_model"),
[
({"messages": [{"role": "user", "content": "Hello"}]}, None),
({"model": "count-log-local", "messages": []}, "count-log-local"),
],
)
def test_count_tokens_rejected_before_counting_logs_the_400_it_answers(
monkeypatch: pytest.MonkeyPatch,
token_count_log_lines: Callable[[], tuple[dict[str, object], ...]],
body: Mapping[str, object],
request_model: str | None,
) -> None:
response: Final = _count_on_local_model(monkeypatch, body)
assert response.status_code == 400, response.text
assert token_count_log_lines() == (
{
"litellm_call_id": "count-log-local",
"request_model": request_model,
"model_id": None,
"model_used": None,
"input_tokens": None,
"count_source": None,
"status_code": 400,
},
)
class TestEventLoggingBatchEndpoint:
"""Test the stubbed event logging batch endpoint"""
def test_event_logging_batch_endpoint_exists(self):
"""Test that the event_logging_batch endpoint exists and returns 200"""
from fastapi import FastAPI
from litellm.proxy.anthropic_endpoints.endpoints import router
app = FastAPI()
app.include_router(router)
client = TestClient(app)
response = client.post("/api/event_logging/batch", json={"events": []})
assert response.status_code == 200
assert response.json() == {"status": "ok"}
class TestStripTotalTokens(unittest.TestCase):
"""Cover ``_strip_total_tokens_from_anthropic_response``.
The Anthropic /v1/messages spec does not define ``usage.total_tokens``.
LiteLLM injects it internally; the helper must remove it from the wire
response so the non-streaming path matches the streaming SSE shape and
direct Anthropic API responses.
"""
def test_strips_total_tokens_when_present(self):
from litellm.proxy.anthropic_endpoints.endpoints import (
_strip_total_tokens_from_anthropic_response,
)
response = {
"id": "msg_123",
"usage": {
"input_tokens": 100,
"output_tokens": 50,
"total_tokens": 150,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0,
},
}
_strip_total_tokens_from_anthropic_response(response)
assert "total_tokens" not in response["usage"]
assert response["usage"]["input_tokens"] == 100
assert response["usage"]["output_tokens"] == 50
assert response["usage"]["cache_read_input_tokens"] == 0
def test_no_op_when_total_tokens_absent(self):
from litellm.proxy.anthropic_endpoints.endpoints import (
_strip_total_tokens_from_anthropic_response,
)
response = {"usage": {"input_tokens": 100, "output_tokens": 50}}
_strip_total_tokens_from_anthropic_response(response)
assert response["usage"] == {"input_tokens": 100, "output_tokens": 50}
def test_no_op_when_usage_missing(self):
from litellm.proxy.anthropic_endpoints.endpoints import (
_strip_total_tokens_from_anthropic_response,
)
response = {"id": "msg_123"}
_strip_total_tokens_from_anthropic_response(response)
assert response == {"id": "msg_123"}
def test_no_op_on_non_dict_response(self):
from litellm.proxy.anthropic_endpoints.endpoints import (
_strip_total_tokens_from_anthropic_response,
)
# Streaming responses (StreamingResponse, async iterators) are not dicts.
# The helper must not raise or attempt to mutate them.
for value in (None, "stream", 42, [{"usage": {"total_tokens": 1}}]):
_strip_total_tokens_from_anthropic_response(value) # no raise
def test_strips_total_tokens_on_pydantic_model_with_dict_usage(self):
"""Greptile P1 on #30382: helper must not silently no-op when the
response is a Pydantic-shaped object whose `usage` attribute is a
plain dict (the common case for objects wrapping raw upstream JSON).
"""
from types import SimpleNamespace
from litellm.proxy.anthropic_endpoints.endpoints import (
_strip_total_tokens_from_anthropic_response,
)
# SimpleNamespace mimics the .usage attribute access pattern; the
# helper's contract: if .usage is dict-shaped, strip total_tokens.
response = SimpleNamespace(usage={"input_tokens": 100, "output_tokens": 50, "total_tokens": 150})
_strip_total_tokens_from_anthropic_response(response)
assert "total_tokens" not in response.usage
assert response.usage == {"input_tokens": 100, "output_tokens": 50}
class TestStripTotalTokensFeatureFlag(unittest.TestCase):
"""The strip is gated behind `litellm.strip_anthropic_total_tokens`.
Default off (backward compat). Greptile P1 on #30382 required a
user-controlled flag so existing clients reading the LiteLLM-shaped
`usage.total_tokens` continue to work after this PR lands.
"""
def test_flag_defaults_off(self):
import litellm
assert litellm.strip_anthropic_total_tokens is False