LibreChat/api/server
Danny Avila 8f6228095f
🖼️ fix: Stop Reporting a Failed Sandbox Image Read as an Unreadable Format (#15453)
* 🖼️ fix: Stop Reporting a Failed Sandbox Image Read as an Unreadable Format

`read_file` on a `/mnt/data` image collapsed every failure — rate limit,
missing path, truncated transfer, transport error — into the binary-file
hint, whose image branch reads as a permanent capability limit ("is an
image file and cannot be read as text"). The model stopped retrying, and
the operator had no way to tell which failure had actually occurred.

The failure that surfaced it: the reader windows the bytes through
`/exec` stdout, one call per window, against the Code API's per-user
execution limiter (20 requests / 30s in the reference deployment). At a
fixed 32KB window a 122KB chart cost 4 calls and a 1MB image cost 33 —
more than a whole limiter window, so anything over ~640KB could never
complete. A turn that generates several charts and reads them back
exhausts the budget partway through and reports the 429s as unreadable
images.

- `handlers.ts`: failed reads name the real cause and the affordance that
  matches it — rate-limited waits and retries, a missing path lists the
  directory, a truncated transfer retries. The "cannot be read as text"
  phrasing is now reserved for bytes that are not a supported image. An
  image past the inline budget says to downscale and read the smaller
  copy rather than pointing at `bash_tool`, which cannot show an image.
- `process.js`: the window is derived from the runner's stdout budget
  (48,960B vs 32,768B, so a 122KB chart is 3 calls and 1MB is 22), halves
  and re-reads the same offset when a runner truncates anyway (remembered
  per Code API, with the env remedy logged), waits out a rate limit it
  can afford instead of discarding the bytes already pulled, and stops at
  a 24-call ceiling so no read stalls a turn across limiter windows.
- `utils/code.ts`: shared `getCodeApiRetryAfterMs` (header, then the
  `retry_after_seconds` body); `skillFiles.ts` drops its private copy.

* ♻️ refactor: Move the Sandbox Image Reader Into TypeScript

Addresses the review round on #15453.

- P1: windowing, window sizing, the in-sandbox reader script, response
  parsing and byte assembly move to `packages/api/src/files/code/image.ts`,
  and the Code API rate-limit policy to `packages/api/src/utils/code.ts`.
  `process.js` keeps only the `/exec` transport it owns. Both new modules
  are tested against their real implementations (19 + 13 cases), including
  the round-trip ceiling, the changed-file guard and the shell-injection
  guard on the reader script.
- P2: a configured stdout budget below the old 8KB window floor produced a
  window the runner could never emit, and narrowing was pinned to the same
  floor, so no retry could succeed. The window now aligns to the base64
  group size instead, and the round-trip ceiling bounds what a small budget
  costs.
- P2: `.env.example` documented a 32,768-byte default that no longer holds
  and never mentioned the stdout budget. It now describes
  `LIBRECHAT_CODE_SANDBOX_OUTPUT_MAX_SIZE` as the setting to tune, with the
  chunk override as the escape hatch.
- P3: `process.spec.js` no longer reimplements the retry parser in its
  module mock. Behaviour is tested where it lives; the mock is a declared
  passthrough and the spec covers what the transport still owns — session
  forwarding, the profile header, parser hand-off and error logging.
- P3: `getCodeApiRetryAfterMs` reads the response body through `in`
  narrowing rather than an `as` assertion.
- P3: the window-sizing JSDoc no longer claims a multiple of 3 in the one
  case that is not — an explicit `LIBRECHAT_CODE_IMAGE_CHUNK_BYTES`.

* 🎯 fix: Size Sandbox Image Reads to What the Limiter Can Deliver

Addresses review round 2 on #15453.

- Narrowing now halves the caller's own failing window instead of the
  shared learned value, and the learned value only moves down to the
  narrowest size any read needed. Reads run concurrently (one per tool
  call), so two reads failing at the same size were compounding into a
  window two steps smaller than the runner would have accepted, and
  persisting it for every later read.
- Narrowing continues until a runner accepts a window or a floor below
  which no runner could serve an image at all. Two halvings stopped at
  12,240 bytes — still ~16KB of base64 — so an 8-10KB stdout cap that was
  not configured up front stayed unreachable.
- The round-trip ceiling is now one limiter window (20), and a read that
  cannot finish within it says so from the FIRST response rather than
  spending the whole budget rediscovering it: the first window reveals
  both the file size and what a call really delivers. A near-limit image
  no longer drains the turn's executions before failing, which is what
  made the wait budget look too small.
- The round-trip message names the size this deployment can actually
  inline, so the model has a downscale target instead of a guess.
- `python3: not found` from the sandbox reader was matching the generic
  "not found" in `isSandboxMissingFileError` and being reported as a
  missing image path, sending the model to `ls /mnt/data` while hiding a
  runner dependency. Image reads now classify only path-specific ENOENT
  forms as a missing file.

* 📐 fix: Size a Truncated Read's Retry From the Cap It Revealed

Addresses review round 3 on #15453.

A ~1KB sandbox stdout cap is a real runner default, and the 1,536-byte
narrowing floor sat above it: discovery stopped one step short and
declared every image unreadable, even though small ones fit comfortably
in the 576-byte window the configured path already derives.

Narrowing now runs until no smaller window exists. It also no longer has
to grope its way down: a truncated response emitted exactly as much as
the runner allows, so its length measures the cap this deployment never
declared, and the retry is derived from it directly — one round-trip
instead of the eight blind halvings it takes to fall from ~49KB to ~1KB.
Blind halving remains the fallback and bounds the result either way, so
every retry is strictly smaller than the window that just failed.
2026-09-01 09:08:01 -04:00
..
controllers 🧲 fix: Draw OpenID Refresh Lookups Into System Context (#15451) 2026-09-01 02:02:21 -04:00
middleware 🔐 fix: Redirect OAuth Browser Navigations Instead of Rendering JSON (#15440) 2026-08-31 21:13:47 -04:00
routes 🔐 fix: Redirect OAuth Browser Navigations Instead of Rendering JSON (#15440) 2026-08-31 21:13:47 -04:00
services 🖼️ fix: Stop Reporting a Failed Sandbox Image Read as an Unreadable Format (#15453) 2026-09-01 09:08:01 -04:00
utils 🎟️ refactor: Require Credentials for Local Image Access by Default (#15252) 2026-08-27 09:55:27 -04:00
cleanup.js
csp.spec.js 🔣 fix: Escape SPA Language Attribute (#15248) 2026-08-26 07:37:36 -04:00
experimental.js 🧳 feat: Register Principal-Owned Code Environments (#15365) 2026-08-30 19:33:19 -04:00
experimental.spec.js 🧾 feat: Store Durable Event Actor Receipts (#15265) 2026-08-27 06:00:43 -04:00
index.js 🧳 feat: Register Principal-Owned Code Environments (#15365) 2026-08-30 19:33:19 -04:00
index.metrics.spec.js ⏱️ feat: Run Scheduled Chats Through Durable Agent Triggers (#14939) 2026-08-20 11:51:30 -04:00
index.spec.js 🦺 feat: Configurable Baseline HTTP Security Headers (#14445) 2026-08-25 08:21:39 -04:00
socialLogins.js ⏳ feat: Make OpenID Token Reuse Window Configurable (#13546) 2026-06-06 15:15:58 -04:00
socialLogins.spec.js ⏳ feat: Make OpenID Token Reuse Window Configurable (#13546) 2026-06-06 15:15:58 -04:00
telemetry.js 📡 feat: Add Backend OpenTelemetry Tracing (#12909) 2026-05-14 09:08:55 -04:00
telemetry.spec.js 📡 feat: Add Backend OpenTelemetry Tracing (#12909) 2026-05-14 09:08:55 -04:00