fix(token-counter): count Anthropic server tool history (#51334)

* fix(token-counter): count Anthropic server tool history

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(token-counter): bound nested results and update proxy coverage

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(token-counter): include readable web search snippets

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
kenneth-berri 2026-11-12 09:35:36 -08:00 • committed by GitHub
parent 2845677822
commit be623b5bde
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 325 additions and 27 deletions

View file

@ -31,6 +31,7 @@ from litellm.constants import (
DEFAULT_IMAGE_HEIGHT,
DEFAULT_IMAGE_TOKEN_COUNT,
DEFAULT_IMAGE_WIDTH,
DEFAULT_MAX_RECURSE_DEPTH,
MAX_IMAGE_URL_DOWNLOAD_SIZE_MB,
MAX_LONG_SIDE_FOR_IMAGE_HIGH_RES,
MAX_SHORT_SIDE_FOR_IMAGE_HIGH_RES,
@ -1758,31 +1759,72 @@ def _count_file_tokens(
)
_ANTHROPIC_SERVER_TOOL_FIELDS: Final = MappingProxyType(
{
"server_tool_use": ("name", "input"),
"mcp_tool_use": ("name", "input"),
"web_search_tool_result": ("content",),
"web_fetch_tool_result": ("content",),
"code_execution_tool_result": ("content",),
"bash_code_execution_tool_result": ("content",),
"text_editor_code_execution_tool_result": ("content",),
"mcp_tool_result": ("content",),
"web_search_result": ("url", "title", "snippet"),
"web_fetch_result": ("url", "content"),
"code_execution_result": ("stdout", "stderr", "content"),
"bash_code_execution_result": ("stdout", "stderr", "content"),
"text_editor_code_execution_view_result": ("content",),
"text_editor_code_execution_str_replace_result": ("lines",),
"text_editor_code_execution_create_result": (),
"encrypted_code_execution_result": ("stderr", "content"),
"code_execution_output": (),
"bash_code_execution_output": (),
"web_search_tool_result_error": ("error_code",),
"web_fetch_tool_result_error": ("error_code",),
"code_execution_tool_result_error": ("error_code",),
"bash_code_execution_tool_result_error": ("error_code",),
"text_editor_code_execution_tool_result_error": ("error_code", "error_message"),
}
)
_ANTHROPIC_DOCUMENT: Final = TypeAdapter(AnthropicMessagesDocumentParam)
def _count_anthropic_content(
content: Mapping[str, object],
count_function: TokenCounterFunction,
use_default_image_token_count: bool,
default_token_count: int | None,
pdf_pricing: PdfPagePricing = "claude",
*,
_content_depth: int = 0,
) -> int:
"""
Count tokens in Anthropic-specific content blocks (tool_use, tool_result, etc.).
Uses TypedDict definitions from litellm.types.llms.anthropic to determine
what fields to count and how to handle nested structures.
Dynamically infers which fields to count based on the TypedDict definition,
avoiding hardcoded field names.
Uses readable server tool fields or the client tool TypedDict definitions.
"""
typeddict_cls: Final = _validate_anthropic_content(content)
type_hints: Final[Mapping[str, object]] = getattr(typeddict_cls, "__annotations__", {})
if _content_depth >= DEFAULT_MAX_RECURSE_DEPTH:
raise UnsupportedContentTypeError("Anthropic tool content nesting exceeds the token counter limit")
content_type: Final = content.get("type")
if content_type == "document":
return _count_document_tokens(
_ANTHROPIC_DOCUMENT.validate_python(content),
count_function,
use_default_image_token_count,
default_token_count,
pdf_pricing,
)
server_fields: Final = _ANTHROPIC_SERVER_TOOL_FIELDS.get(content_type) if isinstance(content_type, str) else None
fields: Final[Mapping[str, object]] = (
dict.fromkeys(server_fields)
if server_fields is not None
else getattr(_validate_anthropic_content(content), "__annotations__", {})
)
tokens = 0
# Fields to skip (metadata/identifiers that don't contribute to prompt tokens)
skip_fields: Final = {"type", "id", "tool_use_id", "cache_control", "is_error"}
# Iterate over all fields defined in the TypedDict
for field_name, field_type in type_hints.items():
for field_name in fields:
if field_name in skip_fields:
continue
@ -1801,7 +1843,21 @@ def _count_anthropic_content(
pdf_pricing,
)
elif isinstance(field_value, dict):
tokens += count_function(str(field_value))
if (
server_fields is not None
and field_name == "content"
and (field_mapping := as_str_mapping(content[field_name])) is not None
):
tokens += _count_anthropic_content(
field_mapping,
count_function,
use_default_image_token_count,
default_token_count,
pdf_pricing,
_content_depth=_content_depth + 1,
)
else:
tokens += count_function(str(field_value))
except Exception as e:
if default_token_count is not None:
return default_token_count
@ -1867,7 +1923,9 @@ def _count_content_list(
use_default_image_token_count,
pdf_pricing,
)
elif c["type"] in ("tool_use", "tool_result", "search_result"):
elif (
c["type"] in ("tool_use", "tool_result", "search_result") or c["type"] in _ANTHROPIC_SERVER_TOOL_FIELDS
):
num_tokens += _count_anthropic_content(
c,
count_function,
@ -1892,7 +1950,7 @@ def _count_content_list(
f"Invalid content item type: {content_type}. "
f"Expected str or dict with 'type' field "
f"(text, image_url, image, document, file, tool_use, tool_result, thinking, redacted_thinking, "
f"compaction, tool_reference, search_result)."
f"compaction, tool_reference, search_result, or Anthropic server tool blocks)."
)
return num_tokens
except Exception as e:

View file

@ -11,6 +11,7 @@ IGNORE_FUNCTIONS = [
"_json_cost", # bounded at depth 32 and consumed under byte/node limits.
"detached", # router_config: copies only the plain containers of the router's own constructor arguments, once per build or changeset
"_format_type",
"_count_anthropic_content", # Nested server result mappings are bounded by DEFAULT_MAX_RECURSE_DEPTH.
"remove_additional_properties",
"remove_strict_from_schema",
"filter_schema_fields",

View file

@ -2304,6 +2304,185 @@ def test_anthropic_image_block_with_empty_base64_data():
assert tokens > 0
@pytest.mark.parametrize(
"block, expected",
(
(
{"type": "tool_result", "tool_use_id": "toolu_1", "content": {"status": "done"}},
"{'status': 'done'}",
),
(
{"type": "server_tool_use", "id": "srv_1", "name": "web_search", "input": {"query": "release"}},
"web_search{'query': 'release'}",
),
(
{
"type": "mcp_tool_use",
"id": "mcp_1",
"server_name": "notes",
"name": "lookup",
"input": {"q": "release"},
},
"lookup{'q': 'release'}",
),
(
{
"type": "web_search_tool_result",
"tool_use_id": "srv_1",
"content": [
{
"type": "web_search_result",
"url": "https://example.invalid",
"title": "Release",
"snippet": "Streaming added.",
"encrypted_content": "opaque" * 1000,
"page_age": "yesterday",
}
],
},
"https://example.invalidReleaseStreaming added.",
),
(
{
"type": "web_fetch_tool_result",
"tool_use_id": "srv_1",
"content": {
"type": "web_fetch_result",
"url": "https://example.invalid",
"retrieved_at": "metadata",
"content": {
"type": "document",
"title": "Release",
"source": {"type": "text", "media_type": "text/plain", "data": "Streaming added."},
},
},
},
"https://example.invalidReleaseStreaming added.",
),
(
{
"type": "code_execution_tool_result",
"tool_use_id": "srv_1",
"content": {
"type": "code_execution_result",
"stdout": "42",
"stderr": "warning",
"return_code": 0,
"content": [{"type": "code_execution_output", "file_id": "opaque-file-id"}],
},
},
"42warning",
),
(
{
"type": "code_execution_tool_result",
"tool_use_id": "srv_1",
"content": {
"type": "encrypted_code_execution_result",
"encrypted_stdout": "opaque" * 1000,
"stderr": "warning",
"return_code": 0,
"content": [],
},
},
"warning",
),
(
{
"type": "mcp_tool_result",
"tool_use_id": "mcp_1",
"is_error": False,
"content": [{"type": "text", "text": "Streaming added."}],
},
"Streaming added.",
),
(
{
"type": "bash_code_execution_tool_result",
"tool_use_id": "srv_1",
"content": {
"type": "bash_code_execution_result",
"stdout": "42",
"stderr": "warning",
"return_code": 0,
"content": [{"type": "bash_code_execution_output", "file_id": "opaque-file-id"}],
},
},
"42warning",
),
(
{
"type": "text_editor_code_execution_tool_result",
"tool_use_id": "srv_1",
"content": {
"type": "text_editor_code_execution_view_result",
"content": "print(42)",
"file_type": "text",
"num_lines": 1,
},
},
"print(42)",
),
(
{
"type": "text_editor_code_execution_tool_result",
"tool_use_id": "srv_1",
"content": {
"type": "text_editor_code_execution_str_replace_result",
"lines": ["first", "second"],
"old_lines": 1,
"new_lines": 2,
},
},
"firstsecond",
),
(
{
"type": "text_editor_code_execution_tool_result",
"tool_use_id": "srv_1",
"content": {"type": "text_editor_code_execution_create_result", "is_file_update": False},
},
"",
),
),
)
def test_token_counter_counts_anthropic_tool_readable_content(block: Mapping[str, object], expected: str) -> None:
from litellm.litellm_core_utils.token_counter import _count_content_list
assert _count_content_list(len, [block], False, None) == len(expected)
def test_token_counter_bounds_nested_server_results_and_honors_the_fallback() -> None:
from functools import reduce
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH
from litellm.litellm_core_utils.token_counter import UnsupportedContentTypeError, _count_content_list
def wrap(content: Mapping[str, object], _: int) -> Mapping[str, object]:
return {"type": "web_fetch_result", "content": content}
leaf: Final[Mapping[str, object]] = {"type": "web_fetch_result", "url": "https://example.invalid"}
block: Final = reduce(wrap, range(DEFAULT_MAX_RECURSE_DEPTH + 1), leaf)
with pytest.raises(UnsupportedContentTypeError, match="nesting exceeds the token counter limit"):
_count_content_list(len, [block], False, None)
assert _count_content_list(len, [block], False, 123) == 123
@pytest.mark.parametrize(
"tool", ("web_search", "web_fetch", "code_execution", "bash_code_execution", "text_editor_code_execution")
)
def test_token_counter_counts_server_tool_errors(tool: str) -> None:
from litellm.litellm_core_utils.token_counter import _count_content_list
block: Final = {
"type": f"{tool}_tool_result",
"tool_use_id": "srv_1",
"content": {"type": f"{tool}_tool_result_error", "error_code": "unavailable"},
}
assert _count_content_list(len, [block], False, None) == len("unavailable")
def test_token_counter_counts_anthropic_search_result_text() -> None:
from litellm.litellm_core_utils.token_counter import _count_content_list
@ -2334,12 +2513,12 @@ def test_token_counter_counts_a_search_result_without_a_title() -> None:
)
@pytest.mark.parametrize("nested", (False, True))
def test_unknown_content_type_remains_a_value_error_with_an_optional_fallback(nested: bool) -> None:
@pytest.mark.parametrize("container", (None, "tool_result", "web_search_tool_result"))
def test_unknown_content_type_remains_a_value_error_with_an_optional_fallback(container: str | None) -> None:
from litellm.litellm_core_utils.token_counter import _count_content_list
unknown: Final = {"type": "future_block"}
content: Final = [{"type": "tool_result", "tool_use_id": "toolu_1", "content": [unknown]}] if nested else [unknown]
content: Final = [{"type": container, "tool_use_id": "toolu_1", "content": [unknown]}] if container else [unknown]
with pytest.raises(ValueError, match="Invalid content item type: future_block"):
_count_content_list(len, content, False, None)

View file

@ -2119,12 +2119,17 @@ async def test_framing_keeps_the_400_for_blocks_the_provider_counter_cannot_conv
@pytest.mark.asyncio
@pytest.mark.parametrize(
"unreadable_body",
[
pytest.param(
{
"messages": [
async def test_framed_estimate_counts_server_tool_history_when_the_provider_counter_rejects_it(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_framed_kimi(monkeypatch)
_available_kimi_k3(monkeypatch, tmp_path)
async def count(title: str) -> TokenCountResponse:
return await count_request_tokens(
request=TokenCountRequest(
model="kimi",
messages=[
{"role": "user", "content": "What is new?"},
{
"role": "assistant",
@ -2135,13 +2140,31 @@ async def test_framing_keeps_the_400_for_blocks_the_provider_counter_cannot_conv
"name": "web_search",
"input": {"query": "new"},
},
{"type": "web_search_tool_result", "tool_use_id": "srvtoolu_01", "content": []},
{
"type": "web_search_tool_result",
"tool_use_id": "srvtoolu_01",
"content": [
{"type": "web_search_result", "url": "https://example.invalid", "title": title}
],
},
],
},
]
},
id="web_search_blocks",
),
],
),
call_endpoint=True,
)
short: Final = await count("Release")
long: Final = await count("Release notes " * 100)
assert (short.count_source, long.count_source) == ("local_estimate", "local_estimate")
assert long.total_tokens > short.total_tokens
@pytest.mark.asyncio
@pytest.mark.parametrize(
"unreadable_body",
[
pytest.param(
{"messages": [], "contents": [{"role": "user", "parts": [{"text": "What is new?"}]}]},
id="google_contents",

View file

@ -168,6 +168,43 @@ async def test_select_without_messages_or_input_runs_no_context_window_check(mak
assert await _picked(_small_and_big(make_core)) == {"small", "big"}
@pytest.mark.asyncio
@pytest.mark.parametrize(
("title", "snippet"),
[("release notes " * 100, ""), ("Release", "release notes " * 100)],
ids=["title", "snippet"],
)
async def test_server_tool_history_does_not_bypass_the_context_window_check(
make_core: MakeCore, title: str, snippet: str
) -> None:
core: Final = make_core(model_list=[windowed("g", "server-tools", 50)], enable_pre_call_checks=True)
messages: Final = [
{"role": "user", "content": "Search release notes"},
{
"role": "assistant",
"content": [
{"type": "server_tool_use", "id": "srv_1", "name": "web_search", "input": {"query": "release notes"}},
{
"type": "web_search_tool_result",
"tool_use_id": "srv_1",
"content": [
{
"type": "web_search_result",
"url": "https://example.invalid",
"title": title,
"snippet": snippet,
"encrypted_content": "opaque",
}
],
},
],
},
{"role": "user", "content": "Summarize"},
]
with pytest.raises(litellm.ContextWindowExceededError, match="Context Window exceeded"):
await core.select("g", messages=messages)
@pytest.mark.asyncio
async def test_when_no_context_window_fits_select_raises_context_window_exceeded(make_core: MakeCore) -> None:
core: Final = make_core(model_list=[windowed("g", "small", 5)], enable_pre_call_checks=True)