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
21 changes: 21 additions & 0 deletions docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,27 @@ caller, including one that posts them directly, and a model turn records no hist
opencodex decides this by reading Codex own config itself, so the switch does not depend on the
injected URL or on anything a client sends, and turning it off takes effect without a restart.

### Hosted image results in Codex App

When a routed Responses provider returns a completed hosted `image_generation_call`
with base64 image data, opencodex saves the validated image under its local
`artifacts/` directory and delivers a final assistant image message to a locally
connected Codex client. The image stays outside the collapsible progress section.
This applies to streaming and non-streaming Responses, without another generation
request or a change to the selected provider.

When a client replays these generated image messages as assistant history, opencodex
replaces its generated local image links with opaque artifact references before
forwarding that history. This protects the display paths without changing the image
message already shown in the app. It does not redact unrelated user-supplied paths.

This display compatibility requires loopback admission and a recognized Codex client.
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.

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

Codex's built-in `image_gen` tool does not go through `/v1/responses` — the codex-rs extension
Expand Down
2 changes: 2 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
"version": 1,
"root": "tests",
"explicit": {
"responses-hosted-image-delivery.test.ts": "responses",
"responses-hosted-image-display.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
205 changes: 205 additions & 0 deletions src/server/responses-hosted-image-display.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { mkdirSync, writeFileSync, unlinkSync } from "node:fs";
import { createHash, randomUUID } from "node:crypto";
import { join } from "node:path";
import { getConfigDir } from "../config";
import { recordOwnedConfigPath } from "../lib/config-ownership";
import {
chargeImageBudget, createImageBudget, decodeValidatedImageBase64,
artifactHttpUrl, getArtifactsDir, MAX_ENCODED_BYTES_PER_IMAGE, pruneArtifacts, sniffImageExtension,
} from "../images/artifacts";
import { sseDataPayload, replaceSseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";

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"]);

/** Full client replay can include display-only messages even without their generated ids. */
export function redactHostedImageDisplayPaths(body: unknown): void {
if (!object(body) || !Array.isArray(body.input)) return;
const prefix = join(getArtifactsDir(), "img-codex-");
const generatedLink = /!\[Generated image\]\(<([^>\r\n]+)>\)/g;
const redact = (text: string) => text.replace(generatedLink, (link, path: string) => {
if (!path.startsWith(prefix)
|| !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\.(png|jpg|webp|gif)$/.test(path.slice(prefix.length))) return link;
// No file read or existence check: history may outlive artifact retention.
return "![Generated image](<" + artifactHttpUrl(path) + ">)";
});
for (const item of body.input) {
if (!object(item) || item.role !== "assistant"
|| (item.type !== undefined && item.type !== "message")) continue;
if (typeof item.content === "string") item.content = redact(item.content);
else if (Array.isArray(item.content)) {
for (const part of item.content) {
if (object(part) && ["output_text", "input_text", "text"].includes(part.type)
&& typeof part.text === "string") part.text = redact(part.text);
}
}
}
}

/** Local filesystem links are only appropriate for the loopback Codex client. */
export function isLocalCodexImageClient(headers: Headers, admissionKind?: string, inboundWire?: string): boolean {
return admissionKind === "loopback"
&& (inboundWire === undefined || inboundWire === "responses")
&& (originators.has(headers.get("originator") ?? "")
|| /^codex(?:[_ /-]|$)/i.test(headers.get("user-agent") ?? ""));
}

/** Client branch only: raw hosted items remain intact in the upstream replay cache. */
export function createHostedImageDisplayRewrite(): SseBlockRewrite & { json(text: string): string } {
const budget = createImageBudget();
const states = new Map<string, { message: Row; added: boolean; done: boolean }>();
let extraSequence = 0;
let wroteArtifact = false;

function stateFor(item: Row, index: number) {
const key = typeof item.id === "string" ? item.id : "index:" + 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");
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.
id, type: "message", role: "assistant", phase: "final_answer", status: "in_progress", content: [],
...(item.internal_chat_message_metadata_passthrough === undefined ? {} : {
internal_chat_message_metadata_passthrough: item.internal_chat_message_metadata_passthrough,
}),
} };
states.set(key, state);
return state;
}

function complete(item: Row, index: number) {
const state = stateFor(item, index);
if (state.message.status !== "in_progress") return state;
let text = item.status === "completed"
? "The completed image result has no supported image data for local display."
: "Image generation did not complete; no image is available to display.";
let status = "incomplete";
if (item.status === "completed" && typeof item.result === "string") {
try {
if (item.result.length > MAX_ENCODED_BYTES_PER_IMAGE) throw new RangeError("image too large");
const bytes = decodeValidatedImageBase64(item.result);
chargeImageBudget(budget, bytes.length);
const dir = getArtifactsDir();
recordOwnedConfigPath(getConfigDir(), dir);
mkdirSync(dir, { recursive: true, mode: 0o700 });
const candidate = join(dir, "img-codex-" + randomUUID() + "." + sniffImageExtension(bytes));
// Bounded synchronous write: block rewriters cannot await. Never use upstream ids as paths.
try {
writeFileSync(candidate, bytes, { flag: "wx", mode: 0o600 });
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
try { unlinkSync(candidate); } catch { /* best-effort partial-write cleanup */ }
}
throw error;
}
wroteArtifact = true;
text = "![Generated image](<" + candidate + ">)";
status = "completed";
} catch {
// Do not expose image data, filesystem errors, or credentials in a generated message.
text = "The image result could not be saved for local display.";
}
}
state.message = { ...state.message, status, content: [{ type: "output_text", text, annotations: [] }] };
return state;
}

