diff --git a/litellm/litellm_core_utils/token_counter.py b/litellm/litellm_core_utils/token_counter.py index c2e76f896ff..e978a919096 100644 --- a/litellm/litellm_core_utils/token_counter.py +++ b/litellm/litellm_core_utils/token_counter.py @@ -681,17 +681,32 @@ def _count_anthropic_document( Anthropic shape: {"type": "document", "source": {...}, "title": ..., "context": ..., "citations": ...} - We count the text-bearing fields ("title", "context") with the model - tokenizer and add DEFAULT_IMAGE_TOKEN_COUNT for the opaque source payload - (typically a PDF — we cannot dimension it). Skipped: "type", - "cache_control", "citations" (configuration metadata, not prompt content). + Source variants (per the Anthropic Citations API): + - {"type": "text", "media_type": "text/plain", "data": ""} + - {"type": "base64", "media_type": ..., "data": ...} + - {"type": "url", "url": ...} + - {"type": "file", "file_id": ...} + + For text sources we tokenize source["data"] with the model tokenizer + (it's plain prompt content). For base64 / url / file sources the + payload is opaque (typically a PDF — we cannot dimension it) and we + add DEFAULT_IMAGE_TOKEN_COUNT as the fallback. + + Title and context are always counted. Skipped: "type", "cache_control", + "citations" (configuration metadata, not prompt content). """ tokens = 0 for field in ("title", "context"): value = content.get(field) if isinstance(value, str) and value: tokens += count_function(value) - if content.get("source") is not None: + source = content.get("source") + if isinstance(source, dict) and source.get("type") == "text": + data = source.get("data") + if isinstance(data, str) and data: + tokens += count_function(data) + elif source is not None: + # base64 / url / file / unknown — opaque payload, use the fallback. tokens += DEFAULT_IMAGE_TOKEN_COUNT return tokens 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 3c782974aae..631b05d8f06 100644 --- a/tests/test_litellm/litellm_core_utils/test_token_counter.py +++ b/tests/test_litellm/litellm_core_utils/test_token_counter.py @@ -1130,6 +1130,40 @@ def test_token_counter_with_anthropic_document_file(): assert tokens > 0, f"Expected positive token count, got {tokens}" +def test_token_counter_with_anthropic_document_text(): + """ + Anthropic-native document block with source.type='text' tokenizes the + inline text in source['data'], not the flat DEFAULT_IMAGE_TOKEN_COUNT + fallback used for opaque (base64/url/file) payloads. + """ + inline_text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " * 200 + messages = [ + { + "role": "user", + "content": [ + { + "type": "document", + "source": { + "type": "text", + "media_type": "text/plain", + "data": inline_text, + }, + "title": "Long inline doc", + } + ], + } + ] + + tokens = token_counter( + model="anthropic/claude-sonnet-4-5-20250929", messages=messages + ) + # The inline text alone is well over a thousand tokens — substantially + # more than the 250-token fallback used for opaque sources. + assert ( + tokens > 500 + ), f"Expected text-source document to tokenize source['data'], got {tokens}" + + def test_token_counter_with_image_inside_tool_result(): """ Image block nested inside tool_result.content is counted via recursion.