fix(tokenizers): unwrap BatchEncoding in HuggingFaceTokenizer.count_messages#2447
fix(tokenizers): unwrap BatchEncoding in HuggingFaceTokenizer.count_messages#2447nvm wants to merge 1 commit into
Conversation
…essages Recent transformers return a BatchEncoding from apply_chat_template(tokenize=True); len() of it is the key count (2), not the token count. The proxy then computed original_tokens=2, so the inflation guard reverted every compression on the OpenAI-compatible path (DeepSeek, Qwen, ...) with 0% savings and no error.
PR governanceThis PR follows the template and is marked ready for human review. |
JerrettDavis
left a comment
There was a problem hiding this comment.
Reviewed the latest head commit (ced83d8). The fix targets the real failure mode: recent transformers returns a dict-like tokenizer output from apply_chat_template(..., tokenize=True), and counting input_ids preserves the older flat-list behavior while avoiding the BatchEncoding key-count bug.
Verified locally in an isolated worktree:
- uv run ruff check headroom/tokenizers/huggingface.py tests/test_huggingface_batchencoding_count.py
- uv run pytest tests/test_huggingface_batchencoding_count.py tests/test_huggingface_tokenizer_timeout.py -q
- git diff --check upstream/main...upstream/pr/2447
I also inspected the local transformers 5.13.1 apply_chat_template implementation: with the current default return_dict=True it returns the tokenizer output object, so input_ids is the correct field to count for this call shape. The broader PR-listed tokenizer command hit unrelated default-environment failures here because tiktoken is not installed; the new HF regression tests pass.
Description
With a recent
transformers,apply_chat_template(..., tokenize=True)returns aBatchEncoding(dict-like withinput_ids/attention_mask), solen(formatted)inHuggingFaceTokenizer.count_messages()yields the key count — 2 — for any conversation. The proxy then computesoriginal_tokens = 2, the inflation guard in the OpenAI-compatible handler (optimized_tokens > original_tokens) always fires, and every compression is silently reverted (0% savings,transforms=none, no error surfaced) for HF-tokenized models such as DeepSeek and Qwen.The fix unwraps
input_idswhen a dict-like is returned, keeping the legacy flat-list path intact.Type of Change
Changes Made
headroom/tokenizers/huggingface.py: incount_messages, returnlen(formatted["input_ids"])whenapply_chat_templatereturns aBatchEncoding/dict-like; fall through tolen(formatted)for older transformers that return a flat token list.tests/test_huggingface_batchencoding_count.py: regression tests for both return shapes (BatchEncoding-like dict and legacy flat list).Testing
pytest)ruff check .)mypy headroom)Test Output
Real Behavior Proof
pip install "headroom-ai[all]", July 2026),headroom proxy --mode token --openai-api-url https://api.deepseek.com, modeldeepseek-chat./v1/chat/completionsthrough the proxy; before/after compared via proxy.log.Optimization inflated tokens (2 -> 28539), reverting to original messagesandtok_saved=0 transforms=none; after the fix the same request logstok_before=45944 tok_after=33299 tok_saved=12927 transforms=router:smart_crusher:0.53*3— DeepSeek-billed prompt tokens drop 46209 → 33299 (−28%). Direct repro:get_tokenizer("deepseek-chat").count_messages([{"role":"system","content":"word "*5000}])returns 2 before, correct count after.mypy(fails in my env on an unrelated numpy stub error before reaching this file); other HF-tokenized providers beyond DeepSeek.Review Readiness
Checklist
CHANGELOG.md— it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this)Additional Notes
apply_chat_template(..., return_dict=False), but the explicit unwrap is robust across transformers versions in both directions.