fix(tokenizers): price Claude against a real BPE (tiktoken o200k) not a char estimate (#2543)

## Description

Claude's tokenizer is not public, so `get_tokenizer("claude-*")`
returned `EstimatingTokenCounter(chars_per_token=3.5)` — a
**character-ratio estimate**. The estimator's chars-per-token flips with
the *detected content type* (JSON 3.2 / code 3.5 / English 4.0 / CJK
1.5), so:

- the **same bytes** count differently before vs after compression → a
fold could appear to *increase* tokens;
- it **disagrees with the pipeline's** estimator on the same content.

Calibration vs a real BPE (tiktoken `o200k_base`) on representative
content:

| content | char-est(3.5) / o200k |
|---|---|
| English | **1.28×** (overcount 28%) |
| code | **0.83×** (undercount 17%) |
| JSON | 0.97× |
| diff | 1.02× |

i.e. the estimate is off **−17% … +28%**, and the error is
content-dependent (non-uniform) — which is exactly what produces
impossible `tok_after > tok_before` deltas.

This prices Claude against **tiktoken `o200k_base`** — a real,
deterministic, **monotone** BPE. It's not Claude's exact vocab, but it's
within ~10–20% and, crucially, **consistent before/after**, which is
what compression ratios and context-pressure gating actually need.

Closes #

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [x] Performance improvement
- [ ] Breaking change
- [ ] Documentation update
- [ ] Code refactoring (no functional changes)

## Changes Made

- `TiktokenCounter.__init__` accepts an explicit `encoding` override (so
a model can be priced against a chosen encoding regardless of name-based
resolution).
- `_create_anthropic` now returns `TiktokenCounter(model,
encoding="o200k_base")`, **failing open to the character estimator** if
the tiktoken vocab can't be loaded (reuses the existing `load_encoding`
timeout/guard).
- Updated the `MODEL_PATTERNS` comment and `test_get_anthropic_model` to
the new contract; added
`test_claude_priced_with_real_bpe_not_char_estimate`.

**Blast radius is contained:** `content_router` keeps its own
module-level estimator for routing decisions, so only the **handler's
reported counts** change. `tiktoken` is already a dependency.

**Composes with #2542** (tokenizer-consistent before/after): that PR
makes both endpoints use *one* tokenizer; this PR makes that one
tokenizer a *real BPE*. Together they fully eliminate the
inflated/phantom lines. This PR is based on `main` and is independently
valid.

## Calibration note (please review)

