diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index f33f8e2955..576c8d1a76 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -117,6 +117,25 @@ export const IMAGE_PROVIDERS: Record = { supportedSizes: ["1024x1024", "1024x1792", "1792x1024", "256x256", "512x512"], }, + // Codex exposes image generation only as a Responses-API hosted tool under + // ChatGPT OAuth. Incoming DALL-E-style `/v1/images/generations` requests are + // translated to /responses calls with `tools: [{ type: "image_generation" }]` + // by handleCodexImageGeneration. + codex: { + id: "codex", + alias: "cx", + baseUrl: "https://chatgpt.com/backend-api/codex/responses", + authType: "oauth", + authHeader: "bearer", + format: "codex-responses", + models: [ + { id: "gpt-5.4", name: "GPT 5.4 (Codex Image)" }, + { id: "gpt-5.3-codex", name: "GPT 5.3 Codex (Image)" }, + { id: "gpt-5.1-codex-max", name: "GPT 5.1 Codex Max (Image)" }, + ], + supportedSizes: ["512x512", "1024x1024", "1024x1536", "1536x1024"], + }, + xai: { id: "xai", baseUrl: "https://api.x.ai/v1/images/generations", diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index dc9abdcd55..5e33c67b9f 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -326,6 +326,31 @@ function stripStoredItemReferences(body: Record): void { } } +function hasImageGenerationTool(body: Record): boolean { + if (!Array.isArray(body.tools)) return false; + return body.tools.some((tool) => { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) return false; + return (tool as Record).type === "image_generation"; + }); +} + +// Responses-API hosted tool types that OpenAI executes server-side (e.g. +// `image_generation` backed by gpt-image-1 under ChatGPT auth). These arrive shaped as +// `{ type, ...params }` with no `function` object and no `name` — Codex CLI injects +// `{ type: "image_generation", output_format: "png" }` when [features] image_generation = true. +// Keep them through `normalizeCodexTools` so upstream can execute them. +const CODEX_HOSTED_TOOL_TYPES: ReadonlySet = new Set([ + "image_generation", + "web_search", + "web_search_preview", + "file_search", + "computer", + "computer_use_preview", + "code_interpreter", + "mcp", + "local_shell", +]); + function normalizeCodexTools(body: Record): void { if (!Array.isArray(body.tools)) return; @@ -336,10 +361,11 @@ function normalizeCodexTools(body: Record): void { } const tool = toolValue as Record; + const toolType = typeof tool.type === "string" ? tool.type : ""; // Preserve namespace tools (MCP tool groups used by Codex/OpenAI Responses API). // Codex API supports them natively; register sub-tool names for tool_choice validation. - if (tool.type === "namespace") { + if (toolType === "namespace") { if (Array.isArray(tool.tools)) { for (const st of tool.tools as unknown[]) { if (st && typeof st === "object" && !Array.isArray(st)) { @@ -352,7 +378,16 @@ function normalizeCodexTools(body: Record): void { return true; } - if (tool.type !== "function") { + if (toolType !== "function") { + const hasFunctionObject = tool.function && typeof tool.function === "object"; + const hasName = typeof tool.name === "string"; + if (!toolType || hasFunctionObject || hasName) { + return false; + } + if (CODEX_HOSTED_TOOL_TYPES.has(toolType)) { + return true; + } + console.debug(`[Codex] dropping unknown hosted tool type: ${toolType}`); return false; } @@ -613,6 +648,8 @@ export class CodexExecutor extends BaseExecutor { // Proxy clients (e.g. OpenClaw) rely on response chaining via previous_response_id, // which requires store=true so that response items are persisted. // If the client explicitly sets store, respect it. Otherwise default to true. + // Exception: when the request uses the image_generation hosted tool, the Codex + // backend rejects store=true ("Store must be set to false"), so default to false. const explicitStoreSetting = credentials?.providerSpecificData && typeof credentials.providerSpecificData === "object" && @@ -622,7 +659,7 @@ export class CodexExecutor extends BaseExecutor { if (explicitStoreSetting === false) { body.store = false; } else if (body.store === undefined) { - body.store = true; + body.store = hasImageGenerationTool(body) ? false : true; } // Codex Responses only supports function tools with non-empty names. diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 743c67762c..8e6dc53e33 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -18,6 +18,7 @@ import { randomUUID } from "crypto"; import { getImageProvider, parseImageModel } from "../config/imageRegistry.ts"; import { mapImageSize } from "../translator/image/sizeMapper.ts"; +import { getCodexClientVersion, getCodexUserAgent } from "../config/codexClient.ts"; import { saveCallLog } from "@/lib/usageDb"; import { submitComfyWorkflow, @@ -275,6 +276,17 @@ export async function handleImageGeneration({ body, credentials, log, resolvedPr return handleComfyUIImageGeneration({ model, provider, providerConfig, body, log }); } + if (providerConfig.format === "codex-responses") { + return handleCodexImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + }); + } + return handleOpenAIImageGeneration({ model, provider, providerConfig, body, credentials, log }); } @@ -1321,6 +1333,219 @@ function isHttpUrl(value) { return typeof value === "string" && /^https?:\/\//i.test(value); } +/** + * Codex image generation — translate DALL-E-style /v1/images/generations + * request into a /v1/responses call with the `image_generation` hosted tool, + * parse the SSE stream, and return the base64 PNG in OpenAI image response shape. + * + * Requires ChatGPT OAuth credentials (Codex provider connection). The hosted + * image_generation tool is only served upstream under ChatGPT auth; API-key + * users will receive a 400 from OpenAI. + */ +export function extractImageGenerationCalls( + sseText: string +): Array<{ b64: string; revisedPrompt: string | null }> { + const results: Array<{ b64: string; revisedPrompt: string | null }> = []; + const lines = String(sseText || "").split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:")) continue; + const payload = trimmed.slice(5).trim(); + if (!payload || payload === "[DONE]") continue; + let evt: Record; + try { + evt = JSON.parse(payload) as Record; + } catch { + continue; + } + if (evt?.type !== "response.output_item.done") continue; + const item = evt.item as Record | undefined; + if (!item || item.type !== "image_generation_call") continue; + const result = typeof item.result === "string" ? item.result : ""; + if (!result) continue; + const revisedPrompt = typeof item.revised_prompt === "string" ? item.revised_prompt : null; + results.push({ b64: result, revisedPrompt }); + } + return results; +} + +// The image_generation hosted tool accepts { "auto" | "low" | "medium" | "high" } +// for `quality`. DALL-E clients often send "standard" / "hd". Map legacy values +// so OpenWebUI's quality dropdown doesn't silently get rejected upstream. +function mapDalleQualityToImageTool(value: string): string { + const normalized = value.toLowerCase(); + if (normalized === "standard") return "medium"; + if (normalized === "hd") return "high"; + return normalized; +} + +async function handleCodexImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}) { + const startTime = Date.now(); + const prompt = typeof body.prompt === "string" ? body.prompt : ""; + if (!prompt.trim()) { + return saveImageErrorResult({ + provider, + model, + status: 400, + startTime, + error: "Prompt is required for Codex image generation", + }); + } + + const requestedCount = Number.isInteger(body.n) && (body.n as number) > 0 ? (body.n as number) : 1; + if (log && requestedCount > 1) { + log.warn( + "IMAGE", + `Codex hosted image_generation returns one image per call; requested n=${requestedCount} will run sequentially` + ); + } + + const token = credentials?.accessToken || credentials?.apiKey; + if (!token) { + return saveImageErrorResult({ + provider, + model, + status: 401, + startTime, + error: "Codex credentials missing accessToken — reconnect the Codex provider", + }); + } + + const workspaceId = + credentials?.providerSpecificData && + typeof credentials.providerSpecificData === "object" && + !Array.isArray(credentials.providerSpecificData) + ? (credentials.providerSpecificData as Record).workspaceId + : undefined; + + // Forward size/quality from the DALL-E-style body into the hosted tool so + // OpenWebUI's size/quality selectors actually take effect. Everything else + // (model, n, background, moderation, output_compression) is left to the + // Codex backend's defaults — today that's `gpt-image-2`. + const toolConfig: Record = { type: "image_generation", output_format: "png" }; + if (typeof body.size === "string" && body.size.trim()) { + toolConfig.size = body.size.trim(); + } + if (typeof body.quality === "string" && body.quality.trim()) { + toolConfig.quality = mapDalleQualityToImageTool(body.quality.trim()); + } + + const upstreamBody: Record = { + model, + instructions: + "You must call the image_generation tool exactly once to fulfill the user's request. Do not add narration.", + input: [ + { + role: "user", + content: [{ type: "input_text", text: prompt }], + }, + ], + tools: [toolConfig], + stream: true, + store: false, + }; + + const headers: Record = { + "Content-Type": "application/json", + Accept: "text/event-stream", + Authorization: `Bearer ${token}`, + Version: getCodexClientVersion(), + "User-Agent": getCodexUserAgent(), + originator: "codex_cli_rs", + }; + if (typeof workspaceId === "string" && workspaceId) { + headers["chatgpt-account-id"] = workspaceId; + headers["session_id"] = workspaceId; + } + + if (log) { + log.info( + "IMAGE", + `${provider}/${model} (codex-responses) | prompt: "${prompt.slice(0, 60)}..."` + ); + } + + const collected: Array<{ b64_json: string; revised_prompt?: string }> = []; + for (let i = 0; i < requestedCount; i++) { + let response: Response; + try { + response = await fetch(providerConfig.baseUrl, { + method: "POST", + headers, + body: JSON.stringify(upstreamBody), + }); + } catch (err) { + if (log) log.error("IMAGE", `${provider} fetch error: ${(err as Error).message}`); + return saveImageErrorResult({ + provider, + model, + status: 502, + startTime, + error: `Image provider error: ${(err as Error).message}`, + requestBody: upstreamBody, + }); + } + + if (!response.ok) { + const errorText = await response.text(); + if (log) + log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); + return saveImageErrorResult({ + provider, + model, + status: response.status, + startTime, + error: errorText, + requestBody: upstreamBody, + }); + } + + const rawSSE = await response.text(); + const items = extractImageGenerationCalls(rawSSE); + if (items.length === 0) { + return saveImageErrorResult({ + provider, + model, + status: 502, + startTime, + error: + "Codex completed without producing an image_generation_call — the model may have declined the tool", + requestBody: upstreamBody, + }); + } + for (const item of items) { + collected.push({ + b64_json: item.b64, + ...(item.revisedPrompt ? { revised_prompt: item.revisedPrompt } : {}), + }); + } + } + + const wantsUrl = body.response_format !== "b64_json"; + const data = wantsUrl + ? collected.map((item) => ({ + url: `data:image/png;base64,${item.b64_json}`, + ...(item.revised_prompt ? { revised_prompt: item.revised_prompt } : {}), + })) + : collected; + + return saveImageSuccessResult({ + provider, + model, + startTime, + requestBody: upstreamBody, + responseBody: { images_count: data.length }, + images: data, + }); +} + function saveImageSuccessResult({ provider, model, diff --git a/tests/manual/image-generation.http b/tests/manual/image-generation.http index ed37705a94..3fbe227aaf 100644 --- a/tests/manual/image-generation.http +++ b/tests/manual/image-generation.http @@ -50,4 +50,44 @@ Content-Type: application/json "image": "" } +### +# @name Omniroute - Codex image via /v1/images/generations (DALL-E shim) +# Translates a DALL-E-style request into a /responses call with the +# image_generation hosted tool. Use with OpenWebUI's image generation feature. +POST {{omniroute-address}}/v1/images/generations +Authorization: Bearer {{OMNIROUTE_API_KEY}} +Content-Type: application/json + +{ + "model": "codex/gpt-5.4", + "prompt": "A happy red kitten, highly detailed fur, studio lighting", + "n": 1, + "size": "1024x1024", + "response_format": "b64_json" +} + +### +# @name Omniroute - Codex hosted image_generation (Responses API) +# Requires a Codex provider connection authenticated via ChatGPT OAuth. +# Expect SSE stream to include `response.output_item.done` with +# `item.type === "image_generation_call"` and a base64 PNG in `item.result`. +POST {{omniroute-address}}/v1/responses +Authorization: Bearer {{OMNIROUTE_API_KEY}} +Content-Type: application/json + +{ + "model": "gpt-5.3-codex", + "instructions": "Generate an image when the user asks for one.", + "input": [ + { + "role": "user", + "content": [ + { "type": "input_text", "text": "Draw a happy red kitten as a PNG." } + ] + } + ], + "tools": [{ "type": "image_generation", "output_format": "png" }], + "stream": true +} + ### \ No newline at end of file diff --git a/tests/unit/executor-codex.test.ts b/tests/unit/executor-codex.test.ts index 6d8329b4cd..32d81f5d92 100644 --- a/tests/unit/executor-codex.test.ts +++ b/tests/unit/executor-codex.test.ts @@ -332,3 +332,168 @@ test("CodexExecutor.refreshCredentials refreshes OAuth tokens and returns null w globalThis.fetch = originalFetch; } }); + +test("CodexExecutor.transformRequest preserves image_generation hosted tool for Codex CLI", () => { + const executor = new CodexExecutor(); + const body = { + _nativeCodexPassthrough: true, + input: [], + tools: [{ type: "image_generation", output_format: "png" }], + }; + + const result = executor.transformRequest("gpt-5.3-codex", body, true, { + requestEndpointPath: "/responses", + }); + + assert.deepEqual(result.tools, [{ type: "image_generation", output_format: "png" }]); +}); + +test("CodexExecutor.transformRequest preserves web_search and file_search hosted tools", () => { + const executor = new CodexExecutor(); + const body = { + _nativeCodexPassthrough: true, + input: [], + tools: [ + { type: "web_search" }, + { type: "file_search" }, + { type: "image_generation", output_format: "png" }, + ], + }; + + const result = executor.transformRequest("gpt-5.3-codex", body, true, { + requestEndpointPath: "/responses", + }); + + assert.deepEqual(result.tools, [ + { type: "web_search" }, + { type: "file_search" }, + { type: "image_generation", output_format: "png" }, + ]); +}); + +test("CodexExecutor.transformRequest drops unknown hosted tool types", () => { + const executor = new CodexExecutor(); + const body = { + _nativeCodexPassthrough: true, + input: [], + tools: [{ type: "made_up_tool" }, { type: "image_generation", output_format: "png" }], + }; + + const result = executor.transformRequest("gpt-5.3-codex", body, true, { + requestEndpointPath: "/responses", + }); + + assert.deepEqual(result.tools, [{ type: "image_generation", output_format: "png" }]); +}); + +test("CodexExecutor.transformRequest keeps valid function tools and drops empty-named ones", () => { + const executor = new CodexExecutor(); + const body = { + _nativeCodexPassthrough: true, + input: [], + tools: [ + { type: "function", function: { name: "" } }, + { type: "function", function: { name: " " } }, + { type: "function", function: { name: "shell", parameters: {} } }, + ], + }; + + const result = executor.transformRequest("gpt-5.3-codex", body, true, { + requestEndpointPath: "/responses", + }); + + assert.equal(result.tools.length, 1); + assert.equal(result.tools[0].function.name, "shell"); +}); + +test("CodexExecutor.transformRequest leaves hosted tool_choice untouched and strips stale function tool_choice", () => { + const executor = new CodexExecutor(); + + const hostedChoice = executor.transformRequest( + "gpt-5.3-codex", + { + _nativeCodexPassthrough: true, + input: [], + tools: [{ type: "image_generation", output_format: "png" }], + tool_choice: { type: "image_generation" }, + }, + true, + { requestEndpointPath: "/responses" } + ); + + assert.deepEqual(hostedChoice.tool_choice, { type: "image_generation" }); + + const staleChoice = executor.transformRequest( + "gpt-5.3-codex", + { + _nativeCodexPassthrough: true, + input: [], + tools: [{ type: "function", function: { name: "shell" } }], + tool_choice: { type: "function", name: "ghost" }, + }, + true, + { requestEndpointPath: "/responses" } + ); + + assert.equal(staleChoice.tool_choice, undefined); +}); + +test("CodexExecutor.transformRequest drops hosted tools that also declare name or function properties", () => { + const executor = new CodexExecutor(); + const body = { + _nativeCodexPassthrough: true, + input: [], + tools: [ + { type: "image_generation", name: "img", output_format: "png" }, + { type: "image_generation", function: { name: "img" }, output_format: "png" }, + { type: "image_generation", output_format: "png" }, + ], + }; + + const result = executor.transformRequest("gpt-5.3-codex", body, true, { + requestEndpointPath: "/responses", + }); + + assert.deepEqual(result.tools, [{ type: "image_generation", output_format: "png" }]); +}); + +test("CodexExecutor.transformRequest defaults store to false when image_generation tool is present", () => { + const executor = new CodexExecutor(); + + const withImageGen = executor.transformRequest( + "gpt-5.3-codex", + { + _nativeCodexPassthrough: true, + input: [], + tools: [{ type: "image_generation", output_format: "png" }], + }, + true, + { requestEndpointPath: "/responses" } + ); + assert.equal(withImageGen.store, false); + + const withoutImageGen = executor.transformRequest( + "gpt-5.3-codex", + { + _nativeCodexPassthrough: true, + input: [], + tools: [{ type: "function", function: { name: "shell" } }], + }, + true, + { requestEndpointPath: "/responses" } + ); + assert.equal(withoutImageGen.store, true); + + const explicitTrue = executor.transformRequest( + "gpt-5.3-codex", + { + _nativeCodexPassthrough: true, + input: [], + store: true, + tools: [{ type: "image_generation", output_format: "png" }], + }, + true, + { requestEndpointPath: "/responses" } + ); + assert.equal(explicitTrue.store, true); +}); diff --git a/tests/unit/image-generation-handler.test.ts b/tests/unit/image-generation-handler.test.ts index 91d1da0e32..caa27fe2bd 100644 --- a/tests/unit/image-generation-handler.test.ts +++ b/tests/unit/image-generation-handler.test.ts @@ -1470,3 +1470,215 @@ test("handleImageGeneration normalizes Imagen3 single-image payloads and non-ok } } }); + +const { extractImageGenerationCalls } = await import("../../open-sse/handlers/imageGeneration.ts"); + +function buildCodexSSE(items) { + const frames = items.map((item) => + JSON.stringify({ type: "response.output_item.done", item }) + ); + return frames.map((frame) => `event: response.output_item.done\ndata: ${frame}\n`).join("\n"); +} + +test("extractImageGenerationCalls pulls base64 PNG from image_generation_call output items", () => { + const sse = buildCodexSSE([ + { + type: "image_generation_call", + id: "ig_1", + status: "completed", + revised_prompt: "a small kitten", + result: "aGVsbG8=", + }, + ]); + const calls = extractImageGenerationCalls(sse); + assert.equal(calls.length, 1); + assert.equal(calls[0].b64, "aGVsbG8="); + assert.equal(calls[0].revisedPrompt, "a small kitten"); +}); + +test("extractImageGenerationCalls ignores unrelated events and malformed lines", () => { + const sse = [ + "event: response.in_progress", + `data: ${JSON.stringify({ type: "response.in_progress" })}`, + "", + "data: not-json", + "", + "event: response.output_item.done", + `data: ${JSON.stringify({ + type: "response.output_item.done", + item: { type: "message", role: "assistant", content: [] }, + })}`, + "", + "data: [DONE]", + ].join("\n"); + assert.deepEqual(extractImageGenerationCalls(sse), []); +}); + +test("handleImageGeneration routes codex image requests through /responses with image_generation tool", async () => { + const originalFetch = globalThis.fetch; + let captured; + globalThis.fetch = async (url, options = {}) => { + captured = { + url: String(url), + headers: options.headers, + body: JSON.parse(String(options.body || "{}")), + }; + const sse = buildCodexSSE([ + { + type: "image_generation_call", + id: "ig_1", + status: "completed", + revised_prompt: "happy red kitten", + result: "a2l0dGVu", + }, + ]); + return new Response(sse, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }; + + try { + const result = await handleImageGeneration({ + body: { + model: "codex/gpt-5.4", + prompt: "Draw a happy red kitten", + response_format: "b64_json", + }, + credentials: { + accessToken: "codex-token", + providerSpecificData: { workspaceId: "acct-123" }, + }, + log: null, + }); + + assert.equal(result.success, true); + assert.equal(captured.url, "https://chatgpt.com/backend-api/codex/responses"); + assert.equal(captured.headers.Authorization, "Bearer codex-token"); + assert.equal(captured.headers["chatgpt-account-id"], "acct-123"); + assert.equal(captured.body.model, "gpt-5.4"); + assert.equal(captured.body.stream, true); + assert.equal(captured.body.store, false); + assert.deepEqual(captured.body.tools, [{ type: "image_generation", output_format: "png" }]); + assert.equal(captured.body.input[0].role, "user"); + assert.equal(captured.body.input[0].content[0].text, "Draw a happy red kitten"); + assert.equal(result.data.data[0].b64_json, "a2l0dGVu"); + assert.equal(result.data.data[0].revised_prompt, "happy red kitten"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleImageGeneration (codex) returns a data URL when response_format is not b64_json", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + const sse = buildCodexSSE([ + { type: "image_generation_call", id: "ig_2", status: "completed", result: "YWJjZA==" }, + ]); + return new Response(sse, { status: 200 }); + }; + + try { + const result = await handleImageGeneration({ + body: { model: "cx/gpt-5.4", prompt: "kitten" }, + credentials: { accessToken: "codex-token" }, + log: null, + }); + assert.equal(result.success, true); + assert.equal(result.data.data[0].url, "data:image/png;base64,YWJjZA=="); + assert.equal(result.data.data[0].b64_json, undefined); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleImageGeneration (codex) surfaces an error when no image_generation_call is emitted", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + const sse = buildCodexSSE([ + { type: "message", role: "assistant", content: [{ type: "output_text", text: "hi" }] }, + ]); + return new Response(sse, { status: 200 }); + }; + + try { + const result = await handleImageGeneration({ + body: { model: "codex/gpt-5.4", prompt: "kitten" }, + credentials: { accessToken: "codex-token" }, + log: null, + }); + assert.equal(result.success, false); + assert.equal(result.status, 502); + assert.match(result.error, /image_generation_call/); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleImageGeneration (codex) propagates upstream HTTP errors", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response("upstream boom", { status: 403, headers: { "content-type": "text/plain" } }); + + try { + const result = await handleImageGeneration({ + body: { model: "codex/gpt-5.4", prompt: "kitten" }, + credentials: { accessToken: "codex-token" }, + log: null, + }); + assert.equal(result.success, false); + assert.equal(result.status, 403); + assert.match(result.error, /upstream boom/); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleImageGeneration (codex) forwards size and maps DALL-E quality to hosted tool config", async () => { + const originalFetch = globalThis.fetch; + let captured; + globalThis.fetch = async (_url, options = {}) => { + captured = JSON.parse(String(options.body || "{}")); + const sse = buildCodexSSE([ + { type: "image_generation_call", id: "ig_1", status: "completed", result: "YWJj" }, + ]); + return new Response(sse, { status: 200 }); + }; + + try { + await handleImageGeneration({ + body: { + model: "codex/gpt-5.4", + prompt: "kitten", + size: "1024x1792", + quality: "hd", + }, + credentials: { accessToken: "codex-token" }, + log: null, + }); + assert.deepEqual(captured.tools, [ + { + type: "image_generation", + output_format: "png", + size: "1024x1792", + quality: "high", + }, + ]); + + await handleImageGeneration({ + body: { model: "codex/gpt-5.4", prompt: "kitten", quality: "standard" }, + credentials: { accessToken: "codex-token" }, + log: null, + }); + assert.equal(captured.tools[0].quality, "medium"); + + await handleImageGeneration({ + body: { model: "codex/gpt-5.4", prompt: "kitten" }, + credentials: { accessToken: "codex-token" }, + log: null, + }); + assert.deepEqual(captured.tools, [{ type: "image_generation", output_format: "png" }]); + } finally { + globalThis.fetch = originalFetch; + } +});