Inline image output costs ~230 tokens but 4.2 MB on the wire, so token-based context management never sees the payload that wedges the thread
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 125k
- Forks
- 19.4k
- PR merge metrics
- PR metrics pending
Description
Related issues
I searched first. This overlaps #18629 (inline base64 tool images poisoning threads) and #31579 (thread stuck after a failed auto-compact), and I have 👍'd both.
I am filing separately because I have a measurement that contradicts a stated hypothesis in #18629 and that neither issue reports: the images are nearly free in tokens and enormous on the wire. #18629 says the payload is "likely inflating token usage." Measured here, it does not — and that asymmetry looks like the actual reason nothing in the client notices before the thread dies. Happy for this to be folded into #18629 if you'd rather keep it in one place.
New here: the per-image token cost, a 34.78 MB payload (32× #31579's 1.08 MB), and view_image on a local file as the producer rather than a browser/screenshot plugin.
What version of the Codex App are you using?
Codex App 26.810.52044; codex-cli 0.150.1
What subscription do you have?
Not specified here. Observable from the session's own telemetry: limit_id: "codex", a 10,080-minute (weekly) primary window at 81% used, credits.balance: "0".
What platform is your computer?
Linux 7.0.0-30-generic x86_64 x86_64
What issue are you seeing?
A thread became permanently unable to continue. Three consecutive user turns each returned:
{"detail":"Bad Request"}
Immediately before that, three pre-turn auto-compactions failed:
16:14:50 WARN remote compaction v2 stream failed; retrying request after delay retries=1 max_retries=2
compact_error=stream disconnected before completion: failed to send websocket request: Connection closed normally
16:14:52 WARN remote compaction v2 stream failed; retrying request after delay retries=2 max_retries=2
compact_error=stream disconnected before completion: websocket closed by server before response.completed
16:14:54 WARN falling back to HTTP
16:14:54 DEBUG Compressed request body with zstd pre_compression_bytes=34777589 post_compression_bytes=492064
16:14:55 ERROR session_task.run:run_turn: Failed to run pre-sampling compact
After the compaction failed, the turn proceeded on the uncompacted history: five websocket retries, all closed by server before response.completed, HTTP fallback, then Turn error: {"detail":"Bad Request"}. Every later message repeated it, with the body growing slightly each time as the failed turns appended (34,777,589 → 34,779,905).
The measurement
The request body was 34.78 MB. Of that, eight view_image outputs were 4,200,348 bytes each:
response_item type |
Count | Bytes | Share |
|---|---|---|---|
custom_tool_call_output |
226 | 35,243,778 | 96.3% |
reasoning |
281 | 771,319 | 2.1% |
custom_tool_call |
226 | 375,302 | 1.0% |
message |
56 | 150,834 | 0.4% |
| everything else | 4 | 48,958 | 0.1% |
The eight images alone are 33.6 MB, 92% of the entire request.
Now the part I think matters. Taking last_token_usage.input_tokens on the turn immediately after each image entered history:
| Image | Bytes on the wire | Prompt-token delta |
|---|---|---|
| 1 | 4,200,348 | +239 |
| 2 | 4,200,348 | +238 |
| 3 | 4,200,348 | +230 |
| 4 | 4,200,348 | +232 |
| 5 | 4,200,348 | +227 |
| 6 | 4,200,348 | +216 |
| 7 | 4,200,347 | +235 |
| 8 | 4,200,346 | +226 |
| total | 33.6 MB | 1,843 |
1,843 tokens — 0.71% of the 258,400 context window — for 92% of the request body. For comparison, ordinary exec outputs in the same session cost +18,516, +11,000 and +7,240 tokens each while contributing almost nothing to the byte count.
Why this looks like the root cause
Context management is token-denominated; the transport constraint is byte-denominated. Only one of those is tracked.
- The client cannot see the problem. Its own accounting said 233,226 / 258,400 — a normal 90%, the routine trigger for compaction — while the actual body was 34.78 MB.
pre_compression_bytesis computed and logged immediately before the POST and feeds no decision. - Compaction cannot fix it, even when it works. A token-budget compactor has no incentive to evict images: they are the cheapest items per token and the most expensive per byte. It will evict the 18,516-token text output and keep the 4.2 MB picture. A textbook-perfect compaction could leave the body essentially unchanged.
- Compaction cannot run at all. It is itself a model request carrying the same payload, so when the payload is what fails, the only shrink mechanism fails with it — then the turn proceeds uncompacted anyway. That converts one bad turn into a permanently dead thread.
- The error says nothing.
{"detail":"Bad Request"}gives no size, no token count, no offending item, no remedy.
What steps can reproduce the bug?
- Session id:
01a04764-a97d-71c2-ace0-6fcf5816cc42 - Model:
gpt-5.6-sol,reasoning_effort=max - Context window: 258,400. Last successful turn: 233,226 input tokens (90.3%).
- Two earlier auto-compactions in the same session succeeded — both before any image entered history.
- Run a long session with ordinary tool use until it sits near the auto-compact threshold.
- Have a tool write a large PNG to disk. Ours was 1280×820 at 3,149,928 bytes.
- Inline it:
const r = await tools.view_image({path, detail:"original"}); image(r.image_url);— repeat ~8 times. - Send another message. Pre-turn auto-compact fires, fails, and every turn from then on returns
{"detail":"Bad Request"}.
The image producer does not matter — #18629 collects browser-use, Playwright, image_gen and plugin cases. What matters is an unbounded artifact reaching history via base64.
What is the expected behavior?
- Bound the inlined artifact. A 1280×820 frame tiles to the same image tokens whether the file is 13 KB or 3.1 MB — the measurement above shows the extra bytes buy the model nothing. Downscale or re-encode to a byte ceiling before the item enters history. This is lossless in every sense that matters and removes ~99% of the payload on its own.
- Track bytes as a budget, not only tokens. The number is already computed at
pre_compression_bytes. Evicting by bytes-per-token would have dropped eight items worth 1,843 tokens and reclaimed 33.6 MB. - Give compaction a local fallback, and never proceed uncompacted. If the compaction request fails, elide the heaviest items client-side and retry without the network. Running the turn anyway on the uncompacted history is what makes the state permanent rather than transient.
- Report the real reason. A 400 on a 34.78 MB body should say so.
Additional information
detail: "original"was requested; the stored item recordsdetail: "high". Nothing downscaled or capped it either way.- On the producer side: the oversized PNG came from a local MCP server we maintain, which was writing PNGs as stored deflate blocks — 3,149,928 bytes for a line drawing that compresses to 16,687. We have fixed that (299× smaller, byte-identical pixel data), so this particular trigger is gone for us. It only removes one producer, though: nothing client-side stops the next large image from any source.
- We depend on these images and are not going to stop sending them. The text alternatives our tooling exposes — a 400×200 character grid, a 32-entry colour ranking, single-pixel reads — answer "what colour is at this point", not "what does this drawing show". The right fix is bounding the payload, not rationing the capability.
- On whether this is a size limit: I want to be careful. #31579 hit the same 400 at 1,084,457 bytes, ~32× smaller than ours, alongside a dangling
No tool output found for tool search call, so the rejection may be structural rather than a byte cap. What I can state from this session is the payload composition, the token/byte asymmetry, and that the client had no recovery path. I could not observe the server's reason. - Possible recovery, from #31579: archiving and immediately unarchiving the thread moved it from
systemErrortonotLoaded, unloading the stale in-memory history, after which the same prompt succeeded. Worth documenting if confirmed — right now a user's only visible option is to abandon the thread.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
No source file or test is named. Start by tracing where pre_compression_bytes is computed, how image outputs enter history, and how compaction failure proceeds to an uncompacted turn. Done should include bounded image payloads, byte-aware recovery, and an actionable size-related error without breaking image use.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- backend, cli
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100