From 1cce589aa008609cdd6e1d3ebf6db37c37c27d51 Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Wed, 12 Aug 2026 20:45:24 +0530 Subject: [PATCH 1/6] fix(token-counter): count Anthropic native image content blocks `_count_content_list` accepted text, image_url, tool_use, tool_result, thinking and tool_reference, and raised on anything else, so an Anthropic-native `{"type": "image", "source": {...}}` block aborted the whole count. That is the documented Anthropic image format and exactly what /v1/messages receives. Three user-visible effects. /v1/messages/count_tokens and /utils/token_counter return 500, and the router's context-window pre-call check swallows the ValueError and returns every deployment unfiltered, so an oversized prompt carrying an image is dispatched to the provider instead of being rejected locally with a 400. Prices the block through the existing image path: a base64 source becomes a data URI, a url source passes through, and a file source falls back to the default image token count. Blocks nested inside tool_result.content are covered too, because _count_anthropic_content recurses back into _count_content_list. Fixes #36604 --- litellm/litellm_core_utils/token_counter.py | 28 +++- .../litellm_core_utils/test_token_counter.py | 120 ++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/token_counter.py b/litellm/litellm_core_utils/token_counter.py index 858b078d626..ccfce0e4133 100644 --- a/litellm/litellm_core_utils/token_counter.py +++ b/litellm/litellm_core_utils/token_counter.py @@ -646,6 +646,24 @@ def _validate_anthropic_content(content: Mapping[str, Any]) -> type: return expected_cls +def _anthropic_image_source_data(source: Mapping[str, str]) -> str: + """ + Resolve an Anthropic image `source` to the data string `calculate_img_tokens` prices. + + Returns "" for a `file` source, whose bytes the proxy cannot resolve locally. + """ + source_type: Final = source.get("type") + if source_type == "base64": + data: Final = source.get("data") + if not data: + return "" + media_type: Final = source.get("media_type") or "image/png" + return f"data:{media_type};base64,{data}" + if source_type == "url": + return source.get("url") or "" + return "" + + def _count_anthropic_content( content: Mapping[str, Any], count_function: TokenCounterFunction, @@ -714,6 +732,13 @@ def _count_content_list( elif c["type"] == "image_url": image_url = c.get("image_url") num_tokens += _count_image_tokens(image_url, use_default_image_token_count) + elif c["type"] == "image": + source = c.get("source") + num_tokens += calculate_img_tokens( + data=_anthropic_image_source_data(source) if isinstance(source, dict) else "", + mode="auto", + use_default_image_token_count=use_default_image_token_count, + ) elif c["type"] in ("tool_use", "tool_result"): num_tokens += _count_anthropic_content( c, @@ -742,7 +767,8 @@ def _count_content_list( content_type = c.get("type", type(c).__name__) if isinstance(c, dict) else type(c).__name__ raise ValueError( f"Invalid content item type: {content_type}. " - f"Expected str or dict with 'type' field (text, image_url, tool_use, tool_result, thinking, tool_reference)." + f"Expected str or dict with 'type' field " + f"(text, image_url, image, tool_use, tool_result, thinking, tool_reference)." ) return num_tokens except Exception as e: diff --git a/tests/test_litellm/litellm_core_utils/test_token_counter.py b/tests/test_litellm/litellm_core_utils/test_token_counter.py index a2590dbca2d..701a1accd5f 100644 --- a/tests/test_litellm/litellm_core_utils/test_token_counter.py +++ b/tests/test_litellm/litellm_core_utils/test_token_counter.py @@ -1160,3 +1160,123 @@ def test_count_content_list_rejects_unknown_type(): message = str(exc_info.value) assert "Invalid content item type: totally_unknown_block" in message assert "tool_reference" in message + + +@pytest.mark.parametrize( + "source", + [ + {"type": "base64", "media_type": "image/png", "data": "iVBORw0KGgo="}, + {"type": "url", "url": "https://example.com/image.png"}, + {"type": "file", "file_id": "file-abc123"}, + ], + ids=["base64", "url", "file"], +) +def test_token_counter_with_anthropic_image_block(source): + """ + Anthropic-native `image` blocks must NOT raise, for every source variant. + + Before this fix `_count_content_list` raised + `Invalid content item type: image`. That 500s /v1/messages/count_tokens and + /utils/token_counter, and it makes the router's context-window pre-call + check swallow the error and return every deployment unfiltered, so an + oversized prompt carrying an image is dispatched upstream instead of being + rejected locally. + """ + from litellm.constants import DEFAULT_IMAGE_TOKEN_COUNT + + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What is in this image?"}, + {"type": "image", "source": source}, + ], + } + ] + + tokens = token_counter( + model="anthropic/claude-sonnet-4-5-20250929", + messages=messages, + use_default_image_token_count=True, + ) + assert tokens > DEFAULT_IMAGE_TOKEN_COUNT, ( + f"Expected the image block to contribute tokens, got {tokens}" + ) + + +def test_anthropic_image_block_matches_equivalent_image_url(): + """ + An Anthropic `image` block must price identically to the OpenAI `image_url` + block carrying the same bytes, so the count does not depend on which + endpoint shape the caller used. + """ + anthropic_messages = [ + { + "role": "user", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "iVBORw0KGgo=", + }, + } + ], + } + ] + openai_messages = [ + { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,iVBORw0KGgo="}, + } + ], + } + ] + + anthropic_tokens = token_counter( + model="anthropic/claude-sonnet-4-5-20250929", messages=anthropic_messages + ) + openai_tokens = token_counter( + model="anthropic/claude-sonnet-4-5-20250929", messages=openai_messages + ) + assert anthropic_tokens == openai_tokens + + +def test_anthropic_image_block_nested_in_tool_result(): + """ + An `image` block nested inside a `tool_result.content` list must be counted + too. `_count_anthropic_content` recurses back into `_count_content_list`, so + the nested case failed for the same reason the top-level one did. + """ + messages = [ + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "iVBORw0KGgo=", + }, + } + ], + } + ], + } + ] + + tokens = token_counter( + model="anthropic/claude-sonnet-4-5-20250929", + messages=messages, + use_default_image_token_count=True, + ) + assert tokens > 0 From fe28781dfd0e6012769ab13ac033f14926753633 Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Thu, 13 Aug 2026 13:17:46 +0530 Subject: [PATCH 2/6] fix(token-counter): enhance handling of Anthropic image blocks in token counting --- litellm/litellm_core_utils/token_counter.py | 26 ++++++---- .../litellm_core_utils/test_token_counter.py | 47 +++++++++++++++++++ 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/litellm/litellm_core_utils/token_counter.py b/litellm/litellm_core_utils/token_counter.py index ccfce0e4133..f101188a692 100644 --- a/litellm/litellm_core_utils/token_counter.py +++ b/litellm/litellm_core_utils/token_counter.py @@ -3,7 +3,7 @@ import base64 import io import struct -from collections.abc import Callable, Mapping +from collections.abc import Callable, Iterable, Mapping from typing import Any, Final, Literal, cast import tiktoken @@ -25,6 +25,10 @@ from litellm.litellm_core_utils.default_encoding import encoding as default_enco from litellm.litellm_core_utils.url_utils import safe_get from litellm.llms.custom_httpx.http_handler import _get_httpx_client from litellm.types.llms.anthropic import ( + AnthropicContentParamSource, + AnthropicContentParamSourceFileId, + AnthropicContentParamSourceUrl, + AnthropicMessagesImageParam, AnthropicMessagesToolResultParam, AnthropicMessagesToolUseParam, ) @@ -32,7 +36,7 @@ from litellm.types.llms.openai import ( AllMessageValues, ChatCompletionNamedToolChoiceParam, ChatCompletionToolParam, - OpenAIMessageContent, + OpenAIMessageContentListBlock, ) from litellm.types.utils import Message, SelectTokenizerResponse @@ -646,20 +650,21 @@ def _validate_anthropic_content(content: Mapping[str, Any]) -> type: return expected_cls -def _anthropic_image_source_data(source: Mapping[str, str]) -> str: +def _anthropic_image_source_data( + source: AnthropicContentParamSource | AnthropicContentParamSourceUrl | AnthropicContentParamSourceFileId, +) -> str: """ Resolve an Anthropic image `source` to the data string `calculate_img_tokens` prices. Returns "" for a `file` source, whose bytes the proxy cannot resolve locally. """ - source_type: Final = source.get("type") - if source_type == "base64": + if source["type"] == "base64": data: Final = source.get("data") if not data: return "" media_type: Final = source.get("media_type") or "image/png" return f"data:{media_type};base64,{data}" - if source_type == "url": + if source["type"] == "url": return source.get("url") or "" return "" @@ -715,12 +720,16 @@ def _count_anthropic_content( def _count_content_list( count_function: TokenCounterFunction, - content_list: OpenAIMessageContent, + content_list: str | Iterable[OpenAIMessageContentListBlock | AnthropicMessagesImageParam], use_default_image_token_count: bool, default_token_count: int | None, ) -> int: """ Recursively count tokens from a list of content blocks. + + The block union is wider than OpenAI's: the proxy's Anthropic endpoints count + their native blocks through this same helper, so an `image` block is as much + an input here as OpenAI's `image_url`. """ try: num_tokens = 0 @@ -733,9 +742,8 @@ def _count_content_list( image_url = c.get("image_url") num_tokens += _count_image_tokens(image_url, use_default_image_token_count) elif c["type"] == "image": - source = c.get("source") num_tokens += calculate_img_tokens( - data=_anthropic_image_source_data(source) if isinstance(source, dict) else "", + data=_anthropic_image_source_data(c["source"]), mode="auto", use_default_image_token_count=use_default_image_token_count, ) diff --git a/tests/test_litellm/litellm_core_utils/test_token_counter.py b/tests/test_litellm/litellm_core_utils/test_token_counter.py index 701a1accd5f..e3090d9751b 100644 --- a/tests/test_litellm/litellm_core_utils/test_token_counter.py +++ b/tests/test_litellm/litellm_core_utils/test_token_counter.py @@ -1280,3 +1280,50 @@ def test_anthropic_image_block_nested_in_tool_result(): use_default_image_token_count=True, ) assert tokens > 0 + + +def test_anthropic_image_block_with_empty_base64_data(): + """ + A base64 source carrying no bytes must still price as an image rather than + raise: the block is well-formed enough to count, and an empty `data` only + means there is nothing to measure the dimensions from. + """ + from litellm.litellm_core_utils.token_counter import _count_content_list + + tokens = _count_content_list( + count_function=len, + content_list=[ + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": ""}} + ], + use_default_image_token_count=False, + default_token_count=None, + ) + assert tokens > 0 + + +def test_anthropic_image_block_without_source_raises(): + """ + An `image` block with no `source` is malformed, and must fail the same way + the OpenAI `image_url` block with no `url` does - a ValueError the caller + can turn into a 400 - instead of being silently counted as a valid image. + """ + from litellm.litellm_core_utils.token_counter import _count_content_list + + with pytest.raises(ValueError): + _count_content_list( + count_function=len, + content_list=[{"type": "image"}], + use_default_image_token_count=False, + default_token_count=None, + ) + + # ... and `default_token_count`, the caller's opt-out from raising, still wins. + assert ( + _count_content_list( + count_function=len, + content_list=[{"type": "image"}], + use_default_image_token_count=False, + default_token_count=7, + ) + == 7 + ) From 7cd3a27d6007493fdf4819b72ce2e9dbb5b07688 Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Thu, 13 Aug 2026 13:37:53 +0530 Subject: [PATCH 3/6] update test signature for Anthropic image block handling --- tests/test_litellm/litellm_core_utils/test_token_counter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_litellm/litellm_core_utils/test_token_counter.py b/tests/test_litellm/litellm_core_utils/test_token_counter.py index e3090d9751b..7019577cca0 100644 --- a/tests/test_litellm/litellm_core_utils/test_token_counter.py +++ b/tests/test_litellm/litellm_core_utils/test_token_counter.py @@ -1171,7 +1171,7 @@ def test_count_content_list_rejects_unknown_type(): ], ids=["base64", "url", "file"], ) -def test_token_counter_with_anthropic_image_block(source): +def test_token_counter_with_anthropic_image_block(source: dict[str, str]): """ Anthropic-native `image` blocks must NOT raise, for every source variant. From 70ba0bb97399e2ab55bc22a65dd1b4ccba18bf9a Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:36:09 -0700 Subject: [PATCH 4/6] fix(proxy): count tools, system, and Anthropic document blocks in the count_tokens fallback --- litellm/litellm_core_utils/token_counter.py | 56 ++++++++++++- litellm/proxy/proxy_server.py | 27 +++++- litellm/types/llms/anthropic.py | 21 ++++- litellm/utils.py | 4 +- .../litellm_core_utils/test_token_counter.py | 84 ++++++++++++++++++- .../proxy/proxy_server/test_routes_utils.py | 45 ++++++++++ 6 files changed, 226 insertions(+), 11 deletions(-) diff --git a/litellm/litellm_core_utils/token_counter.py b/litellm/litellm_core_utils/token_counter.py index f101188a692..98bcedf43fe 100644 --- a/litellm/litellm_core_utils/token_counter.py +++ b/litellm/litellm_core_utils/token_counter.py @@ -3,7 +3,7 @@ import base64 import io import struct -from collections.abc import Callable, Iterable, Mapping +from collections.abc import Callable, Iterable, Mapping, Sequence from typing import Any, Final, Literal, cast import tiktoken @@ -28,12 +28,15 @@ from litellm.types.llms.anthropic import ( AnthropicContentParamSource, AnthropicContentParamSourceFileId, AnthropicContentParamSourceUrl, + AnthropicMessagesDocumentParam, AnthropicMessagesImageParam, + AnthropicMessagesTextParam, AnthropicMessagesToolResultParam, AnthropicMessagesToolUseParam, ) from litellm.types.llms.openai import ( AllMessageValues, + ChatCompletionDocumentObject, ChatCompletionNamedToolChoiceParam, ChatCompletionToolParam, OpenAIMessageContentListBlock, @@ -350,7 +353,7 @@ def token_counter( model="", custom_tokenizer: dict | SelectTokenizerResponse | None = None, text: str | list[str] | None = None, - messages: list[AllMessageValues | Message] | None = None, + messages: Sequence[AllMessageValues | Message] | None = None, count_response_tokens: bool | None = False, tools: list[ChatCompletionToolParam] | None = None, tool_choice: ChatCompletionNamedToolChoiceParam | None = None, @@ -669,6 +672,38 @@ def _anthropic_image_source_data( return "" +def _count_document_tokens( + document: ChatCompletionDocumentObject | AnthropicMessagesDocumentParam, + count_function: TokenCounterFunction, + use_default_image_token_count: bool, + default_token_count: int | None, +) -> int: + """ + Count an Anthropic `document` block: its title and context text, plus the source itself. + + Text-bearing sources (`text`, `content`) count their text; opaque ones (`base64`, `url`, + `file`) are priced like an image, since their bytes cannot be tokenized locally. + """ + source: Final = document["source"] + metadata_tokens: Final = sum( + count_function(text) for text in (document.get("title"), document.get("context")) if text + ) + if source["type"] == "text": + return metadata_tokens + count_function(source["data"]) + if source["type"] == "content": + content: Final = source["content"] + if isinstance(content, str): + return metadata_tokens + count_function(content) + return metadata_tokens + _count_content_list( + count_function, content, use_default_image_token_count, default_token_count + ) + return metadata_tokens + calculate_img_tokens( + data=_anthropic_image_source_data(source), + mode="auto", + use_default_image_token_count=use_default_image_token_count, + ) + + def _count_anthropic_content( content: Mapping[str, Any], count_function: TokenCounterFunction, @@ -720,7 +755,13 @@ def _count_anthropic_content( def _count_content_list( count_function: TokenCounterFunction, - content_list: str | Iterable[OpenAIMessageContentListBlock | AnthropicMessagesImageParam], + content_list: str + | Iterable[ + OpenAIMessageContentListBlock + | AnthropicMessagesTextParam + | AnthropicMessagesImageParam + | AnthropicMessagesDocumentParam + ], use_default_image_token_count: bool, default_token_count: int | None, ) -> int: @@ -747,6 +788,13 @@ def _count_content_list( mode="auto", use_default_image_token_count=use_default_image_token_count, ) + elif c["type"] == "document": + num_tokens += _count_document_tokens( + c, + count_function, + use_default_image_token_count, + default_token_count, + ) elif c["type"] in ("tool_use", "tool_result"): num_tokens += _count_anthropic_content( c, @@ -776,7 +824,7 @@ def _count_content_list( raise ValueError( f"Invalid content item type: {content_type}. " f"Expected str or dict with 'type' field " - f"(text, image_url, image, tool_use, tool_result, thinking, tool_reference)." + f"(text, image_url, image, document, tool_use, tool_result, thinking, tool_reference)." ) return num_tokens except Exception as e: diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 7ea6fdee6a5..f107eafd283 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -672,7 +672,12 @@ from litellm.types.llms.anthropic import ( AnthropicResponseContentBlockText, AnthropicResponseUsageBlock, ) -from litellm.types.llms.openai import HttpxBinaryResponseContent +from litellm.types.llms.openai import ( + AllMessageValues, + ChatCompletionSystemMessage, + ChatCompletionToolParam, + HttpxBinaryResponseContent, +) from litellm.types.proxy.control_plane_endpoints import WorkerRegistryEntry from litellm.types.proxy.management_endpoints.model_management_endpoints import ( ModelGroupInfoProxy, @@ -12126,6 +12131,13 @@ async def _try_provider_token_count( return result +def _system_message(system: object) -> ChatCompletionSystemMessage | None: + if not isinstance(system, (str, list)) or not system: + return None + message: Final[ChatCompletionSystemMessage] = {"role": "system", "content": system} + return message + + @router.post( "/utils/token_counter", tags=["llm utils"], @@ -12224,10 +12236,21 @@ async def token_counter(request: TokenCountRequest, call_endpoint: bool = False) _tokenizer_used: Final = litellm.utils._select_tokenizer(model=model_to_use, custom_tokenizer=custom_tokenizer) tokenizer_used: Final = str(_tokenizer_used["type"]) + system_message: Final = _system_message(system) + typed_messages: Final = cast( # cast-ok: request messages are raw chat-shaped dicts that token_counter normalizes + Sequence[AllMessageValues] | None, messages + ) + counted_messages: Final = ( + typed_messages if typed_messages is None or system_message is None else (system_message, *typed_messages) + ) + counted_tools: Final = cast( # cast-ok: raw OpenAI or Anthropic tool dicts, both of which token_counter formats + list[ChatCompletionToolParam] | None, tools + ) total_tokens: Final = await asyncify(litellm.token_counter)( model=model_to_use, text=prompt, - messages=messages, + messages=counted_messages, + tools=counted_tools, custom_tokenizer=_tokenizer_used, ) return TokenCountResponse( diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index 7805dd595a2..b3462203c4b 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -1,4 +1,4 @@ -from collections.abc import Iterable +from collections.abc import Iterable, Sequence from enum import Enum from typing import Any, Final, Literal, TypeAlias @@ -254,6 +254,17 @@ class AnthropicContentParamSourceFileId(TypedDict): file_id: str +class AnthropicContentParamSourceText(TypedDict): + type: ReadOnly[Literal["text"]] + media_type: ReadOnly[Literal["text/plain"]] + data: ReadOnly[str] + + +class AnthropicContentParamSourceContent(TypedDict): + type: ReadOnly[Literal["content"]] + content: ReadOnly[str | Sequence["AnthropicMessagesTextParam | AnthropicMessagesImageParam"]] + + class AnthropicMessagesContainerUploadParam(TypedDict, total=False): type: Required[Literal["container_upload"]] file_id: str @@ -305,7 +316,13 @@ AnthropicCitation = AnthropicCitationPageLocation | AnthropicCitationCharLocatio class AnthropicMessagesDocumentParam(TypedDict, total=False): type: Required[Literal["document"]] - source: Required[AnthropicContentParamSource | AnthropicContentParamSourceFileId | AnthropicContentParamSourceUrl] + source: Required[ + AnthropicContentParamSource + | AnthropicContentParamSourceFileId + | AnthropicContentParamSourceUrl + | AnthropicContentParamSourceText + | AnthropicContentParamSourceContent + ] cache_control: dict | ChatCompletionCachedContent | None title: str context: str diff --git a/litellm/utils.py b/litellm/utils.py index 520c40f67c0..5c5fe7cd97f 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -2302,7 +2302,7 @@ def token_counter( model="", custom_tokenizer: dict | SelectTokenizerResponse | None = None, text: str | list[str] | None = None, - messages: list | None = None, + messages: Sequence | None = None, count_response_tokens: bool | None = False, tools: list[ChatCompletionToolParam] | None = None, tool_choice: ChatCompletionNamedToolChoiceParam | None = None, @@ -7741,7 +7741,7 @@ def convert_to_dict(message: BaseModel | dict) -> dict: raise TypeError(f"Invalid message type: {type(message)}. Expected dict or Pydantic model.") -def convert_list_message_to_dict(messages: list): +def convert_list_message_to_dict(messages: Sequence): new_messages: Final = [] for message in messages: convert_msg_to_dict = cast(AllMessageValues, convert_to_dict(message)) diff --git a/tests/test_litellm/litellm_core_utils/test_token_counter.py b/tests/test_litellm/litellm_core_utils/test_token_counter.py index 7019577cca0..b8f001240c9 100644 --- a/tests/test_litellm/litellm_core_utils/test_token_counter.py +++ b/tests/test_litellm/litellm_core_utils/test_token_counter.py @@ -1282,6 +1282,26 @@ def test_anthropic_image_block_nested_in_tool_result(): assert tokens > 0 +@pytest.mark.parametrize( + ("source", "expected"), + [ + ({"type": "base64", "media_type": "image/jpeg", "data": "/9j/4AAQ"}, "data:image/jpeg;base64,/9j/4AAQ"), + ({"type": "url", "url": "https://example.com/image.png"}, "https://example.com/image.png"), + ({"type": "file", "file_id": "file-abc123"}, ""), + ], + ids=["base64", "url", "file"], +) +def test_anthropic_image_source_resolves_to_what_the_image_pricer_reads(source: dict[str, str], expected: str): + """ + The image pricer reads either a data URI or a fetchable URL: a base64 source keeps its + media type inside the URI, a url source passes through untouched, and a file source has + no bytes the proxy can measure locally. + """ + from litellm.litellm_core_utils.token_counter import _anthropic_image_source_data + + assert _anthropic_image_source_data(source) == expected + + def test_anthropic_image_block_with_empty_base64_data(): """ A base64 source carrying no bytes must still price as an image rather than @@ -1309,7 +1329,7 @@ def test_anthropic_image_block_without_source_raises(): """ from litellm.litellm_core_utils.token_counter import _count_content_list - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Error getting number of tokens from content list"): _count_content_list( count_function=len, content_list=[{"type": "image"}], @@ -1327,3 +1347,65 @@ def test_anthropic_image_block_without_source_raises(): ) == 7 ) + + +def _count_user_content(content: list[dict]) -> int: + from litellm.litellm_core_utils.token_counter import token_counter + + return token_counter( + model="anthropic/claude-fable-5", + messages=[{"role": "user", "content": content}], + use_default_image_token_count=True, + ) + + +@pytest.mark.parametrize( + "source", + [ + {"type": "base64", "media_type": "application/pdf", "data": "JVBERi0xLjQK"}, + {"type": "url", "url": "https://example.com/report.pdf"}, + {"type": "file", "file_id": "file-abc123"}, + ], + ids=["base64", "url", "file"], +) +def test_anthropic_document_block_with_opaque_source_is_priced_like_an_image(source: dict[str, str]): + """ + A `document` whose bytes cannot be tokenized locally must not raise (it 500ed + /v1/messages/count_tokens before) and is priced exactly like an `image` block. + """ + prompt = {"type": "text", "text": "Summarize this file."} + + assert _count_user_content([prompt, {"type": "document", "source": source}]) == _count_user_content( + [prompt, {"type": "image", "source": source}] + ) + + +def test_anthropic_document_block_text_sources_count_their_text(): + """`text` and `content` document sources count the text they carry, as inline text blocks would.""" + prompt = {"type": "text", "text": "Summarize this file."} + body = {"type": "text", "text": "Revenue grew eleven percent while churn fell to two percent."} + picture = {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "iVBORw0KGgo="}} + + text_source = {"type": "document", "source": {"type": "text", "media_type": "text/plain", "data": body["text"]}} + assert _count_user_content([prompt, text_source]) == _count_user_content([prompt, body]) + + string_content = {"type": "document", "source": {"type": "content", "content": body["text"]}} + assert _count_user_content([prompt, string_content]) == _count_user_content([prompt, body]) + + block_content = {"type": "document", "source": {"type": "content", "content": [body, picture]}} + assert _count_user_content([prompt, block_content]) == _count_user_content([prompt, body, picture]) + + +def test_anthropic_document_title_and_context_add_their_tokens(): + prompt = {"type": "text", "text": "Summarize this file."} + source = {"type": "base64", "media_type": "application/pdf", "data": "JVBERi0xLjQK"} + described = {"type": "document", "source": source, "title": "Q3 board packet", "context": "Shared by finance"} + + assert _count_user_content([prompt, described]) == _count_user_content( + [ + prompt, + {"type": "text", "text": "Q3 board packet"}, + {"type": "text", "text": "Shared by finance"}, + {"type": "document", "source": source}, + ] + ) diff --git a/tests/test_litellm/proxy/proxy_server/test_routes_utils.py b/tests/test_litellm/proxy/proxy_server/test_routes_utils.py index f39192b171b..6fffead102f 100644 --- a/tests/test_litellm/proxy/proxy_server/test_routes_utils.py +++ b/tests/test_litellm/proxy/proxy_server/test_routes_utils.py @@ -184,3 +184,48 @@ def test_transform_request_unsafe_body(client, auth_as, monkeypatch): response = client.post("/utils/transform_request", json=payload) assert response.status_code == 400 assert "unsafe" in response.text or "error" in response.text + + +def test_token_counter_fallback_counts_tools_system_and_anthropic_blocks(client, auth_as, monkeypatch): + """ + Without a provider counter the route falls back to ``litellm.token_counter``. That count + must include the request's tools and system prompt, and Anthropic ``image`` and ``document`` + blocks must be counted instead of turning the whole request into a 500. + """ + monkeypatch.setattr(proxy_server, "llm_router", None) + monkeypatch.setattr(litellm, "disable_token_counter", False, raising=False) + system = [{"type": "text", "text": "You are a terse assistant. Answer in one sentence."}] + tools = [ + { + "name": "get_weather", + "description": "Look up the current weather for a city", + "input_schema": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}, + } + ] + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What is in this file?"}, + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "iVBORw0KGgo="}}, + {"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": "JVBERi0xLjQK"}}, + ], + } + ] + + def count(payload: dict) -> int: + with auth_as(): + response = client.post("/utils/token_counter", json={"model": "claude-fable-5", **payload}) + assert response.status_code == 200, response.text + return response.json()["total_tokens"] + + bare = count({"messages": messages}) + full = count({"messages": messages, "tools": tools, "system": system}) + + assert bare == litellm.token_counter(model="claude-fable-5", messages=messages) + assert full == litellm.token_counter( + model="claude-fable-5", + messages=[{"role": "system", "content": system}, *messages], + tools=tools, + ) + assert full > bare From 83ab87091b3ea06b2b40f76c7797dc4bcb2c55ed Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 28 Aug 2026 06:10:30 -0700 Subject: [PATCH 5/6] fix(proxy): only attach tools to the count_tokens fallback when counting messages --- litellm/proxy/proxy_server.py | 2 +- .../proxy/proxy_server/test_routes_utils.py | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index f107eafd283..68d0960905f 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -12244,7 +12244,7 @@ async def token_counter(request: TokenCountRequest, call_endpoint: bool = False) typed_messages if typed_messages is None or system_message is None else (system_message, *typed_messages) ) counted_tools: Final = cast( # cast-ok: raw OpenAI or Anthropic tool dicts, both of which token_counter formats - list[ChatCompletionToolParam] | None, tools + list[ChatCompletionToolParam] | None, tools if counted_messages is not None else None ) total_tokens: Final = await asyncify(litellm.token_counter)( model=model_to_use, diff --git a/tests/test_litellm/proxy/proxy_server/test_routes_utils.py b/tests/test_litellm/proxy/proxy_server/test_routes_utils.py index 6fffead102f..ea36f31a82f 100644 --- a/tests/test_litellm/proxy/proxy_server/test_routes_utils.py +++ b/tests/test_litellm/proxy/proxy_server/test_routes_utils.py @@ -229,3 +229,33 @@ def test_token_counter_fallback_counts_tools_system_and_anthropic_blocks(client, tools=tools, ) assert full > bare + + +def test_token_counter_fallback_prompt_with_tools_does_not_500(client, auth_as, monkeypatch): + """ + Regression: a raw-text ``prompt`` request that also carries ``tools`` (no ``messages``) must + still count. ``litellm.token_counter`` rejects tools on the text path, so the fallback route + only attaches tools when it is counting messages; otherwise this 500'd instead of returning + the plain text count. + """ + monkeypatch.setattr(proxy_server, "llm_router", None) + monkeypatch.setattr(litellm, "disable_token_counter", False, raising=False) + prompt = "count the tokens in this sentence please" + tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Look up the current weather for a city", + "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}, + }, + } + ] + + with auth_as(): + response = client.post( + "/utils/token_counter", json={"model": "claude-fable-5", "prompt": prompt, "tools": tools} + ) + + assert response.status_code == 200, response.text + assert response.json()["total_tokens"] == litellm.token_counter(model="claude-fable-5", text=prompt) From 24d226c6c2cba581a3833486ce099d240705fd77 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 28 Aug 2026 06:28:21 -0700 Subject: [PATCH 6/6] chore(token_counter): drop docstrings and test prose that restated the count_tokens branches --- litellm/litellm_core_utils/token_counter.py | 19 +------- .../litellm_core_utils/test_token_counter.py | 46 +++---------------- .../proxy/proxy_server/test_routes_utils.py | 13 +----- 3 files changed, 10 insertions(+), 68 deletions(-) diff --git a/litellm/litellm_core_utils/token_counter.py b/litellm/litellm_core_utils/token_counter.py index 98bcedf43fe..256bee7b348 100644 --- a/litellm/litellm_core_utils/token_counter.py +++ b/litellm/litellm_core_utils/token_counter.py @@ -656,11 +656,6 @@ def _validate_anthropic_content(content: Mapping[str, Any]) -> type: def _anthropic_image_source_data( source: AnthropicContentParamSource | AnthropicContentParamSourceUrl | AnthropicContentParamSourceFileId, ) -> str: - """ - Resolve an Anthropic image `source` to the data string `calculate_img_tokens` prices. - - Returns "" for a `file` source, whose bytes the proxy cannot resolve locally. - """ if source["type"] == "base64": data: Final = source.get("data") if not data: @@ -678,12 +673,6 @@ def _count_document_tokens( use_default_image_token_count: bool, default_token_count: int | None, ) -> int: - """ - Count an Anthropic `document` block: its title and context text, plus the source itself. - - Text-bearing sources (`text`, `content`) count their text; opaque ones (`base64`, `url`, - `file`) are priced like an image, since their bytes cannot be tokenized locally. - """ source: Final = document["source"] metadata_tokens: Final = sum( count_function(text) for text in (document.get("title"), document.get("context")) if text @@ -765,13 +754,7 @@ def _count_content_list( use_default_image_token_count: bool, default_token_count: int | None, ) -> int: - """ - Recursively count tokens from a list of content blocks. - - The block union is wider than OpenAI's: the proxy's Anthropic endpoints count - their native blocks through this same helper, so an `image` block is as much - an input here as OpenAI's `image_url`. - """ + """Recursively count tokens from a list of content blocks.""" try: num_tokens = 0 for c in content_list: diff --git a/tests/test_litellm/litellm_core_utils/test_token_counter.py b/tests/test_litellm/litellm_core_utils/test_token_counter.py index b8f001240c9..572b505e94c 100644 --- a/tests/test_litellm/litellm_core_utils/test_token_counter.py +++ b/tests/test_litellm/litellm_core_utils/test_token_counter.py @@ -1172,16 +1172,7 @@ def test_count_content_list_rejects_unknown_type(): ids=["base64", "url", "file"], ) def test_token_counter_with_anthropic_image_block(source: dict[str, str]): - """ - Anthropic-native `image` blocks must NOT raise, for every source variant. - - Before this fix `_count_content_list` raised - `Invalid content item type: image`. That 500s /v1/messages/count_tokens and - /utils/token_counter, and it makes the router's context-window pre-call - check swallow the error and return every deployment unfiltered, so an - oversized prompt carrying an image is dispatched upstream instead of being - rejected locally. - """ + """Anthropic `image` blocks must count for every source variant, not raise `Invalid content item type` (which the router's context-window pre-call check swallows into an unfiltered dispatch).""" from litellm.constants import DEFAULT_IMAGE_TOKEN_COUNT messages = [ @@ -1205,11 +1196,7 @@ def test_token_counter_with_anthropic_image_block(source: dict[str, str]): def test_anthropic_image_block_matches_equivalent_image_url(): - """ - An Anthropic `image` block must price identically to the OpenAI `image_url` - block carrying the same bytes, so the count does not depend on which - endpoint shape the caller used. - """ + """An Anthropic `image` block prices identically to the OpenAI `image_url` carrying the same bytes.""" anthropic_messages = [ { "role": "user", @@ -1247,11 +1234,7 @@ def test_anthropic_image_block_matches_equivalent_image_url(): def test_anthropic_image_block_nested_in_tool_result(): - """ - An `image` block nested inside a `tool_result.content` list must be counted - too. `_count_anthropic_content` recurses back into `_count_content_list`, so - the nested case failed for the same reason the top-level one did. - """ + """An `image` block nested in a `tool_result.content` list is counted through the same recursion.""" messages = [ { "role": "user", @@ -1292,22 +1275,14 @@ def test_anthropic_image_block_nested_in_tool_result(): ids=["base64", "url", "file"], ) def test_anthropic_image_source_resolves_to_what_the_image_pricer_reads(source: dict[str, str], expected: str): - """ - The image pricer reads either a data URI or a fetchable URL: a base64 source keeps its - media type inside the URI, a url source passes through untouched, and a file source has - no bytes the proxy can measure locally. - """ + """base64 sources become a data URI, url sources pass through, file sources resolve to an empty string.""" from litellm.litellm_core_utils.token_counter import _anthropic_image_source_data assert _anthropic_image_source_data(source) == expected def test_anthropic_image_block_with_empty_base64_data(): - """ - A base64 source carrying no bytes must still price as an image rather than - raise: the block is well-formed enough to count, and an empty `data` only - means there is nothing to measure the dimensions from. - """ + """A base64 source with empty `data` prices as an image rather than raising.""" from litellm.litellm_core_utils.token_counter import _count_content_list tokens = _count_content_list( @@ -1322,11 +1297,7 @@ def test_anthropic_image_block_with_empty_base64_data(): def test_anthropic_image_block_without_source_raises(): - """ - An `image` block with no `source` is malformed, and must fail the same way - the OpenAI `image_url` block with no `url` does - a ValueError the caller - can turn into a 400 - instead of being silently counted as a valid image. - """ + """An `image` block with no `source` raises, matching the OpenAI `image_url`-without-`url` behavior.""" from litellm.litellm_core_utils.token_counter import _count_content_list with pytest.raises(ValueError, match="Error getting number of tokens from content list"): @@ -1369,10 +1340,7 @@ def _count_user_content(content: list[dict]) -> int: ids=["base64", "url", "file"], ) def test_anthropic_document_block_with_opaque_source_is_priced_like_an_image(source: dict[str, str]): - """ - A `document` whose bytes cannot be tokenized locally must not raise (it 500ed - /v1/messages/count_tokens before) and is priced exactly like an `image` block. - """ + """A `document` whose bytes can't be tokenized locally is priced like an `image`, not raised on.""" prompt = {"type": "text", "text": "Summarize this file."} assert _count_user_content([prompt, {"type": "document", "source": source}]) == _count_user_content( diff --git a/tests/test_litellm/proxy/proxy_server/test_routes_utils.py b/tests/test_litellm/proxy/proxy_server/test_routes_utils.py index ea36f31a82f..35b5c72f92e 100644 --- a/tests/test_litellm/proxy/proxy_server/test_routes_utils.py +++ b/tests/test_litellm/proxy/proxy_server/test_routes_utils.py @@ -187,11 +187,7 @@ def test_transform_request_unsafe_body(client, auth_as, monkeypatch): def test_token_counter_fallback_counts_tools_system_and_anthropic_blocks(client, auth_as, monkeypatch): - """ - Without a provider counter the route falls back to ``litellm.token_counter``. That count - must include the request's tools and system prompt, and Anthropic ``image`` and ``document`` - blocks must be counted instead of turning the whole request into a 500. - """ + """The ``litellm.token_counter`` fallback counts the request's tools and system prompt, and Anthropic ``image``/``document`` blocks, instead of 500ing.""" monkeypatch.setattr(proxy_server, "llm_router", None) monkeypatch.setattr(litellm, "disable_token_counter", False, raising=False) system = [{"type": "text", "text": "You are a terse assistant. Answer in one sentence."}] @@ -232,12 +228,7 @@ def test_token_counter_fallback_counts_tools_system_and_anthropic_blocks(client, def test_token_counter_fallback_prompt_with_tools_does_not_500(client, auth_as, monkeypatch): - """ - Regression: a raw-text ``prompt`` request that also carries ``tools`` (no ``messages``) must - still count. ``litellm.token_counter`` rejects tools on the text path, so the fallback route - only attaches tools when it is counting messages; otherwise this 500'd instead of returning - the plain text count. - """ + """Regression: a ``prompt`` request carrying ``tools`` but no ``messages`` still counts, because the fallback attaches tools only when counting messages (``token_counter`` rejects tools on the text path).""" monkeypatch.setattr(proxy_server, "llm_router", None) monkeypatch.setattr(litellm, "disable_token_counter", False, raising=False) prompt = "count the tokens in this sentence please"