function doneEvents(item: Row, index: number): Row[] {
const state = complete(item, index);
if (state.done) return [];
const { message } = state;
const common = { item_id: message.id, output_index: index, content_index: 0 };
const part = message.content[0];
const events: Row[] = [];
if (!state.added) events.push({ type: "response.output_item.added", output_index: index,
item: { ...message, status: "in_progress", content: [] } });
events.push(
{ type: "response.content_part.added", ...common, part: { ...part, text: "" } },
{ type: "response.output_text.delta", ...common, delta: part.text },
{ type: "response.output_text.done", ...common, text: part.text },
{ type: "response.content_part.done", ...common, part },
{ type: "response.output_item.done", output_index: index, item: message },
);
state.added = state.done = true;
return events;
}

function snapshot(response: Row, terminal: boolean, events?: 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());
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");
}
let changed = false;
const output = response.output.map((item: unknown, index: number) => {
if (!object(item) || item.type !== "image_generation_call") return item;
changed = true;
if (terminal) {
if (events) events.push(...doneEvents(item, index));
return complete(item, index).message;
}
return stateFor(item, index).message;
});
return changed ? { ...response, output } : response;
}

const rewrite = ((block: string): readonly string[] => {
const payload = sseDataPayload(block);
if (!payload || payload === "[DONE]") return [block];
let event: Row;
try { event = JSON.parse(payload); } catch { return [block]; }
if (!object(event)) return [block];
let result: Row[] = [event];
const hasIndex = Number.isSafeInteger(event.output_index) && event.output_index >= 0;
const index = hasIndex ? event.output_index : 0;
if (object(event.item) && event.item.type === "image_generation_call"
&& (hasIndex || typeof event.item.id === "string")) {
if (event.type === "response.output_item.added") {
const state = stateFor(event.item, index);
result = state.added ? [] : [{ ...event, item: state.message }];
state.added = true;
} else if (event.type === "response.output_item.done") {
result = doneEvents(event.item, index);
}
} else if (typeof event.type === "string" && event.type.startsWith("response.image_generation_call.")) {
// Hosted progress/partial-image frames refer to the replaced item; never expose mismatched ids.
result = [];
} 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);
if (response !== event.response) result = [...injected, { ...event, response }];
}
const sequence = Number.isSafeInteger(event.sequence_number) ? event.sequence_number + extraSequence : undefined;
const shift = extraSequence;
extraSequence += result.length - 1;
return result.map((value, offset) => {
if (value === event && shift === 0 && result.length === 1) return block;
const next = sequence === undefined ? value : { ...value, sequence_number: sequence + offset };
const data = JSON.stringify(next);
return value.type === event.type ? replaceSseDataPayload(block, data) : "event: " + value.type + "\ndata: " + data;
});
}) as SseBlockRewrite & { json(text: string): string };

rewrite.json = (text: string) => {
let response: unknown;
try { response = JSON.parse(text); } catch { return text; }
if (!object(response)) return text;
const next = snapshot(response, true);
return next === response ? text : JSON.stringify(next);
};
rewrite.dispose = () => {
states.clear();
if (wroteArtifact) {
wroteArtifact = false;
pruneArtifacts();
}
};
return rewrite;
}
16 changes: 16 additions & 0 deletions src/server/responses/passthrough-delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ import {
PLAINTEXT_V2_AGENT_MESSAGE_RESTORE_OVERFLOW_MESSAGE,
} from "../../responses/plaintext-v2-agent-messages";
import { createResponsesFieldBackfillBlockRewrite } from "./responses-field-backfill";
import { createHostedImageDisplayRewrite, isLocalCodexImageClient } from "../responses-hosted-image-display";
import { createResponsesFunctionToolRepairBlockRewrite } from "../responses-function-tool-repair";
import {
createUndeclaredToolCallGuardBlockRewrite,
Expand Down Expand Up @@ -743,6 +744,10 @@ export async function deliverPassthroughResponse(
)
: undefined,
rememberPlaintextBlock,
// Display-only projection must follow every continuation-cache observer.
isLocalCodexImageClient(req.headers, options.admission?.kind, options.inboundWire)
? createHostedImageDisplayRewrite()
: undefined,
].filter((rewrite): rewrite is NonNullable<typeof rewrite> => rewrite !== undefined);
const clientBlockRewrite = blockRewrites.length > 0
? composeSseBlockRewrites(...blockRewrites)
Expand Down Expand Up @@ -1022,6 +1027,17 @@ export async function deliverPassthroughResponse(
JSON.parse(grokUpstreamEchoEnabled ? clientJson : text) as { id?: unknown; output?: unknown; status?: unknown; model?: unknown },
);
} catch { /* non-JSON despite content-type; recording is best-effort */ }
if (isLocalCodexImageClient(req.headers, options.admission?.kind, options.inboundWire)) {
const imageDisplay = createHostedImageDisplayRewrite();
try {
clientJson = imageDisplay.json(clientJson);
} catch (error) {
if (error instanceof RangeError) {
return formatErrorResponse(502, "upstream_error", "hosted image result count exceeds local display limit");
}
throw error;
} finally { imageDisplay.dispose?.(); }
}
// #875: the transport-neutral reliability policy forced a bounded JSON
// upstream for a client that asked for SSE. Reframe the completed JSON
// as the canonical terminal SSE sequence (created → output_item.done →
Expand Down
4 changes: 4 additions & 0 deletions src/server/responses/request-prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ import {
conversationCarriesUploadedFiles,
} from "./account-change-state";

