This bug was a three-fault chain:
/v1/responsespayloads carrying{"type":"input_image","file_id":"file_*"}or{"type":"input_image","image_url":"sediment://file_*"}were forwarded upstream even though the Responses surface only accepts inlinedata:URLs for conversationinput_imageparts.- codex-lb persisted only
file_id -> account_id, so after/backend-api/files/{file_id}/uploadedcompleted it had no storeddownload_url/mime_typeto pull the uploaded bytes back and rewrite them into the codex-style inline image form. - When upstream rejected that bad shape, the HTTP responses bridge saw a clean close (
close_code=1000) with zeroresponse.*events and treated it as transient, looping throughretry_precreated/retry_fresh_upstreamuntil the request budget expired.
- Added
_ws_transport_payload_budget_bytes(settings)so auto transport selection respects the deploy'smax_sse_event_byteswith 2 MiB headroom for the websocket envelope and control frames. stream_responses()now computes the post-inline serialized payload size immediately after_inline_input_image_urls(), covering both:app/modules/proxy/service.py::_rewrite_input_image_file_referencesapp/core/clients/proxy.py::_inline_input_image_urls
_resolve_stream_transport()now routesautorequests over HTTP before the existing codex-header / model-registry websocket heuristics when that rewritten payload estimate exceeds the websocket budget.- Explicit
upstream_stream_transport = "websocket"andupstream_stream_transport = "http"still win unchanged.
- Added a new codex-faithful prompt image processor.
- Mirrors the upstream codex image contract:
- accepts only PNG / JPEG / GIF / WebP
- preserves PNG / JPEG / WebP bytes verbatim when already within 2048x2048
- re-encodes GIF as PNG
- resizes oversized images to fit 2048x2048
- uses JPEG quality 85 and lossless WebP on resized output
- Adds a 32-entry in-process LRU cache keyed by
sha1(bytes) + mode.
- Added
fetch_file_bytes(download_url, expected_mime, max_bytes). - Downloads finalize SAS blobs with a hard byte cap so a single attachment cannot blow the websocket frame budget after base64 expansion.
- Added
_input_image_file_reference()for:input_image.file_idinput_image.image_url = "sediment://file_*"
- Extended
extract_input_file_ids()so routing sees bothinput_fileand uploadedinput_imagereferences. - Added
extract_input_image_file_references()so the proxy can rewrite only the preciseinput_imageparts, without touching any other conversation content.
- Replaced the old tuple pin with
_FilePinEntry(account_id, download_url, mime_type, file_name, expires_at). create_file()still pins the upload owner immediately so finalize stays on the same upstream account.finalize_file()now upgrades the pin withdownload_url/mime_type/file_nameonce upstream returnsstatus=success.- Pin expiry is clamped to the shorter of:
_FILE_ACCOUNT_PIN_TTL_SECONDS(30 minutes)- the SAS
se=expiry embedded indownload_url, when present
- Added
_lookup_file_pin(). - Added
_rewrite_input_image_file_references():- finds only
input_image.file_id/sediment://file_* - fetches the uploaded bytes from the pinned SAS
download_url - runs the codex-faithful image processor
- rewrites the original part to inline
image_url: "data:...", preservingdetailwhen supplied and defaulting it toautootherwise - leaves all non-targeted conversation content byte-for-byte untouched
- logs a synthetic
image-inline-rewriterequest-log row for observability
- finds only
- Wired the rewrite into:
- HTTP
/v1/responses/ backend responses streaming path - HTTP bridge path
- websocket
response.createprepare path /responses/compact
- HTTP
- Added
_classify_upstream_close()andresponse_event_counttracking. - HTTP bridge
retry_precreatednow fails fast with502 upstream_rejected_inputwhen upstream closes withclose_code=1000before anyresponse.*event. stream_http_responses()now rewrites uploadedinput_imagereferences before branch selection, estimates the post-rewrite JSON payload size, and bypasses the HTTP responses bridge per request when that rewritten body exceeds the WebSocket frame budget.- The bypass uses a local
dataclasses.replace(runtime_config, enabled=False)copy only, so bridge state stays unchanged globally and smaller follow-up requests still use the bridge normally.
- Added coverage for passthrough, resize, GIF->PNG re-encode, unsupported formats, garbage bytes, ORIGINAL mode, and cache-hit identity.
- Added coverage for
fetch_file_bytes()success andfile_too_largeenforcement.
- Added coverage for
input_image.file_id,sediment://file_*, andextract_input_image_file_references().
- Added coverage for:
_lookup_file_pin()_rewrite_input_image_file_references()single and multiple rewrites- missing pin ->
400 file_not_found - oversized download ->
400 file_too_large - preserving non-image conversation content
- returning the pinned account for routing
- clean-close classifier
- HTTP bridge precreated retry suppression on rejected input
- large rewritten payloads forcing HTTP only in
auto - large rewritten payloads bypassing the HTTP responses bridge selector
- smaller / unknown payload sizes preserving websocket preference
- explicit transport overrides still winning
- websocket budget calculation from
max_sse_event_bytes
- Amended
openspec/changes/add-backend-api-files-protocol/:proposal.mdtasks.mdspecs/responses-api-compat/spec.md
- Documented accepted
input_file/ uploadedinput_imageshapes, the inline rewrite contract, the 16 MiB cap, the “rewrite only the targetedinput_imageparts” rule, the auto HTTP fallback for oversized rewritten payloads, and the clean-close fail-fast behavior. - Added the bridge-bypass scenario so the OpenSpec now covers the default bridge-enabled
/responsespath as well as_resolve_stream_transport().
pyproject.tomlnow declarespillow>=10.0.uv.lockwas updated so the direct dependency is in sync.- Pillow was added explicitly even though it was already present transitively because this code now imports
from PIL import Imagedirectly in production.
- SAS expiry vs pin TTL:
- file pins now expire at the earlier of 30 minutes or the SAS
se=timestamp when present - if the SAS URL expires before the follow-up
/responsescall arrives, inline rewrite fails closed instead of attempting a stale fetch
- file pins now expire at the earlier of 30 minutes or the SAS
- Cache misses:
- the image processor cache is in-process only
- a different worker or a cold process simply re-downloads and re-processes the image
- Partial multi-image rewrites:
- if any referenced upload pin is missing / expired / unfetchable, the whole request fails
- there is no partial-forward behavior
uv run --frozen ruff check app testsuv run --frozen ruff format --check app testsuv run --frozen ty check appuv run --frozen pytest tests/unit -quv run --frozen pytest tests/integration/test_proxy_files.py -quv run --frozen pytest tests/integration/test_proxy_responses.py -q
openspec validate add-backend-api-files-protocol --strict --no-interactive- the
openspecCLI is not installed in this workspace (openspec: command not found)
- the