Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,9 @@ Remote and generic API clients retain the provider's hosted response format.
Partial previews and URL-only results are not rendered by this compatibility layer.
Artifacts use the existing retention limit, so save images you want to keep before
older files are pruned. An invalid image or a failed local write produces a visible
failure message instead of a broken image link.
failure message instead of a broken image link. Display batches reject duplicate image
identities, oversized routing metadata, and output that exceeds the display byte limit;
these failures return HTTP 502 before streaming or a failed terminal event after it starts.

### Built-in image generation (`image_gen`)

Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"discovered-native-models.test.ts": "codex-integration",
"responses-hosted-image-delivery.test.ts": "responses",
"responses-hosted-image-display.test.ts": "responses",
"responses-hosted-image-limits.test.ts": "responses",
"cursor-local-installer.test.ts": "providers/cursor",
"codex-quota-query-backoff.test.ts": "codex-integration",
"pnpm-command-isolation.test.ts": "update", "provider-antigravity-quota-retry.test.ts": "providers", "project-config-warning-snapshot.test.ts": "codex-integration", "codex-quota-auto-refresh-generation.test.ts": "codex-integration", "codex-account-clear-paused.test.ts": "codex-integration",
Expand Down
79 changes: 70 additions & 9 deletions src/server/responses-hosted-image-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { mkdirSync, writeFileSync, unlinkSync } from "node:fs";
import { createHash, randomUUID } from "node:crypto";
import { join, posix } from "node:path";
import { getConfigDir } from "../config";
import { jsonUtf8Bytes } from "../lib/json-byte-size";
import { TRANSLATOR_MAX_TURN_BYTES } from "../lib/translator-budget";
import { recordOwnedConfigPath } from "../lib/config-ownership";
import {
chargeImageBudget, createImageBudget, decodeValidatedImageBase64,
Expand All @@ -12,6 +14,16 @@ import { sseDataPayload, replaceSseDataPayload, type SseBlockRewrite } from "./s
type Row = Record<string, any>;
const object = (v: unknown): v is Row => !!v && typeof v === "object" && !Array.isArray(v);
const originators = new Set(["codex_cli_rs", "Codex Desktop", "codex_app", "codex_work_desktop"]);
const MAX_DISPLAY_ITEMS = 128;
const MAX_ITEM_METADATA_BYTES = 64 * 1024;
const MAX_RETAINED_METADATA_BYTES = 1024 * 1024;
const displayKey = (item: Row, index: number) => typeof item.id === "string" ? "id:" + item.id : "index:" + index;

function displayBytes(value: unknown, limit: number): number {
try { return jsonUtf8Bytes(value, limit); }
catch { throw new RangeError("hosted image result exceeds local display limits"); }
}

const generatedName = /^img-codex-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\.(png|jpg|webp|gif)$/;

/** One spelling per local path: file URL or plain, either separator, dot segments, folded case. */
Expand Down Expand Up @@ -71,13 +83,20 @@ export function createHostedImageDisplayRewrite(): SseBlockRewrite & { json(text
const states = new Map<string, { message: Row; added: boolean; done: boolean }>();
let extraSequence = 0;
let wroteArtifact = false;
let retainedMetadataBytes = 0;

function stateFor(item: Row, index: number) {
const key = typeof item.id === "string" ? item.id : "index:" + index;
// Validate every occurrence, including repeats whose state is already complete.
const metadataBytes = displayBytes({ id: item.id,
metadata: item.internal_chat_message_metadata_passthrough }, MAX_ITEM_METADATA_BYTES);
const key = displayKey(item, index);
let state = states.get(key);
if (state) return state;
// Bound metadata independently of the existing 100 MiB decoded-image budget.
if (states.size >= 128) throw new RangeError("hosted image result count exceeds local display limit");
if (states.size >= MAX_DISPLAY_ITEMS) throw new RangeError("hosted image result count exceeds local display limit");
if (metadataBytes > MAX_RETAINED_METADATA_BYTES - retainedMetadataBytes) {
throw new RangeError("hosted image result exceeds local display limits");
}
retainedMetadataBytes += metadataBytes;
const id = "msg_ocx_img_" + createHash("sha256").update(key).digest("hex").slice(0, 32);
state = { added: false, done: false, message: {
// Persist image results outside the client's collapsible progress messages.
Expand Down Expand Up @@ -147,15 +166,51 @@ export function createHostedImageDisplayRewrite(): SseBlockRewrite & { json(text
return events;
}

function snapshot(response: Row, terminal: boolean, events?: Row[]): Row {
function projectedMessage(message: Row): Row {
if (message.status !== "in_progress") return message;
// Longest possible local filename and JSON text size, without decoding or writing an image.
const pathText = "![Generated image](<" + join(getArtifactsDir(), "img-codex-" + "0".repeat(36) + ".webp") + ">)";
const fallback = "The completed image result has no supported image data for local display.";
const text = displayBytes(pathText, TRANSLATOR_MAX_TURN_BYTES) > displayBytes(fallback, TRANSLATOR_MAX_TURN_BYTES)
? pathText : fallback;
return { ...message, status: "incomplete", content: [{ type: "output_text", text, annotations: [] }] };
}

function lifecycleBytes(message: Row): number {
// At most two item copies and four text copies, plus fixed event fields, ids and numeric indices.
return 2 * displayBytes(message, TRANSLATOR_MAX_TURN_BYTES)
+ 4 * displayBytes(message.content[0].text, TRANSLATOR_MAX_TURN_BYTES) + 2048;
}

function snapshot(response: Row, terminal: boolean, events?: Row[], envelope?: Row): Row {
if (!Array.isArray(response.output)) return response;
// Reject oversized snapshots before saving artifacts that cannot be delivered.
const keys = new Set(states.keys());
// Preflight the complete snapshot before any artifact writes or expanded serialization.
const keys = new Set<string>();
const ids = new Set<string>();
for (const [index, item] of response.output.entries()) {
if (!object(item) || item.type !== "image_generation_call") continue;
keys.add(typeof item.id === "string" ? item.id : "index:" + index);
if (keys.size > 128) throw new RangeError("hosted image result count exceeds local display limit");
const key = displayKey(item, index);
if (keys.has(key)) throw new RangeError("duplicate hosted image identity in local display snapshot");
keys.add(key);
if (typeof item.id === "string") ids.add(item.id);
stateFor(item, index);
}
if (keys.size === 0) return response;
const seen = new Set<string>();
for (const item of response.output) {
if (!object(item) || !ids.has(item.id)) continue;
if (seen.has(item.id)) throw new RangeError("conflicting hosted image identity in local display snapshot");
seen.add(item.id);
}
let extraBytes = 32; // Sequence-number growth on a rewritten SSE envelope.
const preview = { ...response, output: response.output.map((item: unknown, index: number) => {
if (!object(item) || item.type !== "image_generation_call") return item;
const state = stateFor(item, index);
const message = terminal ? projectedMessage(state.message) : state.message;
if (events && terminal && !state.done) extraBytes += lifecycleBytes(message);
return message;
}) };
displayBytes(envelope ? { ...envelope, response: preview } : preview, TRANSLATOR_MAX_TURN_BYTES - extraBytes);
let changed = false;
const output = response.output.map((item: unknown, index: number) => {
if (!object(item) || item.type !== "image_generation_call") return item;
Expand All @@ -182,9 +237,14 @@ export function createHostedImageDisplayRewrite(): SseBlockRewrite & { json(text
&& (hasIndex || typeof event.item.id === "string")) {
if (event.type === "response.output_item.added") {
const state = stateFor(event.item, index);
if (!state.added) displayBytes({ ...event, item: state.message }, TRANSLATOR_MAX_TURN_BYTES - 32);
result = state.added ? [] : [{ ...event, item: state.message }];
state.added = true;
} else if (event.type === "response.output_item.done") {
const state = stateFor(event.item, index);
if (!state.done && lifecycleBytes(projectedMessage(state.message)) > TRANSLATOR_MAX_TURN_BYTES) {
throw new RangeError("hosted image result exceeds local display limits");
}
result = doneEvents(event.item, index);
}
} else if (typeof event.type === "string" && event.type.startsWith("response.image_generation_call.")) {
Expand All @@ -193,7 +253,7 @@ export function createHostedImageDisplayRewrite(): SseBlockRewrite & { json(text
} else if (object(event.response)) {
const terminal = ["response.completed", "response.incomplete", "response.failed"].includes(event.type);
const injected: Row[] = [];
const response = snapshot(event.response, terminal, injected);
const response = snapshot(event.response, terminal, injected, event);
if (response !== event.response) result = [...injected, { ...event, response }];
}
const sequence = Number.isSafeInteger(event.sequence_number) ? event.sequence_number + extraSequence : undefined;
Expand All @@ -216,6 +276,7 @@ export function createHostedImageDisplayRewrite(): SseBlockRewrite & { json(text
};
rewrite.dispose = () => {
states.clear();
retainedMetadataBytes = 0;
if (wroteArtifact) {
wroteArtifact = false;
pruneArtifacts();
Expand Down
2 changes: 1 addition & 1 deletion src/server/responses/passthrough-delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1329,7 +1329,7 @@ export async function deliverPassthroughResponse(
clientJson = imageDisplay.json(clientJson);
} catch (error) {
if (error instanceof RangeError) {
return formatErrorResponse(502, "upstream_error", "hosted image result count exceeds local display limit");
return formatErrorResponse(502, "upstream_error", "hosted image result exceeds local display limits");
}
throw error;
} finally { imageDisplay.dispose?.(); }
Expand Down
13 changes: 10 additions & 3 deletions structure/data-planes/images.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,16 @@ HTTP references here are for upstream context, not a claim of desktop HTTP rende
Image data must pass the shared base64, format and byte-budget checks in
`src/images/artifacts.ts`. Files use random names, exclusive creation and mode 0600;
the artifact directory uses mode 0700 on creation. Per-response display state is capped
at 128 image items, with the existing 50 MiB per-image and 100 MiB aggregate decoded
limits. Exceeding the item cap returns HTTP 502 before streaming or a failed SSE
terminal after streaming has started. Retention runs after the batch. Failed or invalid results produce a bounded
at 128 image items. Each retained identity/metadata envelope is limited to 64 KiB of
serialized UTF-8 JSON, with a 1 MiB aggregate allowance independent of image decoding.
Metadata is preserved unchanged within those bounds, and repeated lifecycle inputs are
validated even when display state already exists. Snapshot image identities are unique,
including against non-image items; explicit ids and fallback indices use separate namespaces.
Projected JSON and SSE lifecycle output is preflighted against the 32 MiB transport limit
before artifact writes or serialization, including metadata reused from earlier events.
The existing 50 MiB per-image and 100 MiB aggregate decoded limits still apply.
Refused display results return HTTP 502 before streaming or a failed SSE terminal after
streaming has started. Retention runs after the batch. Failed or invalid results produce a bounded
display message without reflecting payloads or filesystem errors. URL results and
partial-image previews are not downloaded or rendered by this projection.

Expand Down
5 changes: 5 additions & 0 deletions structure/transports/byte-accounting.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ These optimizations do not add request queues, retry policies, or RSS-based admi
Translated audio/file admission follows the [final-adapter input contract](../adapters/registry.md#untranslated-input-media); native raw passthrough remains separate.
Canonical Responses identity sanitation and narrowly scoped pre-output combo recovery follow [request-local target compatibility](../runtime.md#request-local-target-compatibility); other adapter contracts remain unchanged.

Hosted Responses image display uses the allocation-free JSON byte counter to admit retained
metadata and projected output before saving artifacts or serializing expanded JSON/SSE.
The [Images display contract](../data-planes/images.md#hosted-responses-image-display) owns its
item/metadata limits and lifecycle behavior; raw continuation replay stays upstream-shaped.

## Response-log inspection

`src/lib/redact.ts` scans XML identifying attributes over disjoint tag spans rather than
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"discovered-native-models.test.ts": "codex-integration",
"responses-hosted-image-delivery.test.ts": "responses",
"responses-hosted-image-display.test.ts": "responses",
"responses-hosted-image-limits.test.ts": "responses",
"cursor-local-installer.test.ts": "providers/cursor",
"codex-quota-query-backoff.test.ts": "codex-integration",
"link-relay-bound-transport.test.ts": "clients",
Expand Down
Loading
Loading