Skip to content

fix(tokenizers): unwrap BatchEncoding in HuggingFaceTokenizer.count_messages#2447

Open
nvm wants to merge 1 commit into
headroomlabs-ai:mainfrom
nvm:fix/hf-tokenizer-batchencoding-count
Open

fix(tokenizers): unwrap BatchEncoding in HuggingFaceTokenizer.count_messages#2447
nvm wants to merge 1 commit into
headroomlabs-ai:mainfrom
nvm:fix/hf-tokenizer-batchencoding-count

Conversation

@nvm

@nvm nvm commented Jul 20, 2026

Copy link
Copy Markdown

Description

With a recent transformers, apply_chat_template(..., tokenize=True) returns a BatchEncoding (dict-like with input_ids/attention_mask), so len(formatted) in HuggingFaceTokenizer.count_messages() yields the key count — 2 — for any conversation. The proxy then computes original_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_ids when a dict-like is returned, keeping the legacy flat-list path intact.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)

Changes Made

  • headroom/tokenizers/huggingface.py: in count_messages, return len(formatted["input_ids"]) when apply_chat_template returns a BatchEncoding/dict-like; fall through to len(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

  • Unit tests pass (pytest)
  • Linting passes (ruff check .)
  • Type checking passes (mypy headroom)
  • New tests added for new functionality
  • Manual testing performed

Test Output

$ pytest tests/test_huggingface_batchencoding_count.py tests/test_tokenizers.py tests/test_huggingface_tokenizer_timeout.py -q
62 passed, 14 skipped, 1 warning in 0.64s

$ ruff check headroom/tokenizers/huggingface.py tests/test_huggingface_batchencoding_count.py
All checks passed!

Real Behavior Proof

  • Environment: macOS arm64, Python 3.12, headroom-ai 0.32.1 from PyPI (pip install "headroom-ai[all]", July 2026), headroom proxy --mode token --openai-api-url https://api.deepseek.com, model deepseek-chat.
  • Exact command / steps: 13-message conversation (~25 KB system prompt + three ~50 KB JSON tool results) sent to /v1/chat/completions through the proxy; before/after compared via proxy.log.
  • Observed result: before the fix the proxy logs Optimization inflated tokens (2 -> 28539), reverting to original messages and tok_saved=0 transforms=none; after the fix the same request logs tok_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.
  • Not tested: mypy (fails in my env on an unrelated numpy stub error before reaching this file); other HF-tokenized providers beyond DeepSeek.

Review Readiness

  • I have performed a self-review
  • This PR is ready for human review

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I did not edit CHANGELOG.md — it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this)

Additional Notes

  • Documentation: N/A — internal token-counting fix, no user-facing behavior change beyond restoring the documented compression.
  • An alternative is apply_chat_template(..., return_dict=False), but the explicit unwrap is robust across transformers versions in both directions.

…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.
@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

PR governance

This PR follows the template and is marked ready for human review.

@github-actions github-actions Bot added the status: needs author action Pull request body or readiness checklist still needs author updates label Jul 20, 2026
@nvm nvm closed this Jul 20, 2026
@nvm nvm reopened this Jul 20, 2026
@github-actions github-actions Bot added status: ready for review Pull request body is complete and the author marked it ready for human review and removed status: needs author action Pull request body or readiness checklist still needs author updates labels Jul 20, 2026

@JerrettDavis JerrettDavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status: ready for review Pull request body is complete and the author marked it ready for human review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants