Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 54 additions & 13 deletions litellm/litellm_core_utils/token_counter.py
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,47 @@ def _count_anthropic_content(
return tokens


def _count_unknown_content_tokens(
content: Any,
count_function: TokenCounterFunction,
use_default_image_token_count: bool,
default_token_count: Optional[int],
) -> int:
"""
Best-effort token count for a content block whose `type` is unrecognized or
absent. Clients legitimately send custom or newer block types; rather than
crash, we recurse the block's values and count any recognized text, mirroring
how `_count_anthropic_content` traverses nested content lists. The `type`
discriminator itself never contributes prompt tokens, so it is skipped.
"""
if isinstance(content, str):
return count_function(content)
if not isinstance(content, Mapping):
return 0

num_tokens = 0
for key, value in content.items():
if key == "type":
continue
if isinstance(value, str):
num_tokens += count_function(value)
elif isinstance(value, list):
num_tokens += _count_content_list(
count_function,
value, # type: ignore
use_default_image_token_count,
default_token_count,
)
elif isinstance(value, Mapping):
num_tokens += _count_unknown_content_tokens(
value,
count_function,
use_default_image_token_count,
default_token_count,
)
return num_tokens


def _count_content_list(
count_function: TokenCounterFunction,
content_list: OpenAIMessageContent,
Expand All @@ -724,27 +765,30 @@ def _count_content_list(
for c in content_list:
if isinstance(c, str):
num_tokens += count_function(c)
elif c["type"] == "text":
continue

content_type = c.get("type") if isinstance(c, dict) else None
if content_type == "text":
num_tokens += count_function(str(c.get("text", "")))
elif c["type"] == "image_url":
elif content_type == "image_url":
image_url = c.get("image_url")
num_tokens += _count_image_tokens(
image_url, use_default_image_token_count
)
elif c["type"] in ("tool_use", "tool_result"):
elif content_type in ("tool_use", "tool_result"):
num_tokens += _count_anthropic_content(
c,
count_function,
use_default_image_token_count,
default_token_count,
)
elif c["type"] == "thinking":
elif content_type == "thinking":
# Claude extended thinking content block
# Count the thinking text and skip signature (opaque signature blob)
thinking_text = str(c.get("thinking", ""))
if thinking_text:
num_tokens += count_function(thinking_text)
elif c["type"] == "tool_reference":
elif content_type == "tool_reference":
# Anthropic tool-search reference block: a lightweight pointer to
# a deferred tool, e.g. {"type": "tool_reference", "tool_name": ...}.
# The full tool definition is counted via the `tools` param, so we
Expand All @@ -756,14 +800,11 @@ def _count_content_list(
if tool_name:
num_tokens += count_function(tool_name)
else:
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)."
num_tokens += _count_unknown_content_tokens(
c,
count_function,
use_default_image_token_count,
default_token_count,
)
return num_tokens
except Exception as e:
Expand Down
65 changes: 49 additions & 16 deletions tests/test_litellm/litellm_core_utils/test_token_counter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1051,22 +1051,55 @@ def test_token_counter_with_tool_reference_block():
assert tokens_empty >= 0


def test_count_content_list_rejects_unknown_type():
def test_count_content_list_degrades_on_unknown_type():
"""
An unrecognized content block type must raise, and the error message must
enumerate the supported types (including `tool_reference`). This pins the
catch-all contract so a future block type isn't silently dropped.
Regression: an unrecognized content block type must NOT raise. Clients
legitimately send custom or newer block types; the counter must degrade
gracefully, still counting recognized text in the same message rather than
crashing the whole request.

Covers the full failure class:
- a dict block with an unknown `type` value
- a dict block missing the `type` key entirely
- recognized text nested inside an unknown wrapper block
"""
from litellm.litellm_core_utils.token_counter import _count_content_list

with pytest.raises(ValueError) as exc_info:
_count_content_list(
count_function=len,
content_list=[{"type": "totally_unknown_block"}],
use_default_image_token_count=False,
default_token_count=None,
)
model = "gpt-3.5-turbo"
text_block = {"type": "text", "text": "the quick brown fox jumps over the lazy dog"}
baseline = token_counter_new(model=model, messages=[{"role": "user", "content": [text_block]}])
assert baseline > 0

# Unknown `type` value: must not raise and must not drop the recognized text.
unknown_block = {"type": "totally_made_up_block_type_v9", "data": "irrelevant payload"}
with_unknown = token_counter_new(
model=model, messages=[{"role": "user", "content": [text_block, unknown_block]}]
)
assert isinstance(with_unknown, int) and with_unknown >= baseline
only_unknown = token_counter_new(
model=model, messages=[{"role": "user", "content": [unknown_block]}]
)
assert isinstance(only_unknown, int) and only_unknown >= 0

# Dict block missing the `type` key entirely: must not raise at dispatch.
typeless_block = {"text": "extra payload that should not crash the counter"}
with_typeless = token_counter_new(
model=model, messages=[{"role": "user", "content": [text_block, typeless_block]}]
)
assert isinstance(with_typeless, int) and with_typeless >= baseline

message = str(exc_info.value)
assert "Invalid content item type: totally_unknown_block" in message
assert "tool_reference" in message
# Recognized text nested inside an unknown wrapper must still be counted,
# mirroring how tool_use/tool_result recurse nested content lists.
def wrapper_with(nested_text):
return [
{
"type": "custom_wrapper_block_v9",
"content": [{"type": "text", "text": nested_text}],
}
]

nested_full = token_counter_new(
model=model, messages=[{"role": "user", "content": wrapper_with("the quick brown fox " * 8)}]
)
nested_empty = token_counter_new(
model=model, messages=[{"role": "user", "content": wrapper_with("")}]
)
assert nested_full > nested_empty
Loading