mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
107 lines
4.6 KiB
Python
107 lines
4.6 KiB
Python
|
|
"""Provider token counters must price every content block, not just ``text``.
|
||
|
|
|
||
|
|
Each counter in ``headroom/providers/`` had grown its own shortened content-block
|
||
|
|
walker handling only the shapes its provider was expected to send. Everything else
|
||
|
|
fell through and contributed nothing. Measured on one 6,800-char block
|
||
|
|
(``count_messages`` of a single-block message, so 7-8 is message overhead alone):
|
||
|
|
|
||
|
|
block type OpenAI ctr Anthropic ctr
|
||
|
|
text (control) 3409 3748
|
||
|
|
tool_result 8 3748
|
||
|
|
thinking 8 7
|
||
|
|
document 8 7
|
||
|
|
mcp_tool_result 8 7
|
||
|
|
output_text 8 7
|
||
|
|
refusal 8 7
|
||
|
|
|
||
|
|
Note each counter zeroed blocks from its OWN provider — ``output_text`` and
|
||
|
|
``refusal`` are OpenAI Responses shapes, ``thinking`` and ``document`` are
|
||
|
|
Anthropic's. And these counters are what the LIVE proxy pipelines use
|
||
|
|
(``proxy/server.py`` builds them with ``AnthropicProvider`` / ``OpenAIProvider``),
|
||
|
|
so this was the main request path, not an edge case.
|
||
|
|
|
||
|
|
The counters now delegate to ``count_content_blocks``, which reuses
|
||
|
|
``BaseTokenizer._count_content_parts``. That matters beyond coverage: a naive
|
||
|
|
``count_text(str(block))`` catch-all would serialize a base64 image and price it
|
||
|
|
as text — a 1MB screenshot reads as ~330K phantom tokens. The shared walker gives
|
||
|
|
media a pixel/byte-based estimate instead.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from headroom.providers.anthropic import AnthropicProvider
|
||
|
|
from headroom.providers.openai import OpenAIProvider
|
||
|
|
from headroom.tokenizers.base import count_content_blocks
|
||
|
|
|
||
|
|
_BIG = "x " * 3400 # ~6,800 chars
|
||
|
|
|
||
|
|
|
||
|
|
def _counters():
|
||
|
|
return {
|
||
|
|
"openai": OpenAIProvider().get_token_counter("gpt-4o"),
|
||
|
|
"anthropic": AnthropicProvider(warn=False).get_token_counter("claude-sonnet-4-6"),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
"block",
|
||
|
|
[
|
||
|
|
{"type": "tool_result", "tool_use_id": "t", "content": _BIG},
|
||
|
|
{"type": "tool_use", "id": "t", "name": "grep", "input": {"pattern": _BIG}},
|
||
|
|
{"type": "thinking", "thinking": _BIG},
|
||
|
|
{"type": "document", "source": {"data": _BIG}},
|
||
|
|
{"type": "mcp_tool_result", "content": _BIG},
|
||
|
|
{"type": "output_text", "text": _BIG},
|
||
|
|
{"type": "refusal", "refusal": _BIG},
|
||
|
|
{"type": "search_result", "content": _BIG},
|
||
|
|
],
|
||
|
|
ids=lambda b: str(b.get("type")),
|
||
|
|
)
|
||
|
|
def test_no_provider_counter_prices_a_large_block_at_zero(block: dict) -> None:
|
||
|
|
"""Every one of these returned 7-8 tokens — message overhead only."""
|
||
|
|
message = {"role": "user", "content": [block]}
|
||
|
|
for name, counter in _counters().items():
|
||
|
|
got = counter.count_messages([message])
|
||
|
|
assert got > 1_000, f"{name} priced a ~6,800-char {block['type']} block at {got}"
|
||
|
|
|
||
|
|
|
||
|
|
def test_base64_media_is_not_priced_as_text() -> None:
|
||
|
|
"""The reason a str(block) catch-all would have been the wrong fix."""
|
||
|
|
image = {"type": "image", "source": {"type": "base64", "data": "A" * 200_000}}
|
||
|
|
message = {"role": "user", "content": [image]}
|
||
|
|
|
||
|
|
for name, counter in _counters().items():
|
||
|
|
got = counter.count_messages([message])
|
||
|
|
# 200KB of base64 as text would be ~50,000 tokens; the pixel estimate is 1600.
|
||
|
|
assert got < 5_000, f"{name} priced a 200KB base64 image as text: {got}"
|
||
|
|
assert got > 1_000, f"{name} ignored a declared image entirely: {got}"
|
||
|
|
|
||
|
|
|
||
|
|
def test_plain_text_blocks_are_unchanged() -> None:
|
||
|
|
"""The control: the shape both counters already handled must not move."""
|
||
|
|
message = {"role": "user", "content": [{"type": "text", "text": _BIG}]}
|
||
|
|
for name, counter in _counters().items():
|
||
|
|
text_form = {"role": "user", "content": _BIG}
|
||
|
|
block_form = counter.count_messages([message])
|
||
|
|
# A text block and the equivalent string should agree closely.
|
||
|
|
assert abs(block_form - counter.count_messages([text_form])) <= 5, name
|
||
|
|
|
||
|
|
|
||
|
|
def test_shared_walker_ignores_non_block_parts() -> None:
|
||
|
|
"""A bare int is not a block and must contribute nothing."""
|
||
|
|
assert count_content_blocks([123], len) == 0
|
||
|
|
assert count_content_blocks([], len) == 0
|
||
|
|
|
||
|
|
|
||
|
|
def test_shared_walker_counts_nested_tool_result_blocks() -> None:
|
||
|
|
"""A tool that returns blocks nests them; they must be walked, not serialized."""
|
||
|
|
nested = {
|
||
|
|
"type": "tool_result",
|
||
|
|
"tool_use_id": "t",
|
||
|
|
"content": [{"type": "text", "text": _BIG}],
|
||
|
|
}
|
||
|
|
flat = {"type": "tool_result", "tool_use_id": "t", "content": _BIG}
|
||
|
|
assert abs(count_content_blocks([nested], len) - count_content_blocks([flat], len)) < 100
|