openai / openai/codex

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

Open
#41,338 10 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

app bug context tool-calls
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.

  1. 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_bytes is computed and logged immediately before the POST and feeds no decision.
  2. 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.
  3. 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.
  4. 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.
  1. Run a long session with ordinary tool use until it sits near the auto-compact threshold.
  2. Have a tool write a large PNG to disk. Ours was 1280×820 at 3,149,928 bytes.
  3. Inline it: const r = await tools.view_image({path, detail:"original"}); image(r.image_url); — repeat ~8 times.
  4. 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?
  1. 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.
  2. 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.
  3. 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.
  4. Report the real reason. A 400 on a 34.78 MB body should say so.
Additional information
  • detail: "original" was requested; the stored item records detail: "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 systemError to notLoaded, 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.