Merge pull request #22301 from Chesars/fix/count-tokens-include-system-and-tools

fix(count_tokens): include system and tools in token counting API requests
This commit is contained in:
Cesar Garcia 2026-02-27 18:29:31 -03:00 committed by GitHub
commit 47a7645584
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 331 additions and 43 deletions

View file

@ -31,6 +31,8 @@ class AnthropicCountTokensHandler(AnthropicCountTokensConfig):
api_key: str,
api_base: Optional[str] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
tools: Optional[List[Dict[str, Any]]] = None,
system: Optional[Any] = None,
) -> Dict[str, Any]:
"""
Handle a CountTokens request using httpx.
@ -60,6 +62,8 @@ class AnthropicCountTokensHandler(AnthropicCountTokensConfig):
request_body = self.transform_request_to_count_tokens(
model=model,
messages=messages,
tools=tools,
system=system,
)
verbose_logger.debug(f"Transformed request: {request_body}")

View file

@ -30,6 +30,8 @@ class AnthropicTokenCounter(BaseTokenCounter):
contents: Optional[List[Dict[str, Any]]],
deployment: Optional[Dict[str, Any]] = None,
request_model: str = "",
tools: Optional[List[Dict[str, Any]]] = None,
system: Optional[Any] = None,
) -> Optional[TokenCountResponse]:
"""
Count tokens using Anthropic's CountTokens API.
@ -66,6 +68,8 @@ class AnthropicTokenCounter(BaseTokenCounter):
model=model_to_use,
messages=messages,
api_key=api_key,
tools=tools,
system=system,
)
if result is not None:

View file

@ -4,7 +4,7 @@ Anthropic CountTokens API transformation logic.
This module handles the transformation of requests to Anthropic's CountTokens API format.
"""
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
from litellm.constants import ANTHROPIC_TOKEN_COUNTING_BETA_VERSION
@ -32,27 +32,27 @@ class AnthropicCountTokensConfig:
self,
model: str,
messages: List[Dict[str, Any]],
tools: Optional[List[Dict[str, Any]]] = None,
system: Optional[Any] = None,
) -> Dict[str, Any]:
"""
Transform request to Anthropic CountTokens format.
Input:
{
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "Hello!"}]
}
Output (Anthropic CountTokens format):
{
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "Hello!"}]
}
Includes optional system and tools fields for accurate token counting.
"""
return {
request: Dict[str, Any] = {
"model": model,
"messages": messages,
}
if system is not None:
request["system"] = system
if tools is not None:
request["tools"] = tools
return request
def get_required_headers(self, api_key: str) -> Dict[str, str]:
"""
Get the required headers for the CountTokens API.

View file

@ -32,6 +32,8 @@ class AzureAIAnthropicCountTokensHandler(AzureAIAnthropicCountTokensConfig):
api_base: str,
litellm_params: Optional[Dict[str, Any]] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
tools: Optional[List[Dict[str, Any]]] = None,
system: Optional[Any] = None,
) -> Dict[str, Any]:
"""
Handle a CountTokens request using httpx with Azure authentication.
@ -62,6 +64,8 @@ class AzureAIAnthropicCountTokensHandler(AzureAIAnthropicCountTokensConfig):
request_body = self.transform_request_to_count_tokens(
model=model,
messages=messages,
tools=tools,
system=system,
)
verbose_logger.debug(f"Transformed request: {request_body}")

View file

@ -32,6 +32,8 @@ class AzureAIAnthropicTokenCounter(BaseTokenCounter):
contents: Optional[List[Dict[str, Any]]],
deployment: Optional[Dict[str, Any]] = None,
request_model: str = "",
tools: Optional[List[Dict[str, Any]]] = None,
system: Optional[Any] = None,
) -> Optional[TokenCountResponse]:
"""
Count tokens using Azure AI Anthropic's CountTokens API.
@ -79,6 +81,8 @@ class AzureAIAnthropicTokenCounter(BaseTokenCounter):
api_key=api_key,
api_base=api_base,
litellm_params=litellm_params,
tools=tools,
system=system,
)
if result is not None:

View file

@ -24,6 +24,8 @@ class BaseTokenCounter(ABC):
contents: Optional[List[Dict[str, Any]]],
deployment: Optional[Dict[str, Any]] = None,
request_model: str = "",
tools: Optional[List[Dict[str, Any]]] = None,
system: Optional[Any] = None,
) -> Optional[TokenCountResponse]:
pass

