fix(token_counter): count an Anthropic fallback block as zero on every count route

A client replays the fallback block from a server-side fallback in its history. The API does not render it, so it costs zero tokens, but /utils/token_counter answered 500 and /v1/messages/count_tokens answered 400 on it. The local counter now skips the block, the conversion guard accepts it, and provider counts send the fallback beta those APIs need to accept it.
This commit is contained in:
mateo-berri 2026-10-30 16:04:17 -07:00
parent faf9ee18e9
commit 6b45fad58c
8 changed files with 85 additions and 2 deletions

View file

@ -61,6 +61,7 @@ from litellm.llms.custom_httpx.http_handler import get_httpx_client, header_valu
from litellm.router_utils.deployment_pricing import deployment_pricing_models
from litellm.rust_bridge.tokenizer import get_encoding
from litellm.types.llms.anthropic import (
ANTHROPIC_UNRENDERED_BLOCK_TYPES,
AnthropicContentParamSource,
AnthropicContentParamSourceFileId,
AnthropicContentParamSourceUrl,
@ -1703,6 +1704,8 @@ def _count_content_list(
block_text = str(c.get(_TEXT_FIELD_BY_BLOCK_TYPE[c["type"]]) or "")
if block_text:
num_tokens += count_function(block_text)
elif c["type"] in ANTHROPIC_UNRENDERED_BLOCK_TYPES:
continue
elif c["type"] == "image_url":
image_url = c.get("image_url")
num_tokens += _count_image_tokens(image_url, use_default_image_token_count)
@ -1752,7 +1755,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, fallback)."
)
return num_tokens
except Exception as e:

View file

@ -202,6 +202,15 @@ def carries_signed_compaction_block(messages: Sequence[object]) -> bool:
return any(map(_is_signed_compaction_block, chain.from_iterable(map(_message_blocks, messages))))
def _is_fallback_block(block: object) -> bool:
fields: Final = as_str_mapping(block)
return fields is not None and fields.get("type") == "fallback"
def carries_fallback_block(messages: Sequence[object]) -> bool:
return any(map(_is_fallback_block, chain.from_iterable(map(_message_blocks, messages))))
def requires_native_compaction_beta(
custom_llm_provider: str,
optional_params: Mapping[str, object],
@ -265,6 +274,7 @@ def count_tokens_feature_betas(
ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER,
AnthropicModelInfo().is_mid_conversation_tool_change_used(messages),
),
(ANTHROPIC_BETA_HEADER_VALUES.SERVER_SIDE_FALLBACK_2026_06_01.value, carries_fallback_block(messages)),
)
if used
)

View file

