Skip to content

Charge image tokens by area instead of digest length (#783) - #854

Merged
mpfaffenberger merged 1 commit into
mpfaffenberger:mainfrom
phanisaimunipalli:image-token-estimation
Aug 24, 2026
Merged

Charge image tokens by area instead of digest length (#783)#854
mpfaffenberger merged 1 commit into
mpfaffenberger:mainfrom
phanisaimunipalli:image-token-estimation

Conversation

@phanisaimunipalli

Copy link
Copy Markdown

Fixes #783.

stringify_part reduces a BinaryContent to a 16-hex-char digest, and estimate_tokens then scores that string. So an image costs whatever its digest happens to be long, no matter what is in it. Reproduced on main:

2048x2048 PNG -> 16 tokens        (a vision model bills ~5,592)
20 screenshots -> 252 tokens      (real cost ~112,000)

That undercount flows into compact()'s threshold, filter_huge_messages' 50k rule, and the /context badge, which is why an image-heavy session sails past the real limit and dies on a provider 400 with compaction having never run.

While reproducing it I found a second bug in the same loop. The for item in content: arm handles str and BinaryContent and nothing else, so ImageUrl and DocumentUrl contribute nothing:

ImageUrl("https://a.example/1.png")  -> 'user-prompt'
ImageUrl("https://b.example/other.png") -> 'user-prompt'
hashes collide: True

Two different images dedup into one.

What this does

estimate_tokens_for_message now adds a per-BinaryContent charge from real image dimensions, using (width * height) / 750 — the ratio Anthropic documents, and close enough to OpenAI's tile math. Dimensions come from Pillow, already a dependency; Image.open parses just the header, so no pixel decoding on the estimation path.

Unreadable images and non-image attachments (PDFs and such) take a deliberately generous constant rather than a small one. Overcharging costs an early compaction; undercharging is what kills the run.

stringify_part still emits the digest exactly as before, so the charge is purely additive and existing dedup hashes are untouched. There's a test pinning that.

The list loop gets an else arm appending repr(item), which fixes both the zero-token contribution and the hash collision.

After:

before after
2048x2048 image 16 5,608
20 screenshots 252 112,092
corrupt image / PDF 16 1,516
plain text message 12 12
different ImageUrls collide yes no

Testing

12 new tests in tests/agents/test_history_binary_tokens.py: the 2048x2048 case from the issue, area scaling, accumulation across 20 screenshots, both fallback paths, png/jpeg/webp, the collision fix, and two guards that text-only estimates and digest-based hashing are unchanged.

I checked the tests actually fail when each fix is reverted — dropping the binary charge fails the large-image test, dropping the else arm fails the collision test — so they are not passing vacuously.

tests/agents/ is 415 passed. ruff check and ruff format clean at 0.15.

)

stringify_part reduces a BinaryContent to a 16-hex-char digest, and
estimate_tokens then scores that string. A 2048x2048 screenshot came out
at 16 tokens against the ~5600 a vision model actually bills. Twenty
screenshots estimated at 252 tokens instead of ~112k, so compact()
returned early every cycle, the /context badge showed headroom, and the
run died on a provider 400 with compaction having never executed.

estimate_tokens_for_message now adds a per-BinaryContent charge derived
from real image dimensions, read via Pillow which is already a
dependency. Image.open parses only the header, so this does not decode
pixels. Unreadable images and non-image attachments fall back to a
deliberately generous constant, since undercounting is the failure mode
that hurts. stringify_part still emits the digest, so existing dedup
hashes are unchanged.

Also gives the list-content loop an else arm. ImageUrl and DocumentUrl
items matched neither branch and contributed nothing at all, so two
messages pointing at entirely different URLs produced the same string
and hashed identically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mpfaffenberger

Copy link
Copy Markdown
Owner

Nice.

@mpfaffenberger
mpfaffenberger merged commit 4d269ed into mpfaffenberger:main Aug 24, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Token estimator scores images at a few tokens, so compaction never fires on multimodal histories

2 participants