diff --git a/README.md b/README.md index 2d111ba..9be0fb4 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ It is not trying to be a large multi-provider gateway. If you want a compact, un - **Lightweight by design** — small codebase, minimal moving parts - **Multiple providers, one proxy** — Claude OAuth, OpenAI Codex (ChatGPT) OAuth, and an experimental Cursor local-login provider coexist; per-provider account pools, cooldown, refresh, and stats - **Multi-account support** — load multiple OAuth tokens per provider with sticky routing, automatic failover, and per-account usage tracking -- **OpenAI-compatible API** — supports `/v1/chat/completions`, `/v1/responses`, and `/v1/models` +- **OpenAI-compatible API** — supports `/v1/chat/completions`, `/v1/responses`, `/v1/images/generations`, `/v1/images/edits`, and `/v1/models` - **Claude native passthrough** — supports `/v1/messages` and `/v1/messages/count_tokens` - **Claude Code friendly** — works with both `Authorization: Bearer` and `x-api-key` - **Streaming, tools, images, and reasoning** — covers the main usage patterns without a large framework @@ -205,6 +205,8 @@ When **more than one provider has accounts**, the historical routing table above | -------------------------------- | --------- | ------------------------------------------------------------------- | ------------------------------------------------------------------ | | `POST /v1/chat/completions` | ✅ | ✅ (Chat ↔ Responses translator — reasoning as `reasoning_content`) | ✅ (`chat.completion.chunk` SSE; reasoning as `reasoning_content`) | | `POST /v1/responses` | ✅ | ✅ (passthrough) | ✅ | +| `POST /v1/images/generations` | ❌ | ✅ (Codex Responses `image_generation` tool) | ❌ | +| `POST /v1/images/edits` | ❌ | ✅ (Codex Responses `image_generation` tool with input images) | ❌ | | `POST /v1/messages` | ✅ | ✅ (Anthropic ↔ Responses translator — see below) | ✅ (Anthropic Messages SSE — see below) | | `POST /v1/messages/count_tokens` | ✅ | ❌ (501) | ❌ (501) | @@ -212,6 +214,8 @@ For Cursor all three OpenAI-compatible endpoints are wired natively: `req.path` For Codex (ChatGPT-account backend) the same coverage is achieved through a dedicated Chat ↔ Responses ↔ Anthropic translator pair (`src/upstream/responses-translator.ts`): incoming Chat or Anthropic requests are translated to OpenAI Responses upstream, the streaming Responses SSE response is translated back to the original wire format, and non-streaming requests aggregate the SSE locally before responding. Tool calls, system prompts (lifted into `instructions`), `reasoning_effort`/`thinking`, multi-turn conversations and `response_format` `json_schema` are all supported. Codex-specific incompatibilities (`max_output_tokens`, `parallel_tool_calls`) are stripped automatically in the codex handler — you don't have to think about them. +Codex image routes (`/v1/images/generations`, `/v1/images/edits`) do not call the public OpenAI Images API and do not require a separate OpenAI API key. They route through the ChatGPT-account Codex Responses backend with the hosted `image_generation` tool (`gpt-image-2`) and return OpenAI-compatible `{ data: [{ b64_json }] }` responses. Codex may silently coerce `size` / `quality` to backend defaults, so exact dimensions are best-effort on this OAuth-backed path. + #### Codex `/v1/responses` body requirements The ChatGPT codex backend rejects requests that don't include `stream: true`, `store: false`, and `instructions`, and 400s on a couple of public Responses fields (`max_output_tokens`, `parallel_tool_calls`). auth2api applies the same sanitize-and-force-stream pattern to all three codex endpoints (`/v1/chat/completions`, `/v1/messages`, `/v1/responses`): @@ -234,6 +238,8 @@ The decoder routes Cursor's chain-of-thought (`reasoning`) bytes to `response.re | -------------------------------- | --------------------------------------------------------------------- | | `POST /v1/chat/completions` | OpenAI-compatible chat | | `POST /v1/responses` | OpenAI Responses API compatibility | +| `POST /v1/images/generations` | OpenAI-compatible image generation via Codex OAuth | +| `POST /v1/images/edits` | OpenAI-compatible image edit/reference generation via Codex OAuth | | `POST /v1/messages` | Claude native passthrough | | `POST /v1/messages/count_tokens` | Claude token counting | | `GET /v1/models` | List available models | diff --git a/src/handlers/images.ts b/src/handlers/images.ts new file mode 100644 index 0000000..8d2fa1d --- /dev/null +++ b/src/handlers/images.ts @@ -0,0 +1,564 @@ +import { Request, Response as ExpressResponse } from "express"; +import { Config, isDebugLevel } from "../config"; +import { ProviderRegistry } from "../providers/registry"; +import { proxyWithRetry } from "../utils/http"; +import { tagStatsModel, tagStatsUsage } from "../stats/recorder"; +import { resolveModel } from "../upstream/translator"; +import { normalizeCodexResponsesBody } from "../upstream/codex-api"; +import { drainCodexResponsesSse } from "../upstream/responses-translator"; + +type ImageAction = "generate" | "edit"; + +interface UploadedPart { + name: string; + filename?: string; + contentType?: string; + data: Buffer; +} + +interface ParsedImageRequest { + prompt: string; + imageModel: string; + codexModel: string; + responseFormat: "b64_json"; + imageUrls: string[]; + maskUrl?: string; + options: Record; +} + +interface GeneratedImage { + b64?: string; + url?: string; + revisedPrompt?: string; +} + +function openaiErrorBody(_status: number, body: string): any { + try { + const parsed = JSON.parse(body); + const msg = + parsed?.error?.message || + (typeof parsed?.detail === "string" ? parsed.detail : null) || + parsed?.error?.error?.message || + "Upstream request failed"; + const type = parsed?.error?.type || "upstream_error"; + return { error: { message: msg, type } }; + } catch { + return { + error: { message: "Upstream request failed", type: "upstream_error" }, + }; + } +} + +function internalError(resp: ExpressResponse): void { + if (!resp.headersSent) { + resp.status(500).json({ error: { message: "Internal server error" } }); + } else if (!resp.writableEnded) { + resp.end(); + } +} + +function badRequest(resp: ExpressResponse, message: string): void { + resp.status(400).json({ error: { message, type: "invalid_request_error" } }); +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function oneOf( + body: Record, + keys: string[], +): string | undefined { + for (const key of keys) { + const value = stringValue(body[key]); + if (value) return value; + } + return undefined; +} + +function normalizeImageReference(value: unknown): string | undefined { + const raw = stringValue(value); + if (!raw) return undefined; + if ( + raw.startsWith("data:") || + raw.startsWith("http://") || + raw.startsWith("https://") + ) { + return raw; + } + // Accept bare base64 for simple JSON clients. + return `data:image/png;base64,${raw}`; +} + +function collectImageReferences(...values: unknown[]): string[] { + const refs: string[] = []; + for (const value of values) { + if (Array.isArray(value)) { + refs.push(...collectImageReferences(...value)); + continue; + } + if (value && typeof value === "object") { + const obj = value as Record; + const nested = + normalizeImageReference(obj.url) || + normalizeImageReference(obj.image_url) || + normalizeImageReference(obj.b64_json); + if (nested) refs.push(nested); + continue; + } + const ref = normalizeImageReference(value); + if (ref) refs.push(ref); + } + return refs; +} + +function guessMime(filename?: string, fallback = "image/png"): string { + const lower = (filename || "").toLowerCase(); + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".gif")) return "image/gif"; + return fallback; +} + +function dataUrlFromPart(part: UploadedPart): string { + const mime = part.contentType || guessMime(part.filename); + return `data:${mime};base64,${part.data.toString("base64")}`; +} + +function parseContentDisposition(value: string): Record { + const out: Record = {}; + for (const piece of value.split(";")) { + const [rawKey, ...rawRest] = piece.trim().split("="); + if (!rawKey || rawRest.length === 0) continue; + let rawValue = rawRest.join("=").trim(); + if (rawValue.startsWith('"') && rawValue.endsWith('"')) { + rawValue = rawValue.slice(1, -1); + } + out[rawKey.toLowerCase()] = rawValue; + } + return out; +} + +function stripTrailingLineBreak(data: Buffer): Buffer { + if ( + data.length >= 2 && + data[data.length - 2] === 13 && + data[data.length - 1] === 10 + ) { + return data.subarray(0, data.length - 2); + } + if (data.length >= 1 && data[data.length - 1] === 10) { + return data.subarray(0, data.length - 1); + } + return data; +} + +function findHeaderEnd(part: Buffer): { index: number; size: number } | null { + const crlf = Buffer.from("\r\n\r\n"); + const lf = Buffer.from("\n\n"); + const crlfIndex = part.indexOf(crlf); + if (crlfIndex >= 0) return { index: crlfIndex, size: crlf.length }; + const lfIndex = part.indexOf(lf); + if (lfIndex >= 0) return { index: lfIndex, size: lf.length }; + return null; +} + +function parseMultipartFormData( + contentType: string, + body: Buffer, +): { fields: Map; files: UploadedPart[] } { + const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/i); + const boundaryValue = boundaryMatch?.[1] || boundaryMatch?.[2]; + if (!boundaryValue) { + throw new Error("multipart boundary missing"); + } + + const boundary = Buffer.from(`--${boundaryValue}`); + const fields = new Map(); + const files: UploadedPart[] = []; + + let cursor = body.indexOf(boundary); + while (cursor >= 0) { + cursor += boundary.length; + if (body[cursor] === 45 && body[cursor + 1] === 45) break; + if (body[cursor] === 13 && body[cursor + 1] === 10) cursor += 2; + else if (body[cursor] === 10) cursor += 1; + + const nextBoundary = body.indexOf(boundary, cursor); + if (nextBoundary < 0) break; + let rawPart = body.subarray(cursor, nextBoundary); + rawPart = stripTrailingLineBreak(rawPart); + cursor = nextBoundary; + + const headerEnd = findHeaderEnd(rawPart); + if (!headerEnd) continue; + const rawHeaders = rawPart.subarray(0, headerEnd.index).toString("utf8"); + const data = rawPart.subarray(headerEnd.index + headerEnd.size); + const headers = new Map(); + for (const line of rawHeaders.split(/\r?\n/)) { + const idx = line.indexOf(":"); + if (idx < 0) continue; + headers.set( + line.slice(0, idx).trim().toLowerCase(), + line.slice(idx + 1).trim(), + ); + } + + const disposition = headers.get("content-disposition"); + if (!disposition) continue; + const parts = parseContentDisposition(disposition); + const name = parts.name; + if (!name) continue; + const filename = parts.filename; + const contentTypeHeader = headers.get("content-type"); + + if (filename || contentTypeHeader?.startsWith("image/")) { + files.push({ name, filename, contentType: contentTypeHeader, data }); + continue; + } + const existing = fields.get(name) || []; + existing.push(data.toString("utf8")); + fields.set(name, existing); + } + + return { fields, files }; +} + +function fieldValue( + fields: Map, + name: string, +): string | undefined { + return fields.get(name)?.[0]?.trim() || undefined; +} + +function fieldValues(fields: Map, name: string): string[] { + return (fields.get(name) || []).map((v) => v.trim()).filter(Boolean); +} + +function imageToolOptions( + body: Record, + action: ImageAction, +): Record { + const tool: Record = { + type: "image_generation", + action, + }; + const imageModel = stringValue(body.model) || "gpt-image-2"; + tool.model = imageModel; + + for (const key of [ + "size", + "quality", + "background", + "output_format", + "output_compression", + "moderation", + "partial_images", + "input_fidelity", + ]) { + if (body[key] !== undefined && body[key] !== null && body[key] !== "") { + tool[key] = body[key]; + } + } + + if (tool.output_format === undefined) tool.output_format = "png"; + return tool; +} + +function parseJsonImageRequest( + rawBody: Record, + action: ImageAction, +): ParsedImageRequest { + const prompt = stringValue(rawBody.prompt) || ""; + const responseFormat = stringValue(rawBody.response_format) || "b64_json"; + if (responseFormat !== "b64_json") { + throw new Error( + "Codex-backed image routes only support response_format=b64_json", + ); + } + + const imageUrls = + action === "edit" + ? collectImageReferences( + rawBody.image, + rawBody.images, + rawBody.image_url, + rawBody.image_urls, + ) + : []; + + return { + prompt, + imageModel: stringValue(rawBody.model) || "gpt-image-2", + codexModel: + oneOf(rawBody, ["codex_model", "response_model", "routing_model"]) || + "gpt-5.5", + responseFormat: "b64_json", + imageUrls, + maskUrl: normalizeImageReference(rawBody.mask), + options: imageToolOptions(rawBody, action), + }; +} + +function parseMultipartImageRequest(req: Request): ParsedImageRequest { + const contentType = String(req.headers["content-type"] || ""); + const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0); + const { fields, files } = parseMultipartFormData(contentType, body); + const record: Record = {}; + for (const [key, values] of fields) { + record[key] = values.length > 1 ? values : values[0]; + } + + const parsed = parseJsonImageRequest(record, "edit"); + const imageFiles = files.filter( + (f) => f.name === "image" || f.name === "image[]", + ); + const maskFile = files.find((f) => f.name === "mask"); + parsed.imageUrls.push(...imageFiles.map(dataUrlFromPart)); + if (!parsed.maskUrl && maskFile) parsed.maskUrl = dataUrlFromPart(maskFile); + return parsed; +} + +function parseImageRequest( + req: Request, + action: ImageAction, +): ParsedImageRequest { + const contentType = String(req.headers["content-type"] || ""); + if (contentType.startsWith("multipart/form-data")) { + if (action !== "edit") { + throw new Error("multipart form data is only supported for image edits"); + } + return parseMultipartImageRequest(req); + } + const rawBody = + req.body && typeof req.body === "object" && !Buffer.isBuffer(req.body) + ? (req.body as Record) + : {}; + return parseJsonImageRequest(rawBody, action); +} + +function buildCodexImageBody( + parsed: ParsedImageRequest, + action: ImageAction, +): any { + const content: any[] = [{ type: "input_text", text: parsed.prompt }]; + for (const url of parsed.imageUrls) { + content.push({ type: "input_image", image_url: url }); + } + + const tool = { ...parsed.options }; + if (parsed.maskUrl) { + tool.input_image_mask = { image_url: parsed.maskUrl }; + } + + return normalizeCodexResponsesBody({ + model: resolveModel(parsed.codexModel), + instructions: + action === "edit" + ? "Edit the supplied image using the image_generation tool. Return the generated image." + : "Generate the requested image using the image_generation tool. Return the generated image.", + input: [{ role: "user", content }], + tools: [tool], + tool_choice: { type: "image_generation" }, + store: false, + stream: true, + }); +} + +function findImagePayload(item: any): GeneratedImage | null { + if (!item || typeof item !== "object") return null; + if (item.type === "image_generation_call") { + const b64 = + stringValue(item.result) || + stringValue(item.b64_json) || + stringValue(item.image?.b64_json); + const url = stringValue(item.url) || stringValue(item.image_url); + if (b64 || url) { + return { + b64, + url, + revisedPrompt: + stringValue(item.revised_prompt) || + stringValue(item.revisedPrompt) || + stringValue(item.prompt), + }; + } + } + + const content = Array.isArray(item.content) ? item.content : []; + for (const part of content) { + if (part?.type === "output_image" || part?.type === "image") { + const b64 = + stringValue(part.b64_json) || stringValue(part.image?.b64_json); + const url = stringValue(part.url) || stringValue(part.image_url); + if (b64 || url) { + return { + b64, + url, + revisedPrompt: + stringValue(part.revised_prompt) || + stringValue(part.revisedPrompt) || + stringValue(item.revised_prompt), + }; + } + } + } + + return null; +} + +function extractGeneratedImages( + outputItems: any[], + completedResponse: any, +): GeneratedImage[] { + const candidates = [ + ...outputItems, + ...(Array.isArray(completedResponse?.output) + ? completedResponse.output + : []), + ]; + const seen = new Set(); + const images: GeneratedImage[] = []; + for (const item of candidates) { + const image = findImagePayload(item); + if (!image) continue; + const key = image.b64 || image.url || ""; + if (!key || seen.has(key)) continue; + seen.add(key); + images.push(image); + } + return images; +} + +function imageResponse(images: GeneratedImage[]): any { + return { + created: Math.floor(Date.now() / 1000), + data: images.map((image) => { + const item: Record = {}; + if (image.b64) item.b64_json = image.b64; + if (image.url) item.url = image.url; + if (image.revisedPrompt) item.revised_prompt = image.revisedPrompt; + return item; + }), + }; +} + +function usageFromResponsesUsage(usage: any) { + return { + inputTokens: usage?.input_tokens || 0, + outputTokens: usage?.output_tokens || 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: usage?.input_tokens_details?.cached_tokens || 0, + reasoningOutputTokens: usage?.output_tokens_details?.reasoning_tokens || 0, + }; +} + +function createImageHandler( + config: Config, + registry: ProviderRegistry, + action: ImageAction, +) { + return async (req: Request, resp: ExpressResponse): Promise => { + try { + let parsed: ParsedImageRequest; + try { + parsed = parseImageRequest(req, action); + } catch (err: any) { + badRequest(resp, err.message); + return; + } + + if (!parsed.prompt) { + badRequest(resp, "prompt is required"); + return; + } + if (action === "edit" && parsed.imageUrls.length === 0) { + badRequest(resp, "image is required for image edits"); + return; + } + const n = Number((req.body as any)?.n ?? 1); + if (Number.isFinite(n) && n > 1) { + badRequest(resp, "Codex-backed image routes currently support n=1"); + return; + } + + const provider = registry.get("codex"); + const upstreamBody = buildCodexImageBody(parsed, action); + tagStatsModel(resp, parsed.imageModel, provider.id); + + if (isDebugLevel(config.debug, "verbose")) { + console.log(`[DEBUG] Codex image ${action} body:`); + console.log(JSON.stringify(upstreamBody, null, 2)); + } + + await proxyWithRetry(`Images(${action},codex)`, resp, config, { + manager: provider.manager, + upstream: (account, signal) => + provider.callMessages({ + body: upstreamBody, + request: req, + account, + config, + signal, + }), + success: async (upstream, account) => { + const drained = await drainCodexResponsesSse(upstream); + const images = extractGeneratedImages( + drained.outputItems, + drained.completedResponse, + ); + if (drained.upstreamError && images.length === 0) { + provider.manager.recordFailure( + account.token.email, + "server", + drained.upstreamError, + ); + resp.status(502).json({ + error: { + message: drained.upstreamError, + type: "upstream_error", + }, + }); + return; + } + if (images.length === 0) { + const message = + "Codex did not return an image_generation_call result"; + provider.manager.recordFailure( + account.token.email, + "server", + message, + ); + resp.status(502).json({ + error: { message, type: "upstream_error" }, + }); + return; + } + + const usage = usageFromResponsesUsage(drained.usage); + provider.manager.recordSuccess(account.token.email, usage); + tagStatsUsage(resp, usage); + resp.json(imageResponse(images)); + }, + errorAdapter: openaiErrorBody, + }); + } catch (err: any) { + console.error(`Images ${action} handler error:`, err.message); + internalError(resp); + } + }; +} + +export function createImageGenerationsHandler( + config: Config, + registry: ProviderRegistry, +) { + return createImageHandler(config, registry, "generate"); +} + +export function createImageEditsHandler( + config: Config, + registry: ProviderRegistry, +) { + return createImageHandler(config, registry, "edit"); +} diff --git a/src/index.ts b/src/index.ts index be49146..4dd5065 100644 --- a/src/index.ts +++ b/src/index.ts @@ -184,6 +184,8 @@ async function startServer(): Promise { console.log(`Endpoints:`); console.log(` POST /v1/chat/completions`); console.log(` POST /v1/responses`); + console.log(` POST /v1/images/generations`); + console.log(` POST /v1/images/edits`); console.log(` POST /v1/messages`); console.log(` POST /v1/messages/count_tokens`); console.log(` GET /v1/models`); diff --git a/src/server.ts b/src/server.ts index 8ab8bb4..c25d4fa 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,6 +7,10 @@ import { createResponsesCompactHandler, createResponsesHandler, } from "./handlers/openai"; +import { + createImageEditsHandler, + createImageGenerationsHandler, +} from "./handlers/images"; import { createMessagesHandler, createCountTokensHandler, @@ -282,6 +286,15 @@ export function createServer( "/backend-api/codex/responses/compact", createResponsesCompactHandler(config, registry), ); + app.post( + "/v1/images/generations", + createImageGenerationsHandler(config, registry), + ); + app.post( + "/v1/images/edits", + express.raw({ type: "multipart/form-data", limit: config["body-limit"] }), + createImageEditsHandler(config, registry), + ); // Routes — Anthropic native passthrough app.post("/v1/messages", createMessagesHandler(config, registry)); diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index 0962b80..969e69e 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -160,7 +160,11 @@ async function requestText(options: { path: string; headers?: Record; body?: unknown; -}): Promise<{ status: number; body: string; headers: http.IncomingHttpHeaders }> { +}): Promise<{ + status: number; + body: string; + headers: http.IncomingHttpHeaders; +}> { const address = serverAddress(options.server); const payload = options.body ? JSON.stringify(options.body) : undefined; @@ -1432,7 +1436,6 @@ test("cursor SSE forwards deltas as soon as upstream HTTP/2 chunks arrive (no wh ); }); - test("cursor /v1/messages emits Anthropic Messages SSE for bare model names in cursor-only mode", async (t) => { const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-smoke-")); saveToken( @@ -1993,7 +1996,9 @@ test("codex /v1/chat/completions non-stream still captures final SSE event when const ev = (event: string, data: unknown) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; const sseNoTrailingNewline = - ev("response.created", { response: { id: "resp_x", status: "in_progress" } }) + + ev("response.created", { + response: { id: "resp_x", status: "in_progress" }, + }) + ev("response.output_text.delta", { delta: "answer" }) + `event: response.completed\ndata: ${JSON.stringify({ response: { @@ -2085,7 +2090,7 @@ test("codex /v1/responses non-stream splices streamed output_item.done into comp status: "completed", call_id: "call_xyz", name: "get_weather", - arguments: "{\"city\":\"Tokyo\"}", + arguments: '{"city":"Tokyo"}', }; const sseBody = @@ -2138,10 +2143,7 @@ test("codex /v1/responses non-stream splices streamed output_item.done into comp assert.equal(jsonResp.body.status, "completed"); // The handler-level splice: completed.response.output was [] but // we should have stitched the three streamed items in order. - assert.ok( - Array.isArray(jsonResp.body.output), - "output must be an array", - ); + assert.ok(Array.isArray(jsonResp.body.output), "output must be an array"); assert.equal( jsonResp.body.output.length, 3, @@ -2153,7 +2155,7 @@ test("codex /v1/responses non-stream splices streamed output_item.done into comp assert.equal(jsonResp.body.output[1].content[0].text, "PONG"); assert.equal(jsonResp.body.output[2].type, "function_call"); assert.equal(jsonResp.body.output[2].call_id, "call_xyz"); - assert.equal(jsonResp.body.output[2].arguments, "{\"city\":\"Tokyo\"}"); + assert.equal(jsonResp.body.output[2].arguments, '{"city":"Tokyo"}'); // Usage from completed.response is preserved. assert.deepEqual(jsonResp.body.usage, { input_tokens: 17, @@ -2236,8 +2238,170 @@ test("codex /v1/responses non-stream prefers upstream-populated output over stre // When upstream supplies output already, it wins. assert.equal(jsonResp.body.output.length, 1); assert.equal(jsonResp.body.output[0].id, "msg_completed"); + assert.equal(jsonResp.body.output[0].content[0].text, "FROM_COMPLETED"); +}); + +test("codex /v1/images/generations routes through image_generation tool", async (t) => { + const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-smoke-")); + saveToken( + authDir, + makeToken({ + accessToken: "codex-access", + email: "codex-image-gen@example.com", + accountUuid: "chatgpt-account-id", + provider: "codex", + }), + ); + + let receivedUpstreamBody: any = null; + const ev = (event: string, data: unknown) => + `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + const imageItem = { + id: "ig_1", + type: "image_generation_call", + status: "completed", + result: Buffer.from("fake-png").toString("base64"), + revised_prompt: "A clean dashboard hero", + }; + const sseBody = + ev("response.output_item.done", { item: imageItem }) + + ev("response.completed", { + response: { + id: "resp_img", + status: "completed", + output: [], + usage: { input_tokens: 9, output_tokens: 2 }, + }, + }); + + const restoreFetch = withMockedFetch(async (input, init) => { + assert.equal( + String(input), + "https://chatgpt.com/backend-api/codex/responses", + ); + receivedUpstreamBody = JSON.parse(String(init?.body || "{}")); + return new Response(sseBody, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + }); + const server = await startAppWithLoadedRegistry(makeConfig(authDir)); + t.after(async () => { + restoreFetch(); + await stopApp(server); + fs.rmSync(authDir, { recursive: true, force: true }); + }); + + const resp = await requestJson({ + server, + method: "POST", + path: "/v1/images/generations", + headers: { Authorization: "Bearer test-key" }, + body: { + model: "gpt-image-2", + codex_model: "gpt-5.5", + prompt: "Generate a clean dashboard hero", + size: "1024x1024", + quality: "high", + response_format: "b64_json", + }, + }); + + assert.equal(resp.status, 200); + assert.equal( + resp.body.data[0].b64_json, + Buffer.from("fake-png").toString("base64"), + ); + assert.equal(resp.body.data[0].revised_prompt, "A clean dashboard hero"); + assert.equal(receivedUpstreamBody.model, "gpt-5.5"); + assert.equal(receivedUpstreamBody.store, false); + assert.equal(receivedUpstreamBody.stream, true); + assert.equal(receivedUpstreamBody.tools[0].type, "image_generation"); + assert.equal(receivedUpstreamBody.tools[0].action, "generate"); + assert.equal(receivedUpstreamBody.tools[0].model, "gpt-image-2"); + assert.equal(receivedUpstreamBody.tools[0].size, "1024x1024"); + assert.equal(receivedUpstreamBody.tools[0].quality, "high"); + assert.deepEqual(receivedUpstreamBody.tool_choice, { + type: "image_generation", + }); + assert.equal(receivedUpstreamBody.input[0].content[0].type, "input_text"); + assert.equal( + receivedUpstreamBody.input[0].content[0].text, + "Generate a clean dashboard hero", + ); +}); + +test("codex /v1/images/edits forwards input images", async (t) => { + const authDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth2api-smoke-")); + saveToken( + authDir, + makeToken({ + accessToken: "codex-access", + email: "codex-image-edit@example.com", + accountUuid: "chatgpt-account-id", + provider: "codex", + }), + ); + + let receivedUpstreamBody: any = null; + const ev = (event: string, data: unknown) => + `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + const imageItem = { + id: "ig_edit", + type: "image_generation_call", + status: "completed", + result: Buffer.from("edited-png").toString("base64"), + }; + const sseBody = + ev("response.output_item.done", { item: imageItem }) + + ev("response.completed", { + response: { + id: "resp_edit", + status: "completed", + output: [], + usage: { input_tokens: 12, output_tokens: 3 }, + }, + }); + + const restoreFetch = withMockedFetch(async (_input, init) => { + receivedUpstreamBody = JSON.parse(String(init?.body || "{}")); + return new Response(sseBody, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + }); + const server = await startAppWithLoadedRegistry(makeConfig(authDir)); + t.after(async () => { + restoreFetch(); + await stopApp(server); + fs.rmSync(authDir, { recursive: true, force: true }); + }); + + const resp = await requestJson({ + server, + method: "POST", + path: "/v1/images/edits", + headers: { Authorization: "Bearer test-key" }, + body: { + model: "gpt-image-2", + prompt: "Make the background white", + image_url: "https://example.com/source.png", + }, + }); + + assert.equal(resp.status, 200); + assert.equal( + resp.body.data[0].b64_json, + Buffer.from("edited-png").toString("base64"), + ); + assert.equal(receivedUpstreamBody.tools[0].action, "edit"); + assert.equal( + receivedUpstreamBody.input[0].content[0].text, + "Make the background white", + ); + assert.equal(receivedUpstreamBody.input[0].content[1].type, "input_image"); assert.equal( - jsonResp.body.output[0].content[0].text, - "FROM_COMPLETED", + receivedUpstreamBody.input[0].content[1].image_url, + "https://example.com/source.png", ); });