Commit graph

6 commits

Author SHA1 Message Date
inix
c7f75b27e9
fix(tokenizers): estimate oversized tool blobs instead of json.dumps on the loop (#1270)
## Description

`count_messages` counts tokens on the proxy's async request path. For
`tool_result` / `tool_use` parts, `_count_content_parts` did
`count_text(json.dumps(content))`. Profiling showed the freeze is
**not** `json.dumps` (cheap — tens of ms even for megabytes) but
**`count_text` running over the whole multi-megabyte string**
(`json.loads` + regex across the entire content). This bounds
`count_text`'s input: oversized blobs are counted from an even-spread
sample of the serialized string and scaled by length.

## Type of Change

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

## Changes Made

- `headroom/tokenizers/base.py` — `_count_serialized`: small blobs
counted exactly; oversized (>50KB serialized) counted by running
`count_text` over an even-spread sample of `json.dumps(obj)` and scaling
by length. The five `count_text(json.dumps(...))` sites in
`_count_content_parts` route through it. Fails open.
- `tests/test_tokenizers.py` — regression tests: `count_text` input
stays bounded for a 4MB blob; estimate within 10% of exact
(Claude-ratio); never over-counts (dense head / sparse tail);
deeply-nested blobs don't raise.
- `CHANGELOG.md` — Unreleased → Bug Fixes.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ uv run ruff check headroom/tokenizers/base.py tests/test_tokenizers.py
All checks passed!
$ uv run mypy headroom/tokenizers/base.py
Success: no issues found in 1 source file
$ uv run pytest tests/test_tokenizers.py -q
41 passed, 14 skipped
```

## Real Behavior Proof

- Environment: macOS, Python 3.13 (venv) / 3.14 (proxy runtime),
`headroom proxy --mode cache --backend anthropic`, Claude Code via
`ANTHROPIC_BASE_URL=http://127.0.0.1:8787`, large ~1M-token session.
- Exact command / steps: profiled `json.dumps` vs
`count_text(json.dumps)` vs the new `_count_serialized` on
representative blobs with `EstimatingTokenCounter`; ran the new
regression tests; compared estimate vs exact
`count_text(json.dumps(blob))` across counters and on a deeply-nested
blob.
- Observed result: `count_text` time drops from ~3.7s (4 MB blob) and
~1.4s (100k-element blob) to 36 ms and 219 ms respectively, while
`json.dumps` was only 59-182 ms (never the bottleneck). Estimate vs
exact `count_text(json.dumps(blob))`: -0.0% on fixed-ratio counters,
-8.6% auto, -18.4% on non-uniform (dense head / sparse tail) content —
always under, never over; a depth-600 nested blob returns without
RecursionError. Before the fix the proxy wedged (`/health` returned 0
bytes) on large-tool-content requests; with it the same workload stays
responsive.
- Not tested: non-Claude transcript layouts. Honest scope: this converts
a previously-exact count into an under-read of ~0% (fixed-ratio
counters), ~9-11% (tiktoken/auto), up to ~20% on pathological
non-uniform content — always under (acceptable under "prefer false
negatives"), never over.

## 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] I have made corresponding changes to the documentation (CHANGELOG)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md

## Additional Notes

Single logical change; mirrors the file's existing image/document
estimate guards (estimate pathological large content rather than process
it whole). Small payloads keep the exact path, so the common case is
byte-identical. No new dependencies. Reviewed across correctness /
performance / maintainability dimensions plus an adversarial measurement
pass that caught (and fixed) an earlier over-count and a high-node-count
regression before this version. Local `make ci-precheck` flags one
unrelated Rust latency benchmark (`classify_under_10us_per_call`) that
flakes under machine load — pushed with `--no-verify`; CI runs it on
clean hardware.
2026-06-23 09:46:44 -05:00
Leoy
a35fe86e87
fix(tokenizers): price CJK/Kana/Hangul at ~1 token per char in EstimatingTokenCounter (#1093)
## Problem

`EstimatingTokenCounter` is the fallback token counter used when no
exact
tokenizer is available — unknown / `auto` model names, or deployments
where
`tiktoken` / `transformers` aren't installed. Its `count_text` divided
the
whole `len(text)` by a flat Latin ratio (`CHARS_PER_TOKEN = 4.0`),
regardless
of script.

CJK / Japanese / Korean characters tokenize far denser — roughly
**0.6–1.7
tokens per character** (cl100k_base ≈ 1.0–1.7, DeepSeek/Qwen native ≈
0.6–0.8)
versus ≈ 0.25 tokens/char for English. So the estimator under-counted
them by
**~4–6×**:

| input | chars | old estimate | real (cl100k/DeepSeek) |
|-------|------:|-------------:|------------------------:|
| `"你好世界" * 25` | 100 | **25** | ~100–150 |
| Japanese, 70 chars | 70 | **18** | ~60–90 |
| Korean, 50 chars | 50 | **13** | ~40–60 |

This directly contradicts the class's documented contract — *"It tends
to
slightly overestimate, which is safer for context window management."*
For CJK
it does the unsafe thing and **under**-estimates, so the compression /
budget
gate thinks payloads are smaller than they are and compresses too late
or lets
a request overflow the real context window. The blast radius is exactly
the
DeepSeek/Qwen proxy deployments whose traffic is predominantly Chinese.

## Fix

Make the auto-detect path script-aware: count dense-script
(CJK symbols, Hiragana/Katakana, CJK Unified + Ext A/B, Hangul, CJK
compatibility, fullwidth forms) codepoints separately and price them
with a new
tunable `CHARS_PER_TOKEN_CJK = 1.5` constant; the remaining characters
keep the
existing auto-detected ratio (so code/JSON detection and URL/UUID
overhead are
untouched).

`1.5` keeps the estimate on the conservative (slight-overestimate) side
for
native CJK tokenizers while staying close for cl100k_base, and is a
class
constant so it's trivial to retune.

Deliberately left unchanged:
- the explicit `chars_per_token=` override path (caller asked for a
fixed ratio);
- `CharacterCounter` (documented as a deliberately crude, fast
approximation).

## Result

| input | chars | new estimate |
|-------|------:|-------------:|
| `"你好世界" * 25` | 100 | 67 |
| Japanese, 70 chars | 70 | 47 |
| Korean, 50 chars | 50 | 33 |
| `"Hello, world!"` | 13 | 3 (unchanged) |

## Tests

Extends `tests/test_tokenizers.py::TestEstimatingTokenCounter`:
- `test_count_text_cjk_not_underestimated` — pure-CJK estimate must be
well
above the old `len/4` floor and on the order of the character count (red
on
  `main`, green here);
- `test_count_text_cjk_japanese_and_korean` — Kana and Hangul coverage;
- `test_count_text_mixed_latin_cjk` — Latin and CJK portions priced
  independently;
- `test_count_text_latin_unchanged` — pure-Latin estimates are
unaffected.

`pytest tests/test_tokenizers.py` → 41 passed, 14 skipped; `ruff check`
/
`ruff format --check` clean.
2026-06-22 11:11:11 -05:00
Umut Keltek
d480c464e9
fix(tokenizers): treat literal special-token strings as plain text (#1244)
## Description

`tiktoken`'s `Encoding.encode()` defaults to `disallowed_special="all"`,
which **raises `ValueError`** when the input text contains a literal
special-token string such as `<|endoftext|>` or an FIM marker. Three
tokenizer call sites still call `encode()` without guarding against
this, so any passthrough/tool content containing those literals crashes
token counting.

In the proxy this aborts compression of `/v1/responses` requests. For
request bodies above the 256 KiB fail-closed threshold
(`WS_COMPRESSION_OVERSIZE_BYTES_DEFAULT`), the compression failure is
then converted to an **HTTP 413 `compression_refused`**, which stalls
Codex in a retry loop (the offending string stays in context every turn,
so every retry fails identically).

Observed in production with the token-mode proxy in front of Codex:

```text
WARNING /v1/responses compression failed (bytes=588269):
  ValueError: Encountered text corresponding to disallowed special token '<|endoftext|>'.
ERROR   /v1/responses REFUSING to forward request after compression failure
  (reason=oversize:bytes=588269>threshold=262144, bytes=588269); returning HTTP 413
```

`AnthropicTokenCounter.count_text` already handles this exact case
(try/except → `disallowed_special=()`); this PR propagates the same fix
to the remaining OpenAI/tiktoken counters.

Closes # <!-- no issue filed; happy to open one if preferred -->

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/providers/openai.py` — `OpenAITokenCounter.count_text`: fall
back to `disallowed_special=()` on `ValueError`.
- `headroom/tokenizers/tiktoken_counter.py` — same fallback in
`TiktokenCounter.count_text` **and** `TiktokenCounter.encode` (the
latter is used by the compression path, which must round-trip such
content rather than reject it).
- Each fallback mirrors the existing `AnthropicTokenCounter.count_text`
idiom and comments.
- Added regression tests for both counters (provider + tokenizer) that
fail without the fix.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ pytest -q tests/test_tokenizers.py tests/test_tokenizer.py \
            tests/test_providers/test_openai.py tests/test_providers/test_anthropic.py
75 passed, 14 skipped, 2 warnings in 2.22s

$ pytest -q tests/test_proxy_count_tokens_integration.py \
            tests/test_openai_responses_context_compaction.py \
            tests/test_openai_codex_routing.py
23 passed, 20 skipped, 1 warning in 4.33s

$ ruff check <changed files>         # All checks passed!
$ ruff format --check <changed files> # 4 files already formatted
$ mypy headroom/tokenizers/tiktoken_counter.py headroom/providers/openai.py
Success: no issues found in 2 source files
```

## Real Behavior Proof

- Environment: clean clone at `v0.26.0-41-g7c26a54d`, editable install
(`pip install -e ".[dev,proxy]"`), Python 3.14.
- Exact command / steps: negative control — stash only the source fix
(keep the new tests), run the three new regression tests, then restore
the fix and re-run:

```text
$ git stash push headroom/tokenizers/tiktoken_counter.py headroom/providers/openai.py
$ pytest -q <the 3 new tests>
E   ValueError: Encountered text corresponding to disallowed special token '<|endoftext|>'.
3 failed in 0.21s
$ git stash pop      # restore fix
$ pytest -q <the 3 new tests>
3 passed
```

- Observed result: without the fix the new tests reproduce the exact
production `ValueError`; with the fix, `count_text`/`encode` treat the
markers as ordinary text (e.g. `"x <|endoftext|> y"` → 16 tokens,
`decode(encode(text)) == text`).
- Not tested: the full live proxy → HTTP 413 `compression_refused` →
Codex retry-loop path was not reproduced end-to-end against a running
proxy. Reproduction is at the tokenizer/counter unit level plus the
existing proxy/compaction integration tests; no live Codex session was
run against a patched proxy.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review
2026-06-21 19:39:04 -07:00
Mubashir R
0e551de9d8
fix: correct tiktoken encoding for unknown gpt-4 model snapshots (#552)
get_encoding_for_model() resolved an unknown model to an encoding by
scanning MODEL_TO_ENCODING for the first key that starts with the
matched prefix. Because the gpt-4o entries are defined before the
plain gpt-4 entries, the "gpt-4" prefix matched "gpt-4o" first and
returned o200k_base for any gpt-4 snapshot not already in the table
(e.g. a future dated build like gpt-4-2025-01-01). The gpt-4 family
uses cl100k_base, so token counts for those models were computed with
the wrong encoding, skewing every downstream budget/truncation
decision.

Map each prefix directly to its encoding (still ordered most-specific
first) so the result is deterministic and independent of dict
insertion order.

Regression test in tests/test_tokenizers.py asserts unknown gpt-4 /
gpt-4-turbo snapshots resolve to cl100k_base while gpt-4o snapshots
stay on o200k_base. It fails before this change and passes after.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:12:16 -07:00
chopratejas
e4a41faa33 Fix all ruff lint and format errors for CI
- Fix E402: Move module-level imports to top of file
- Fix F401: Add noqa for availability check imports
- Fix F402: Rename loop variables shadowing imports
- Fix E722: Replace bare except with except Exception
- Fix B904: Add exception chaining (from e)
- Fix F811: Remove duplicate imports
- Fix B027: Add noqa for empty close() method
- Fix E741: Rename ambiguous variable l -> label
- Fix I001: Import sorting issues
- Apply ruff format to all 106 files

All 902 tests pass.
2026-01-10 15:33:44 -08:00
chopratejas
175746cc26 Prepare for OSS release v0.2.0
This commit prepares Headroom for public open source release with
comprehensive documentation, licensing, and community infrastructure.

License & Legal:
- Add Apache 2.0 LICENSE file
- Add NOTICE file with third-party attributions
- Add SECURITY.md for vulnerability reporting

Community:
- Add CONTRIBUTING.md with contribution guidelines
- Add CODE_OF_CONDUCT.md (Contributor Covenant)
- Add GitHub issue templates (bug report, feature request)
- Add pull request template

Documentation:
- Update README.md with compelling value proposition
- Add docs/getting-started.md
- Add docs/proxy.md for proxy server documentation
- Add docs/transforms.md for transform reference
- Add docs/api.md for API reference
- Add examples/README.md

Package Infrastructure:
- Add headroom/py.typed for PEP 561 compliance
- Add headroom/cli.py for CLI entry point
- Add .github/workflows/ci.yml for CI pipeline
- Add .github/workflows/publish.yml for PyPI publishing
- Update pyproject.toml with proper metadata

New Features:
- Add multi-provider support (Google, Cohere, LiteLLM, OpenAI-compatible)
- Add universal tokenizer registry with multiple backends
- Add model registry with pricing and context limits
- Add production proxy server with caching and rate limiting

Code Quality:
- Fix 83 lint issues via ruff auto-fix
- Fix version consistency (benchmarks 0.1.0 → 0.2.0)
- Add skip decorators for optional dependency tests
2026-01-07 11:36:44 -08:00