headroom/tests/test_provider_counter_content_blocks.py
Tejas Chopra 06add9e9d8
fix(providers): stop pricing modern content blocks at zero (#2760)
## Description

Each token 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, via `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** |

Two things make this worse than a coverage gap:

1. **Each counter zeroed blocks from its own provider.** `output_text`
and `refusal` are OpenAI Responses shapes; `thinking` and `document` are
Anthropic's.
2. **These are the counters the live pipelines use.** `proxy/server.py`
builds them with `AnthropicProvider` / `OpenAIProvider`, so this is the
main request path — not an edge case. #2743 fixed this for
`/v1/compress` only, by routing that route to the registry tokenizers,
whose `BaseTokenizer` walker is complete.

Closes #

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

Rather than add a **fifth** partial walker, the counters now delegate to
the audited one:

- `tokenizers/base.py` — new `count_content_blocks(parts,
count_text_fn)` plus a thin `_DelegatingBlockCounter` adapter, since the
provider counters are not `BaseTokenizer` subclasses. `BaseTokenizer`
itself is untouched.
- `providers/openai.py`, `providers/anthropic.py`,
`providers/openai_compatible.py` — list-content branches delegate.

**Why delegate instead of adding a `count_text(str(block))` catch-all:**
that would serialize a base64 blob and price it as text.
`tiktoken_counter.py` already documents the failure — a 1MB image
becomes ~330K phantom tokens. The shared walker gives media a
pixel/byte-based estimate.

**Scope:** the three counters that accumulate token counts. `google.py`
and `cohere.py` extract a *text string* first and count that, so the
same defect there needs a differently-shaped fix — left as a follow-up.

## Testing

- [x] Unit tests pass
- [x] Linting passes (`ruff check` + `format --check`, pinned 0.15.17)
- [x] New tests added
- [x] Manual testing performed

### Test Output

```text
$ pytest tests/test_provider_counter_content_blocks.py -q
12 passed in 0.52s

$ uvx ruff@0.15.17 check headroom/ tests/... --exclude headroom/dashboard/templates
All checks passed!
```

**After the fix**, every shape lands within ~1% of the equivalent plain
text, and media stays bounded:

```text
block                  OpenAI  Anthropic
text (control)           3409       3748
tool_result              3409       3748
thinking                 3419       3759
document                 3423       3763
mcp_tool_result          3422       3762
output_text              3420       3760
refusal                  3421       3761
image b64 200KB          1608       1607   <- pixel estimate, not ~50K as text
```

## Real Behavior Proof — including a regression I caught

**This change flipped an existing test**, and I only found it because
every suite was run against clean `upstream/main` in the same
environment with the failure sets diffed:

```text
before the test rewrite:
  upstream/main : 1 failed, 104 passed
  this branch   : 2 failed, 103 passed        <- regression
  diff          : + test_openai_compatible_token_counter_ignores_unhandled_content_shapes
```

That test asserted `content: [{"type": "image"}, 123] == 8` — i.e. it
**pinned the defect**, that unhandled shapes contribute nothing.
Rewritten as `..._prices_declared_media`: a declared image is now priced
(1608) while a bare int is still correctly ignored (8), with the
rationale in the docstring.

```text
after the rewrite:
  upstream/main : 1 failed, 104 passed, 10 skipped, 25 errors
  this branch   : 1 failed, 104 passed, 10 skipped, 25 errors
  failure sets  : IDENTICAL
```

- **Pre-existing, not from this change:** the 1 failure and all 25
errors. The errors are all in
`test_compress_route_tokenizer_by_model.py`, whose loopback `TestClient`
fixture this throwaway env cannot satisfy.
- **Environment note:** `content_router` and several suites need the
compiled `headroom._core`, which isn't in a fresh worktree (gitignored,
built in-place). I copied the built `.so` in to run these and removed it
before committing.
- **Not tested:** no live provider call, so the *absolute* accuracy of
the 1600 image estimate against a real Anthropic/OpenAI bill is
unverified — it is the value `BaseTokenizer` already used, and this PR
only changes which blocks reach it.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] I did **not** edit `CHANGELOG.md`

## Related

Fourth PR from one tokenizer-consistency audit: #2757 (litellm total
prompt / `--budget`), #2758 (HuggingFace chat templates, `gpt-5`,
gateway-wrapped names), #2759 (router token units). Plus #2756, which
splits the local/provider token scales in `RequestOutcome`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-08-03 22:40:38 -07:00

106 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