The one behavioral consumer of the handler's count is the
background-compression gate (`original_tokens >=
_background_compression_min_tokens`). Because o200k differs from the old
estimate by ~±20% depending on content, that threshold now trips on
slightly different requests. It's *more* accurate, but the threshold was
tuned against the estimator — worth a re-check. No other gate consumes
the handler count (routing uses `content_router`'s estimator).

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check` + `ruff format --check`)
- [x] Type checking passes (`mypy`)
- [x] New tests added
- [x] Manual testing performed

### Test Output

```text
$ ruff check <changed files> && ruff format --check <changed files>
All checks passed!
4 files already formatted

$ mypy headroom/tokenizers/registry.py headroom/tokenizers/tiktoken_counter.py
Success: no issues found in 2 source files

$ pytest tests/test_tokenizer.py tests/test_tokenizers.py -q
55 passed, 14 skipped
```

## Real Behavior Proof

- **Unit:** `get_tokenizer("claude-opus-4-8")` →
`TiktokenCounter(encoding_name="o200k_base")`; `count_text(sample)` ==
`tiktoken.get_encoding("o200k_base").encode(sample)` exactly; a
`tool_result` shrunk 300→3 words drops 508→13 tokens (folds register).
- **Live proxy** (Anthropic path, `--proxy-extension lossless_guard`):
foldable request logs `tok_before=694 tok_after=231 tok_saved=463
transforms=turn_hook` — real o200k-scale numbers (vs the estimator's 607
for the same payload), clean fold, no inflation.
- **Not tested:** exact agreement with Anthropic's *own* count_tokens
API (o200k is a proxy; a follow-up could calibrate against it offline).
GPT paths unchanged (already tiktoken).

## 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
- [ ] Documentation — N/A (internal; docstrings updated)
- [x] My changes generate no new warnings
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`

## Additional Notes

Follow-ups (not here): (1) extend the same real-BPE treatment to other
private-tokenizer providers (Gemini/Cohere/Moonshot), each with its own
calibration; (2) optional opt-in calibration against Anthropic's
`count_tokens` API to true-up the ~10–20% absolute offset.
This commit is contained in:
Tejas Chopra 2026-07-24 15:06:43 -07:00 committed by GitHub
parent 1cc53c9c92
commit 285176be54
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 91 additions and 11 deletions

View file

@ -36,7 +36,9 @@ MODEL_PATTERNS: list[tuple[str, str]] = [
(r"^curie", "tiktoken"),
(r"^babbage", "tiktoken"),
(r"^ada", "tiktoken"),
# Anthropic models -> estimation (Claude uses custom tokenizer)
# Anthropic models -> real BPE proxy (Claude's tokenizer is private; priced
# against tiktoken o200k_base for consistent, monotone counts — see
# _create_anthropic)
(r"^claude-", "anthropic"),
# Llama family -> huggingface (when available)
(r"^llama", "huggingface"),
@ -335,13 +337,33 @@ class TokenizerRegistry:
return EstimatingTokenCounter()
def _create_anthropic(self, model: str) -> TokenCounter:
"""Create Anthropic tokenizer.
"""Create Anthropic (Claude) tokenizer.
Anthropic uses a custom tokenizer that's not publicly available.
We use estimation calibrated for Claude models.
Anthropic's tokenizer isn't public. Rather than a character-ratio
estimate whose chars-per-token flips with the detected content type
(JSON 3.2 / code 3.5 / English 4.0), so compressing text can appear to
*increase* tokens and two components disagree on the same bytes price
Claude against a real BPE (tiktoken ``o200k_base``) as a stable, monotone
proxy. It is not Claude's exact vocab, but it is deterministic,
consistent before/after, and within ~10-20% of Claude's real counts —
which is what compression ratios and context-pressure gating need. Falls
back to the character estimator if the tiktoken vocab can't be loaded.
"""
# Claude models use ~3.5 chars per token on average
return EstimatingTokenCounter(chars_per_token=3.5)
try:
from .tiktoken_counter import (
TiktokenCounter,
TiktokenLoadError,
load_encoding,
)
try:
load_encoding("o200k_base")
except TiktokenLoadError:
logger.info("tiktoken o200k_base unavailable for %s; using char estimator", model)
return EstimatingTokenCounter(chars_per_token=3.5)
return TiktokenCounter(model, encoding="o200k_base")
except Exception: # pragma: no cover - defensive; keep counting alive
return EstimatingTokenCounter(chars_per_token=3.5)
def _create_google(self, model: str) -> TokenCounter:
"""Create Google tokenizer.

View file

@ -212,15 +212,19 @@ class TiktokenCounter(BaseTokenizer):
MESSAGE_OVERHEAD = 3
REPLY_OVERHEAD = 3
def __init__(self, model: str = "gpt-4o"):
def __init__(self, model: str = "gpt-4o", encoding: str | None = None):
"""Initialize tiktoken counter.
Args:
model: Model name to determine encoding.
Defaults to 'gpt-4o' (o200k_base encoding).
encoding: Explicit tiktoken encoding name (e.g. 'o200k_base') that
overrides model-based resolution. Used to price
private-tokenizer models (Claude) against a real BPE proxy
instead of a character estimate.
"""
self.model = model
self.encoding_name = get_encoding_for_model(model)
self.encoding_name = encoding or get_encoding_for_model(model)
self._encoding = None # Lazy load
@property

View file

@ -97,7 +97,20 @@ def test_load_session_replay_groups_assistant_request_events(tmp_path: Path) ->
def test_simulation_and_winner_logic() -> None:
tool_blob = '{"rows":[1,2,3,4]}' * 80
# Realistic varied tool output. A pathologically repetitive blob (the same
# JSON object * N) is a degenerate case for a real BPE tokenizer — it merges
# the repetition to near-nothing — so a token-mode rewrite can cost MORE real
# tokens than the original, which the old character estimate masked by
# over-counting the repetition. Varied records keep the fixture representative
# of real agent tool output, where the rewrite is a genuine win.
tool_blob = json.dumps(
{
"rows": [
{"id": i, "label": f"row-{i}", "value": (i * 37) % 100, "ok": i % 3 == 0}
for i in range(150)
]
}
)
turn1 = ReplayTurn(
session_id="s1",
project_key="C--git-demo",

View file

@ -22,6 +22,41 @@ class FakeTokenCounter:
return sum(len(str(msg.get("content", "")).split()) for msg in messages)
def test_claude_priced_with_real_bpe_not_char_estimate() -> None:
"""Claude has no public tokenizer, so we price it against a real BPE
(tiktoken o200k_base) instead of a content-adaptive character estimate
otherwise before/after counts drift between components and compressing text
can appear to *increase* tokens. A tool_result fold must always register as
a reduction; and when the vocab is available the count is the exact o200k
count (proving it is a real BPE, not a chars/token ratio)."""
from headroom.tokenizers import get_tokenizer
tok = get_tokenizer("claude-opus-4-8")
long_msg = [
{
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": "t", "content": "alpha " * 300}],
}
]
short_msg = [
{
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": "t", "content": "alpha " * 3}],
}
]
assert tok.count_messages(long_msg) > tok.count_messages(short_msg) # fold visible
try:
import tiktoken
enc = tiktoken.get_encoding("o200k_base")
except Exception: # vocab unavailable → estimator fallback; monotonicity above still holds
return
sample = "The quick brown fox jumps over the lazy dog. " * 10
assert tok.count_text(sample) == len(enc.encode(sample))
def test_tokenizer_delegates_to_counter() -> None:
counter = FakeTokenCounter()
tokenizer = Tokenizer(counter, model="gpt-4o")

View file

@ -363,9 +363,15 @@ class TestTokenizerRegistry:
assert isinstance(tokenizer, TiktokenCounter)
def test_get_anthropic_model(self):
"""Test getting tokenizer for Anthropic model."""
"""Anthropic (Claude) has no public tokenizer, so it is priced against a
real BPE proxy (tiktoken o200k_base) rather than a character estimate
for consistent, monotone before/after counts. Falls back to the estimator
only when the tiktoken vocab can't be loaded."""
tokenizer = get_tokenizer("claude-3-sonnet")
assert isinstance(tokenizer, EstimatingTokenCounter)
if isinstance(tokenizer, TiktokenCounter):
assert tokenizer.encoding_name == "o200k_base"
else: # vocab unavailable in this environment → documented fallback
assert isinstance(tokenizer, EstimatingTokenCounter)
def test_get_unknown_model_fallback(self):
"""Test fallback for unknown model."""