View file

@ -30,6 +30,8 @@ class BedrockTokenCounter(BaseTokenCounter):
contents: Optional[List[Dict[str, Any]]],
deployment: Optional[Dict[str, Any]] = None,
request_model: str = "",
tools: Optional[List[Dict[str, Any]]] = None,
system: Optional[Any] = None,
) -> Optional[TokenCountResponse]:
"""
Count tokens using AWS Bedrock's CountTokens API.
@ -54,11 +56,17 @@ class BedrockTokenCounter(BaseTokenCounter):
litellm_params = deployment.get("litellm_params", {})
# Build request data in the format expected by BedrockCountTokensHandler
request_data = {
request_data: Dict[str, Any] = {
"model": model_to_use,
"messages": messages,
}
if tools:
request_data["tools"] = tools
if system:
request_data["system"] = system
# Get the resolved model (strip prefixes like bedrock/, converse/, etc.)
resolved_model = get_bedrock_base_model(model_to_use)

View file

@ -5,7 +5,8 @@ This module handles the transformation of requests from Anthropic Messages API f
to AWS Bedrock's CountTokens API format and vice versa.
"""
from typing import Any, Dict, List
import re
from typing import Any, Dict, List, Optional
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
from litellm.llms.bedrock.common_utils import get_bedrock_base_model
@ -75,46 +76,81 @@ class BedrockCountTokensConfig(BaseAWSLLM):
input_type = self._detect_input_type(request_data)
if input_type == "converse":
return self._transform_to_converse_format(request_data.get("messages", []))
return self._transform_to_converse_format(request_data)
else:
return self._transform_to_invoke_model_format(request_data)
def _transform_to_converse_format(
self, messages: List[Dict[str, Any]]
self, request_data: Dict[str, Any]
) -> Dict[str, Any]:
"""Transform to Converse input format."""
# Extract system messages if present
system_messages = []
"""Transform to Converse input format, including system and tools."""
messages = request_data.get("messages", [])
system = request_data.get("system")
tools = request_data.get("tools")
# Transform messages
user_messages = []
for message in messages:
if message.get("role") == "system":
system_messages.append({"text": message.get("content", "")})
else:
# Transform message content to Bedrock format
transformed_message: Dict[str, Any] = {"role": message.get("role"), "content": []}
transformed_message: Dict[str, Any] = {"role": message.get("role"), "content": []}
content = message.get("content", "")
if isinstance(content, str):
transformed_message["content"].append({"text": content})
elif isinstance(content, list):
transformed_message["content"] = content
user_messages.append(transformed_message)
# Handle content - ensure it's in the correct array format
content = message.get("content", "")
if isinstance(content, str):
# String content -> convert to text block
transformed_message["content"].append({"text": content})
elif isinstance(content, list):
# Already in blocks format - use as is
transformed_message["content"] = content
converse_input: Dict[str, Any] = {"messages": user_messages}
user_messages.append(transformed_message)
# Transform system prompt (string or list of blocks → Bedrock format)
system_blocks = self._transform_system(system)
if system_blocks:
converse_input["system"] = system_blocks
# Build the converse input format
converse_input = {"messages": user_messages}
# Transform tools (Anthropic format → Bedrock toolConfig)
tool_config = self._transform_tools(tools)
if tool_config:
converse_input["toolConfig"] = tool_config
# Add system messages if present
if system_messages:
converse_input["system"] = system_messages
# Build the complete request
return {"input": {"converse": converse_input}}
def _transform_system(self, system: Optional[Any]) -> List[Dict[str, Any]]:
"""Transform Anthropic system prompt to Bedrock system blocks."""
if system is None:
return []
if isinstance(system, str):
return [{"text": system}]
if isinstance(system, list):
# Already in blocks format (e.g. [{"type": "text", "text": "..."}])
return [{"text": block.get("text", "")} for block in system if isinstance(block, dict)]
return []
def _transform_tools(self, tools: Optional[List[Dict[str, Any]]]) -> Optional[Dict[str, Any]]:
"""Transform Anthropic tools to Bedrock toolConfig format."""
if not tools:
return None
bedrock_tools = []
for tool in tools:
name = tool.get("name", "")
# Bedrock tool names must match [a-zA-Z][a-zA-Z0-9_]* and max 64 chars
name = re.sub(r"[^a-zA-Z0-9_]", "_", name)
if name and not name[0].isalpha():
name = "t_" + name
name = name[:64]
description = tool.get("description") or name
input_schema = tool.get("input_schema", {"type": "object", "properties": {}})
bedrock_tools.append({
"toolSpec": {
"name": name,
"description": description,
"inputSchema": {"json": input_schema},
}
})
return {"tools": bedrock_tools}
def _transform_to_invoke_model_format(
self, request_data: Dict[str, Any]
) -> Dict[str, Any]:

View file

@ -166,6 +166,7 @@ class GoogleAIStudioTokenCounter(BaseTokenCounter):
contents: Optional[List[Dict[str, Any]]],
deployment: Optional[Dict[str, Any]] = None,
request_model: str = "",
**kwargs,
) -> Optional[TokenCountResponse]:
import copy

View file

@ -1042,6 +1042,7 @@ class VertexAITokenCounter(BaseTokenCounter):
contents: Optional[List[Dict[str, Any]]],
deployment: Optional[Dict[str, Any]] = None,
request_model: str = "",
**kwargs,
) -> Optional[TokenCountResponse]:
import copy

View file

@ -2857,6 +2857,9 @@ class TokenCountRequest(LiteLLMPydanticObjectBase):
Google /countTokens endpoint expects contents to be a list of dicts with the following structure:
"""
tools: Optional[List[dict]] = None
system: Optional[Any] = None
class CallInfo(LiteLLMPydanticObjectBase):
"""Used for slack budget alerting"""

View file

@ -204,7 +204,12 @@ async def count_tokens(
# Create TokenCountRequest for the internal endpoint
from litellm.proxy._types import TokenCountRequest
token_request = TokenCountRequest(model=model_name, messages=messages)
token_request = TokenCountRequest(
model=model_name,
messages=messages,
tools=data.get("tools"),
system=data.get("system"),
)
# Call the internal token counter function with direct request flag set to False
token_response = await internal_token_counter(

View file

@ -8355,6 +8355,8 @@ async def token_counter(request: TokenCountRequest, call_endpoint: bool = False)
prompt = request.prompt
messages = request.messages
contents = request.contents
tools = request.tools
system = request.system
#########################################################
# Validate request
@ -8415,6 +8417,8 @@ async def token_counter(request: TokenCountRequest, call_endpoint: bool = False)
contents=contents,
deployment=deployment,
request_model=request.model,
tools=tools,
system=system,
)
#########################################################
# Transfrom the Response to the well known format

View file

@ -0,0 +1,92 @@
import os
import sys
sys.path.insert(
0, os.path.abspath("../../../..")
) # Adds the parent directory to the system path
from litellm.llms.anthropic.count_tokens.transformation import (
AnthropicCountTokensConfig,
)
def test_transform_basic_request():
"""Test basic request with only model and messages."""
config = AnthropicCountTokensConfig()
result = config.transform_request_to_count_tokens(
model="claude-3-5-sonnet",
messages=[{"role": "user", "content": "Hello"}],
)
assert result == {
"model": "claude-3-5-sonnet",
"messages": [{"role": "user", "content": "Hello"}],
}
def test_transform_includes_system():
"""Test that system prompt is included when provided."""
config = AnthropicCountTokensConfig()
result = config.transform_request_to_count_tokens(
model="claude-3-5-sonnet",
messages=[{"role": "user", "content": "Hello"}],
system="You are a helpful assistant.",
)
assert result["system"] == "You are a helpful assistant."
assert result["model"] == "claude-3-5-sonnet"
assert result["messages"] == [{"role": "user", "content": "Hello"}]
def test_transform_includes_tools():
"""Test that tools are included when provided."""
config = AnthropicCountTokensConfig()
tools = [
{
"name": "read_file",
"description": "Read a file",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}}},
}
]
result = config.transform_request_to_count_tokens(
model="claude-3-5-sonnet",
messages=[{"role": "user", "content": "Hello"}],
tools=tools,
)
assert result["tools"] == tools
def test_transform_includes_system_and_tools():
"""Test that both system and tools are included together."""
config = AnthropicCountTokensConfig()
result = config.transform_request_to_count_tokens(
model="claude-3-5-sonnet",
messages=[{"role": "user", "content": "Hello"}],
system="Be helpful",
tools=[{"name": "my_tool", "input_schema": {"type": "object"}}],
)
assert "system" in result
assert "tools" in result
assert "messages" in result
assert "model" in result
def test_transform_no_system_no_tools():
"""Test that None system/tools are not included."""
config = AnthropicCountTokensConfig()
result = config.transform_request_to_count_tokens(
model="claude-3-5-sonnet",
messages=[{"role": "user", "content": "Hello"}],
system=None,
tools=None,
)
assert "system" not in result
assert "tools" not in result

View file

@ -34,3 +34,123 @@ def test_transform_anthropic_to_bedrock_request():
assert "input" in result
assert "converse" in result["input"]
assert "messages" in result["input"]["converse"]
def test_transform_includes_system_prompt():
"""Test that system prompt is included in Bedrock converse format."""
config = BedrockCountTokensConfig()
request = {
"model": "anthropic.claude-3-sonnet-20240229-v1:0",
"messages": [{"role": "user", "content": "Hello"}],
"system": "You are a helpful assistant.",
}
result = config.transform_anthropic_to_bedrock_count_tokens(request)
converse = result["input"]["converse"]
assert "system" in converse
assert converse["system"] == [{"text": "You are a helpful assistant."}]
def test_transform_includes_system_prompt_as_list():
"""Test that system prompt as list of blocks is handled."""
config = BedrockCountTokensConfig()
request = {
"model": "anthropic.claude-3-sonnet-20240229-v1:0",
"messages": [{"role": "user", "content": "Hello"}],
"system": [{"type": "text", "text": "Block 1"}, {"type": "text", "text": "Block 2"}],
}
result = config.transform_anthropic_to_bedrock_count_tokens(request)
converse = result["input"]["converse"]
assert converse["system"] == [{"text": "Block 1"}, {"text": "Block 2"}]
def test_transform_includes_tools():
"""Test that tools are transformed to Bedrock toolConfig format."""
config = BedrockCountTokensConfig()
request = {
"model": "anthropic.claude-3-sonnet-20240229-v1:0",
"messages": [{"role": "user", "content": "Hello"}],
"tools": [
{
"name": "read_file",
"description": "Read a file",
"input_schema": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
}
],
}
result = config.transform_anthropic_to_bedrock_count_tokens(request)
converse = result["input"]["converse"]
assert "toolConfig" in converse
tools = converse["toolConfig"]["tools"]
assert len(tools) == 1
assert tools[0]["toolSpec"]["name"] == "read_file"
assert tools[0]["toolSpec"]["description"] == "Read a file"
assert tools[0]["toolSpec"]["inputSchema"]["json"]["type"] == "object"
def test_transform_includes_system_and_tools_together():
"""Test that both system and tools are included together."""
config = BedrockCountTokensConfig()
request = {
"model": "anthropic.claude-3-sonnet-20240229-v1:0",
"messages": [{"role": "user", "content": "Hello"}],
"system": "Be helpful",
"tools": [
{"name": "my_tool", "description": "A tool", "input_schema": {"type": "object", "properties": {}}},
],
}
result = config.transform_anthropic_to_bedrock_count_tokens(request)
converse = result["input"]["converse"]
assert "system" in converse
assert "toolConfig" in converse
assert "messages" in converse
def test_transform_no_system_no_tools():
"""Test that missing system and tools don't add extra keys."""
config = BedrockCountTokensConfig()
request = {
"model": "anthropic.claude-3-sonnet-20240229-v1:0",
"messages": [{"role": "user", "content": "Hello"}],
}
result = config.transform_anthropic_to_bedrock_count_tokens(request)
converse = result["input"]["converse"]
assert "system" not in converse
assert "toolConfig" not in converse
def test_tool_name_sanitization():
"""Test that tool names are sanitized for Bedrock requirements."""
config = BedrockCountTokensConfig()
request = {
"model": "anthropic.claude-3-sonnet-20240229-v1:0",
"messages": [{"role": "user", "content": "Hello"}],
"tools": [
{"name": "my-tool!", "description": "A tool", "input_schema": {"type": "object", "properties": {}}},
],
}
result = config.transform_anthropic_to_bedrock_count_tokens(request)
tool_name = result["input"]["converse"]["toolConfig"]["tools"][0]["toolSpec"]["name"]
# Should be sanitized: only [a-zA-Z0-9_]
assert tool_name == "my_tool_"