diff --git a/litellm/litellm_core_utils/token_counter.py b/litellm/litellm_core_utils/token_counter.py index c766c6edec1..caa3b9d4faa 100644 --- a/litellm/litellm_core_utils/token_counter.py +++ b/litellm/litellm_core_utils/token_counter.py @@ -724,27 +724,29 @@ 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 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 @@ -755,16 +757,6 @@ def _count_content_list( tool_name = str(c.get("tool_name") or "") 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)." - ) return num_tokens except Exception as e: if default_token_count is not None: 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 60e5a797627..cca4ed14798 100644 --- a/tests/test_litellm/litellm_core_utils/test_token_counter.py +++ b/tests/test_litellm/litellm_core_utils/test_token_counter.py @@ -1051,22 +1051,52 @@ 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_skips_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 test: a non-standard content block (e.g. a custom block emitted + by a tool orchestrator) must NOT crash the token counter. + + Before the fix, _count_content_list raised + `Invalid content item type: ...` on any unrecognized block, which bubbled + up as `Error getting number of tokens from content list` and broke token + counting for the whole message. The counter must instead skip the unknown + block while still counting recognized siblings. """ 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, - ) + long_text = "the quick brown fox jumps over the lazy dog " * 20 + text_only_tokens = _count_content_list( + count_function=len, + content_list=[{"type": "text", "text": long_text}], + use_default_image_token_count=False, + default_token_count=None, + ) + assert text_only_tokens > 0 - message = str(exc_info.value) - assert "Invalid content item type: totally_unknown_block" in message - assert "tool_reference" in message + with_unknown_tokens = _count_content_list( + count_function=len, + content_list=[ + {"type": "text", "text": long_text}, + {"type": "totally_unknown_block", "payload": {"k": "v"}}, + ], + use_default_image_token_count=False, + default_token_count=None, + ) + assert with_unknown_tokens == text_only_tokens + + only_unknown_tokens = _count_content_list( + count_function=len, + content_list=[{"type": "weird_custom_block", "blob": "x"}], + use_default_image_token_count=False, + default_token_count=None, + ) + assert only_unknown_tokens == 0 + + # A dict block with no `type` key at all must also be skipped, not crash. + no_type_tokens = _count_content_list( + count_function=len, + content_list=[{"text": "ignored because there is no type field"}], + use_default_image_token_count=False, + default_token_count=None, + ) + assert no_type_tokens == 0