import { redactHostedImageDisplayPaths } from "../responses-hosted-image-display";

/** Parses, selects, and admits one request without changing the dispatch policy. */
export async function prepareResponsesRequest(
requestContext: Pick<ResponsesRequestContext, "options" | "config" | "req" | "logCtx">,
Expand All @@ -168,6 +170,8 @@ export async function prepareResponsesRequest(
}
return decodeRequestErrorResponse(err, "responses");
}
// Remove display-only artifact paths before helpers, combo dispatch and upstream replay.
redactHostedImageDisplayPaths(body);
observeCacheDiagnosticInbound(
logCtx,
body,
Expand Down
2 changes: 1 addition & 1 deletion structure/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ The wire surfaces a client actually talks to.
| [`transports/responses-spend.md`](transports/responses-spend.md) | Credential-hop and durable spend reservations, and what a spent budget tells the client. |
| [`transports/streaming-health.md`](transports/streaming-health.md) | Heartbeat and stall deadlines, plus the opt-in WebSocket transport. |
| [`transports/inventory.md`](transports/inventory.md) | The per-provider transport table and diagnostic outbound safety. |
| [`data-planes/images.md`](data-planes/images.md) | Standalone image generation and edit relay. |
| [`data-planes/images.md`](data-planes/images.md) | Standalone image generation/edit relay and local hosted Responses image display. |
| [`data-planes/search.md`](data-planes/search.md) | Hosted search relay and exact account selectors. |
| [`data-planes/inbound-compat.md`](data-planes/inbound-compat.md) | Chat Completions inbound, Anthropic-shaped clients, and JSON-upstream streaming clients. |
| [`data-planes/protocol-paths.md`](data-planes/protocol-paths.md) | Shared protocol vocabulary, declared feature dispositions, the ingress-by-upstream baseline, plan/trace shapes, and protocol settings. |
Expand Down
31 changes: 31 additions & 0 deletions structure/data-planes/images.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,37 @@ Codex Spark exception; standalone Images retain the separate relay contract belo

Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](../transports/responses-wire-shapes.md#passthrough-sse-stream-shapes-314).

## Hosted Responses image display

For loopback-admitted Codex Responses clients,
`src/server/responses-hosted-image-display.ts` projects hosted `image_generation_call`
results into assistant messages with `phase: final_answer` and Markdown links to local
artifacts. The final phase keeps the image outside collapsible progress output.
`src/server/responses/passthrough-delivery.ts` applies this projection after continuation
observers, on the client branch only, for SSE, JSON and JSON synthesized into SSE.
Hosted items in the continuation cache retain their upstream representation; other
client rewrites keep their existing cache policy. Generic, remotely admitted and non-Responses
clients do not receive this filesystem projection. Individual image item events without
a string item id or a valid non-negative integer output index pass through without
allocating display state, so unidentified items cannot collide at a synthetic index.

Full-history assistant messages can replay the display Markdown without their generated
item ids. At Responses request preparation, exact generated links under the current
artifact directory become opaque artifact HTTP references before routing and helper
dispatch. This does not read files and still applies after artifact pruning; unrelated
paths, user messages and tool payloads retain their original content. Local display
uses filesystem links because remote Markdown media has a separate client safety gate;
HTTP references here are for upstream context, not a claim of desktop HTTP rendering.

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
display message without reflecting payloads or filesystem errors. URL results and
partial-image previews are not downloaded or rendered by this projection.

## Standalone Images

Codex's local `image_gen.imagegen` tool makes a second Images request after the model calls it:
Expand Down
2 changes: 1 addition & 1 deletion structure/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@
"path": "data-planes/images.md",
"tier": 3,
"title": "Images Data Plane",
"scope": "Standalone image generation and edit relay.",
"scope": "Standalone image generation/edit relay and local hosted Responses image display.",
"documents": [
"src/server/"
]
Expand Down
Loading
Loading