litellm/tests/unit/test_count_tokens_public_api.py
Mateo Wang 91724bb0a7
fix(responses)!: count input tokens the way inference sends the request (#47363)
* fix(count-tokens): translate chat tool use before Claude token counts

Responses and chat tool use (function tools, function_call items, tool messages) is now converted the way Claude chat inference converts it before the Anthropic, Azure AI and Vertex partner counters call count_tokens. Translation errors answer 400 before any provider call. Anthropic-shaped requests and /v1/messages/count_tokens keep their body as sent.

* test(count-tokens): compare Claude count bodies with inference for Responses tool use

* fix(responses): forward the caller's Responses body to OpenAI token counts

OpenAI deployments now get the caller's /v1/responses/input_tokens body as sent, minus LiteLLM-only fields, so OpenAI's own count and errors come back. previous_response_id is checked for ownership like inference and routes to the deployment that served it

* fix(responses)!: count Claude Responses bodies the way inference sends them

Non-native Responses counts now run the body through the inference bridge, so
text.format, tool_choice, reasoning, built-in tools, and file_search count the
way inference sends them on Anthropic, Azure AI, Vertex, and Bedrock. The
OpenAI native count body uses the drop_params-resolved tool fields.

* fix(responses): honor drop_params and replay previous turns in Claude counts

Claude input_tokens counts now read the same drop_params setting inference merges from the deployment, router defaults, and request, so a forced tool_choice downgrades to auto under drop_params. previous_response_id replays the stored turns through inference's session loader, and chat messages carrying fields beyond role and content go through the provider's chat translation

* fix(responses): keep proxy-only request keys out of OpenAI counts and map chat tools in Bedrock counts

* fix(count-tokens): import on Python 3.11 and keep stored prompts in OpenAI counts under drop_params

* fix(responses): leave tool_choice out of counts when drop_params drops every tool

* fix(count-tokens): count Mantle options, drop cache marks, and reject input inference cannot send

The Bedrock Mantle fallback now counts thinking, tool_choice and output_config
like the runtime count. Claude Responses counts leave out cache_control marks
again, so more than four marks count instead of failing. Counts answer 400
before any provider call for user blocks chat inference refuses and for
Responses tools with no Chat Completions equivalent, instead of dropping the
block or counting a placeholder tool. Adds the wire tests for these paths

* fix(count-tokens): count Responses tools the way the inference bridge sends them

A Claude count now hoists additional_tools input items into tools, strips a malformed tool flag under drop_params and rejects it without, and leaves tools out when the deployment lists tools in additional_drop_params, so the count matches the body Claude inference sends for the same request

* fix(count-tokens): keep Bedrock runtime counts option-free and send OpenAI the provider file id

* test(count-tokens): capture the Anthropic count request at the HTTP layer instead of patching the handler

* fix(count-tokens): ignore a caller-sent file id mapping and keep non-string file ids as sent
2026-10-20 15:13:59 +00:00

205 lines
7.2 KiB
Python

"""
Tests for litellm.acount_tokens() public API.
"""
import asyncio
import json
import os
from unittest.mock import AsyncMock, patch
import httpx
import pytest
import respx
import litellm
from litellm.types.utils import TokenCountResponse
def test_acount_tokens_routes_to_openai():
"""Test that acount_tokens routes to OpenAI token counter for openai/ models."""
with patch(
"litellm.llms.openai.responses.count_tokens.token_counter.openai_count_tokens_handler.handle_count_tokens_request",
new_callable=AsyncMock,
return_value={"input_tokens": 15},
):
result = asyncio.run(
litellm.acount_tokens(
model="openai/gpt-4o",
messages=[{"role": "user", "content": "Hello, how are you?"}],
api_key="sk-test-key",
)
)
assert result.total_tokens == 15
assert result.tokenizer_type == "openai_api"
assert result.request_model == "openai/gpt-4o"
def test_acount_tokens_routes_to_anthropic():
"""Test that acount_tokens routes to Anthropic token counter for anthropic/ models."""
with patch(
"litellm.llms.anthropic.count_tokens.token_counter.anthropic_count_tokens_handler.handle_count_tokens_request",
new_callable=AsyncMock,
return_value={"input_tokens": 20},
):
result = asyncio.run(
litellm.acount_tokens(
model="anthropic/claude-3-5-sonnet-20241022",
messages=[{"role": "user", "content": "Hello Claude!"}],
api_key="sk-ant-test-key",
)
)
assert result.total_tokens == 20
assert result.tokenizer_type == "anthropic_api"
assert result.request_model == "anthropic/claude-3-5-sonnet-20241022"
def test_acount_tokens_fallback_to_local():
"""Test that unsupported providers fall back to local tiktoken counting."""
result = asyncio.run(
litellm.acount_tokens(
model="together_ai/meta-llama/Llama-3-8b-chat-hf",
messages=[{"role": "user", "content": "Hello"}],
)
)
assert result.total_tokens > 0
assert result.tokenizer_type == "local_tokenizer"
def test_acount_tokens_with_tools():
"""Test that tools are passed through to the token counter."""
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather info",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
},
},
}
]
with patch(
"litellm.llms.openai.responses.count_tokens.token_counter.openai_count_tokens_handler.handle_count_tokens_request",
new_callable=AsyncMock,
return_value={"input_tokens": 30},
) as mock_handler:
result = asyncio.run(
litellm.acount_tokens(
model="openai/gpt-4o",
messages=[{"role": "user", "content": "What's the weather?"}],
tools=tools,
api_key="sk-test-key",
)
)
assert result.total_tokens == 30
mock_handler.assert_called_once()
call_kwargs = mock_handler.call_args
assert list(call_kwargs.kwargs.get("tools") or ()) == tools
def test_acount_tokens_with_system():
"""Test that system messages are passed through."""
with patch(
"litellm.llms.openai.responses.count_tokens.token_counter.openai_count_tokens_handler.handle_count_tokens_request",
new_callable=AsyncMock,
return_value={"input_tokens": 25},
):
result = asyncio.run(
litellm.acount_tokens(
model="openai/gpt-4o",
messages=[{"role": "user", "content": "Hello"}],
system="You are a helpful assistant.",
api_key="sk-test-key",
)
)
assert result.total_tokens == 25
def test_acount_tokens_api_error_falls_back():
"""Test that API errors in token counting return error response."""
from litellm.llms.openai.common_utils import OpenAIError
with patch(
"litellm.llms.openai.responses.count_tokens.token_counter.openai_count_tokens_handler.handle_count_tokens_request",
new_callable=AsyncMock,
side_effect=OpenAIError(status_code=401, message="Invalid API key"),
):
result = asyncio.run(
litellm.acount_tokens(
model="openai/gpt-4o",
messages=[{"role": "user", "content": "Hello"}],
api_key="sk-bad-key",
)
)
# Should fall back to local tokenizer when provider API errors
assert result.error is False
assert result.tokenizer_type == "local_tokenizer"
assert result.total_tokens > 0
def test_acount_tokens_no_api_key_falls_back(monkeypatch):
"""Test that missing API key falls back to local counting."""
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
result = asyncio.run(
litellm.acount_tokens(
model="openai/gpt-4o",
messages=[{"role": "user", "content": "Hello"}],
)
)
# Should fall back to local tokenizer since no API key
assert result.total_tokens > 0
assert result.tokenizer_type == "local_tokenizer"
@pytest.fixture
def httpx_transport_clients(monkeypatch):
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
for name in ("ANTHROPIC_API_BASE", "ANTHROPIC_BASE_URL"):
monkeypatch.delenv(name, raising=False)
litellm.in_memory_llm_clients_cache.flush_cache()
yield
litellm.in_memory_llm_clients_cache.flush_cache()
def test_acount_tokens_sends_chat_tool_use_to_anthropic_in_the_shape_claude_inference_sends(httpx_transport_clients):
schema = {"type": "object", "properties": {"city": {"type": "string"}}}
with respx.mock(assert_all_called=True) as router:
route = router.post("https://api.anthropic.com/v1/messages/count_tokens").mock(
return_value=httpx.Response(200, json={"input_tokens": 52})
)
result = asyncio.run(
litellm.acount_tokens(
model="anthropic/claude-opus-5-5",
messages=[
{"role": "user", "content": "Weather in SF?"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": "{}"}}
],
},
{"role": "tool", "tool_call_id": "call_1", "content": "sunny"},
],
tools=[{"type": "function", "function": {"name": "get_weather", "parameters": schema}}],
api_key="sk-ant-test-key",
)
)
assert (result.error, result.total_tokens) == (False, 52)
sent = json.loads(route.calls.last.request.content)
assert sent["tools"] == [{"name": "get_weather", "input_schema": schema, "type": "custom"}]
assert [message["role"] for message in sent["messages"]] == ["user", "assistant", "user"]
assert sent["messages"][2]["content"] == [{"type": "tool_result", "tool_use_id": "call_1", "content": "sunny"}]