@ -27,6 +27,7 @@ from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import
)
from litellm.litellm_core_utils.url_utils import join_api_base
from litellm.llms.anthropic.common_utils import (
carries_fallback_block,
carries_signed_compaction_block,
context_management_betas,
context_management_with_edits,
@ -436,15 +437,22 @@ class AnthropicCountTokensConfig:
bearer for ``ANTHROPIC_AUTH_TOKEN`` and for sk-ant-oat tokens, whose mandatory oauth beta
merges with the token-counting beta instead of replacing it. A history that carries a
signed compaction block also needs the compaction beta, or Anthropic rejects the block
as an unknown content type, and ``context_management`` edits need the betas inference sends."""
as an unknown content type. A history that echoes a fallback block needs the fallback beta
the same way, and ``context_management`` edits need the betas inference sends."""
compaction_betas: Final = (
(ANTHROPIC_BETA_HEADER_VALUES.COMPACT_2026_09_04.value,)
if carries_signed_compaction_block(messages)
else ()
)
fallback_betas: Final = (
(ANTHROPIC_BETA_HEADER_VALUES.SERVER_SIDE_FALLBACK_2026_06_01.value,)
if carries_fallback_block(messages)
else ()
)
request_betas: Final = (
ANTHROPIC_TOKEN_COUNTING_BETA_VERSION,
*compaction_betas,
*fallback_betas,
*context_management_betas(context_management),
)
return {

View file

@ -13,6 +13,7 @@ import litellm
from litellm.litellm_core_utils.core_helpers import as_str_mapping, drops_unsupported_params
from litellm.litellm_core_utils.prompt_templates.factory import sanitize_anthropic_tool_use_id
from litellm.llms.anthropic.messages.native.utils import normalize_native_messages
from litellm.types.llms.anthropic import ANTHROPIC_UNRENDERED_BLOCK_TYPES
from litellm.types.utils import TokenCountMessagesFormat
_CONVERTIBLE_BLOCK_TYPES: Final = MappingProxyType(
@ -57,6 +58,8 @@ def _block_label(block: JsonValue, convertible: frozenset[str]) -> str | None:
block_type: Final = block.get("type")
if not isinstance(block_type, str):
return "a block without a type"
if block_type in ANTHROPIC_UNRENDERED_BLOCK_TYPES:
return None
if block_type not in convertible:
return block_type
return _source_label(block_type, block.get("source")) if block_type in _SOURCED_BLOCK_TYPES else None

View file

@ -830,6 +830,9 @@ class ANTHROPIC_HOSTED_TOOLS(str, Enum):
TOOL_SEARCH_TOOL = "tool_search_tool"
ANTHROPIC_UNRENDERED_BLOCK_TYPES: Final = frozenset({"fallback"})
class ANTHROPIC_BETA_HEADER_VALUES(str, Enum):
"""
Known beta header values for Anthropic.
@ -847,6 +850,7 @@ class ANTHROPIC_BETA_HEADER_VALUES(str, Enum):
ADVISOR_TOOL_2026_03_01 = "advisor-tool-2026-03-01"
PER_TURN_CONTROL_2026_07_01 = "per-turn-control-2026-07-01"
DANGEROUS_TOOL_USE_2026_09_03 = "dangerous-tool-use-2026-09-03"
SERVER_SIDE_FALLBACK_2026_06_01 = "server-side-fallback-2026-06-01"
# Tool search beta header constant (for Anthropic direct API and Microsoft Foundry)

View file

@ -1982,6 +1982,23 @@ def test_token_counter_with_compaction_block():
assert token_counter(model=model, messages=unsigned) == token_counter(model=model, messages=as_text)
@pytest.mark.parametrize("model", ("anthropic/claude-sonnet-5-5", "gpt-5.4-mini", "deepseek/deepseek-v4-flash"))
def test_token_counter_counts_nothing_for_a_fallback_block(model: str) -> None:
user_turn = {"role": "user", "content": "hi"}
reply = {"type": "text", "text": "Hello"}
fallback_block = {
"type": "fallback",
"from": {"model": "first-model"},
"to": {"model": "second-model"},
"trigger": {"type": "refusal", "category": "cyber"},
}
with_block = [user_turn, {"role": "assistant", "content": [reply, fallback_block, reply]}]
without_block = [user_turn, {"role": "assistant", "content": [reply, reply]}]
assert token_counter(model=model, messages=with_block) == token_counter(model=model, messages=without_block)
def test_token_counter_counts_chat_compaction_blocks_like_their_summary_text():
"""
Chat clients replay a compaction block in the assistant message's provider_specific_fields, and inference

View file

@ -79,6 +79,14 @@ def test_blocks_both_conversions_drop_are_named(role: str, block: Mapping[str, o
assert unconvertible_anthropic_blocks([{"role": role, "content": [block]}]) == (label,)
@pytest.mark.parametrize("role", ("user", "assistant"))
def test_a_fallback_block_is_accepted_because_it_renders_nothing_to_lose(role: str) -> None:
block: Final = {"type": "fallback", "from": {"model": "first"}, "to": {"model": "second"}}
assert _converted_by_both_adapters(role, block) == (False, False)
assert unconvertible_anthropic_blocks([{"role": role, "content": [block]}]) == ()
def test_labels_are_listed_once_in_order_and_string_content_is_accepted() -> None:
messages: Final = [
{"role": "user", "content": "plain text"},

View file

@ -28,9 +28,21 @@ from litellm.llms.azure_ai.anthropic.count_tokens.transformation import (
)
from litellm.types.utils import CountContextManagement, TokenCountMessagesFormat, TokenCountResponse
from litellm.constants import ANTHROPIC_TOKEN_COUNTING_BETA_VERSION
from litellm.llms.anthropic.common_utils import count_tokens_feature_betas
from litellm.types.llms.anthropic import ANTHROPIC_BETA_HEADER_VALUES
COMPACTION_BETA = ANTHROPIC_BETA_HEADER_VALUES.COMPACT_2026_09_04.value
FALLBACK_BETA = ANTHROPIC_BETA_HEADER_VALUES.SERVER_SIDE_FALLBACK_2026_06_01.value
FALLBACK_HISTORY = [
{"role": "user", "content": "hi"},
{
"role": "assistant",
"content": [
{"type": "text", "text": "Hello"},
{"type": "fallback", "from": {"model": "first-model"}, "to": {"model": "second-model"}},
],
},
]
COMPACTED_HISTORY = [
{
"role": "assistant",
@ -921,6 +933,24 @@ def test_headers_add_the_compaction_beta_only_for_a_compacted_history():
assert plain == ANTHROPIC_TOKEN_COUNTING_BETA_VERSION
def test_headers_add_the_fallback_beta_only_for_a_history_that_echoes_the_block():
config = AnthropicCountTokensConfig()
auth_header = {"x-api-key": "sk-ant-api03-test-key"}
with_block = config.get_count_tokens_headers(auth_header, FALLBACK_HISTORY)["anthropic-beta"].split(",")
plain = config.get_count_tokens_headers(auth_header, [{"role": "user", "content": "hi"}])["anthropic-beta"]
assert FALLBACK_BETA in with_block
assert ANTHROPIC_TOKEN_COUNTING_BETA_VERSION in with_block
assert plain == ANTHROPIC_TOKEN_COUNTING_BETA_VERSION
@pytest.mark.parametrize("provider", ("azure_ai", "vertex_ai"))
def test_platform_count_betas_carry_the_fallback_beta_only_for_a_history_that_echoes_the_block(provider: str) -> None:
assert FALLBACK_BETA in count_tokens_feature_betas(provider, {}, FALLBACK_HISTORY)
assert FALLBACK_BETA not in count_tokens_feature_betas(provider, {}, [{"role": "user", "content": "hi"}])
@pytest.mark.asyncio
async def test_handler_sends_the_compaction_beta_with_a_compacted_history(httpx_transport_clients):
with respx.mock: