Skip to content
Merged
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
1 change: 1 addition & 0 deletions changelog.d/fixes/17184-codex-sol-image-reference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(images):** admit the explicit Codex GPT-6.1 Sol image model and preserve original reference bytes on its edit path instead of applying legacy reference downscaling; reject unsupported masks before dispatch while retaining existing validation and legacy model behavior ([#17184](https://github.com/diegosouzapw/OmniRoute/pull/17184)) — thanks @KiaroSama
41 changes: 2 additions & 39 deletions open-sse/config/imageRegistryData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { AI_HORDE_IMAGE_PROVIDER } from "./providers/registry/aihorde/imageModel
import { toRegistryImageModels as toSyntxImageModels } from "../services/syntxMediaCatalog.ts";
import { ZENMUX_IMAGE_PROVIDER } from "./providers/registry/zenmux/imageModels.ts";
import { MUSE_CODE_IMAGE_PROVIDER } from "./providers/registry/muse-code/imageModels.ts";
import { CODEX_IMAGE_PROVIDER } from "./providers/registry/codex/imageModels.ts";
import { GROK_BUILD_PROXY_BASE_URL } from "./grokBuildEndpoints.ts";

export interface ImageModelEntry {
Expand Down Expand Up @@ -253,45 +254,7 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
supportedSizes: ["1024x1024", "1024x1792", "1792x1024"],
},

// Codex serves images under ChatGPT OAuth two ways (handleCodexImageGeneration):
// - `gpt-image-*` ids use the dedicated `/backend-api/codex/images/{generations,edits}`
// routes, which take the image model as a request field (codexImages.ts);
// - the GPT-6.1/GPT-5.6 ids call `/responses` with the hosted `image_generation` tool,
// whose image model the backend pins to `gpt-image-2-codex`.
codex: {
id: "codex",
alias: "cx",
baseUrl: "https://chatgpt.com/backend-api/codex/responses",
authType: "oauth",
authHeader: "bearer",
format: "codex-responses",
models: [
{
id: "gpt-6.1-sol",
catalogId: "gpt-6.1-sol-image",
name: "GPT 6.1 Sol (Codex Image)",
},
{
id: "gpt-5.6-sol",
catalogId: "gpt-5.6-sol-image",
name: "GPT 5.6 Sol (Codex Image)",
},
{
id: "gpt-5.6-terra",
catalogId: "gpt-5.6-terra-image",
name: "GPT 5.6 Terra (Codex Image)",
},
{
id: "gpt-5.6-luna",
catalogId: "gpt-5.6-luna-image",
name: "GPT 5.6 Luna (Codex Image)",
},
{ id: "gpt-image-2.5-flare", name: "GPT Image 2.5 Flare (Codex)" },
{ id: "gpt-image-2.5-sunburst", name: "GPT Image 2.5 Sunburst (Codex)" },
{ id: "gpt-image-2", name: "GPT Image 2 (Codex)" },
],
supportedSizes: ["1024x1024", "1024x1536", "1536x1024"],
},
codex: CODEX_IMAGE_PROVIDER,

// Cursor plan image generation via the Agent CLI native `generateImage` tool.
// Reuses the same OAuth/API-key connection as chat (`provider: "cursor"`).
Expand Down
41 changes: 41 additions & 0 deletions open-sse/config/providers/registry/codex/imageModels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Hosted chat models and dedicated image engines use separate Codex routes.
// Catalog admission does not establish the served engine or account entitlement.
// Codex serves images under ChatGPT OAuth two ways (handleCodexImageGeneration):
// - `gpt-image-*` ids use the dedicated `/backend-api/codex/images/{generations,edits}`
// routes, which take the image model as a request field (codexImages.ts);
// - the GPT-6.1/GPT-5.6 ids call `/responses` with the hosted `image_generation` tool,
// whose image model the backend pins to `gpt-image-2-codex`.
export const CODEX_IMAGE_PROVIDER = {
id: "codex",
alias: "cx",
baseUrl: "https://chatgpt.com/backend-api/codex/responses",
authType: "oauth",
authHeader: "bearer",
format: "codex-responses",
models: [
{
id: "gpt-6.1-sol",
catalogId: "gpt-6.1-sol-image",
name: "GPT 6.1 Sol (Codex Image)",
},
{
id: "gpt-5.6-sol",
catalogId: "gpt-5.6-sol-image",
name: "GPT 5.6 Sol (Codex Image)",
},
{
id: "gpt-5.6-terra",
catalogId: "gpt-5.6-terra-image",
name: "GPT 5.6 Terra (Codex Image)",
},
{
id: "gpt-5.6-luna",
catalogId: "gpt-5.6-luna-image",
name: "GPT 5.6 Luna (Codex Image)",
},
{ id: "gpt-image-2.5-flare", name: "GPT Image 2.5 Flare (Codex)" },
{ id: "gpt-image-2.5-sunburst", name: "GPT Image 2.5 Sunburst (Codex)" },
{ id: "gpt-image-2", name: "GPT Image 2 (Codex)" },
],
supportedSizes: ["1024x1024", "1024x1536", "1536x1024"],
};
24 changes: 3 additions & 21 deletions open-sse/handlers/imageGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ import { mapImageSize } from "../translator/image/sizeMapper.ts";
import { getCodexClientVersion, getCodexUserAgent } from "../config/codexClient.ts";
import { isCodexFreePlan } from "../executors/codex/tools.ts";
import { sleep } from "../utils/sleep.ts";
import { normalizeImageBuffer } from "../utils/imageNormalize.ts";
import { codexReferenceImageUrl } from "./imageGeneration/providers/codexReferences.ts";
export { shrinkCodexReferenceImage } from "./imageGeneration/providers/codexReferences.ts";
import {
getKieErrorMessage,
getKieErrorStatus,
Expand Down Expand Up @@ -2476,24 +2477,6 @@ export function mapLegacyImageQualityToImageTool(value: string): string {
return normalized;
}

// The Codex backend answers 503 when an inline reference image is large (seen above ~400 KB);
// a 1024px long edge keeps edits working without changing what the model sees in practice.
const CODEX_REFERENCE_MAX_BYTES = 400_000;
const CODEX_REFERENCE_MAX_LONG_EDGE = 1024;

export async function shrinkCodexReferenceImage(image: {
bytes: Buffer;
mime?: string;
}): Promise<{ bytes: Buffer; mime: string }> {
const mime = image.mime || "image/png";
if (image.bytes.length <= CODEX_REFERENCE_MAX_BYTES) return { bytes: image.bytes, mime };
const out = await normalizeImageBuffer(image.bytes, {
maxLongEdge: CODEX_REFERENCE_MAX_LONG_EDGE,
});
if (!out.resized) return { bytes: image.bytes, mime };
return { bytes: out.buffer, mime: out.mime || mime };
}

async function handleCodexImageGeneration({
model,
provider,
Expand Down Expand Up @@ -2577,10 +2560,9 @@ async function handleCodexImageGeneration({

const inputContent: Array<Record<string, unknown>> = [{ type: "input_text", text: prompt }];
for (const image of referenceImages) {
const reference = await shrinkCodexReferenceImage(image);
inputContent.push({
type: "input_image",
image_url: `data:${reference.mime};base64,${reference.bytes.toString("base64")}`,
image_url: await codexReferenceImageUrl(model, image),
});
}

Expand Down
27 changes: 27 additions & 0 deletions open-sse/handlers/imageGeneration/providers/codexReferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { normalizeImageBuffer } from "../../../utils/imageNormalize.ts";

const CODEX_REFERENCE_MAX_BYTES = 400_000;
const CODEX_REFERENCE_MAX_LONG_EDGE = 1024;

// Preserve the established normalization contract for legacy hosted models.
export async function shrinkCodexReferenceImage(image: {
bytes: Buffer;
mime?: string;
}): Promise<{ bytes: Buffer; mime: string }> {
const mime = image.mime || "image/png";
if (image.bytes.length <= CODEX_REFERENCE_MAX_BYTES) return { bytes: image.bytes, mime };
const out = await normalizeImageBuffer(image.bytes, {
maxLongEdge: CODEX_REFERENCE_MAX_LONG_EDGE,
});
if (!out.resized) return { bytes: image.bytes, mime };
return { bytes: out.buffer, mime: out.mime || mime };
}

export async function codexReferenceImageUrl(
model: string,
image: { bytes: Buffer; mime?: string }
): Promise<string> {
// The newly admitted Sol route must submit the original reference, not a thumbnail.
const reference = model === "gpt-6.1-sol" ? image : await shrinkCodexReferenceImage(image);
return `data:${reference.mime || "image/png"};base64,${reference.bytes.toString("base64")}`;
}
106 changes: 106 additions & 0 deletions src/app/api/v1/images/edits/editInput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { z } from "zod";
import { getCachedSettings } from "@/lib/db/readCache";
import { extractImageEditInputFromJson } from "@/lib/images/imageRouteModel";
import { getBodySizeLimit, readRequestBodyWithLimit } from "@/shared/middleware/bodySizeGuard";
import * as log from "@/sse/utils/logger";

export interface EditInput {
prompt: string;
model: string | null;
size: string | null;
responseFormat: string | null;
imageBytes: Buffer | null;
imageMime: string | null;
images: Array<{ bytes: Buffer; mime: string }>;
imageInputCount: number;
hasMask: boolean;
}

const ImageEditJsonSchema = z
.object({
prompt: z.string().optional(),
model: z.string().optional(),
size: z.string().optional(),
response_format: z.string().optional(),
image: z.unknown().optional(),
images: z.array(z.unknown()).optional(),
})
.passthrough();

async function readMultipartImage(formData: FormData): Promise<EditInput> {
const promptRaw = formData.get("prompt");
const prompt = typeof promptRaw === "string" ? promptRaw.trim() : "";
const modelRaw = formData.get("model");
const model = typeof modelRaw === "string" ? modelRaw.trim() : null;
const sizeRaw = formData.get("size");
const size = typeof sizeRaw === "string" ? sizeRaw.trim() : null;
const respRaw = formData.get("response_format");
const responseFormat = typeof respRaw === "string" ? respRaw.trim() : null;

// Count every candidate so malformed or excess references cannot disappear during parsing.
const imageEntries = Array.from(formData.entries())
.filter(([key]) => key === "image" || key === "image[]")
.map(([, value]) => value);
const images: Array<{ bytes: Buffer; mime: string }> = [];
for (const imageEntry of imageEntries) {
if (typeof imageEntry === "string") continue;
const bytes = Buffer.from(await imageEntry.arrayBuffer());
if (bytes.length === 0) continue;
images.push({ bytes, mime: imageEntry.type || "image/png" });
}
const firstImage = images[0] ?? null;
return {
prompt,
model,
size,
responseFormat,
imageBytes: firstImage?.bytes ?? null,
imageMime: firstImage?.mime ?? null,
images,
imageInputCount: imageEntries.length,
hasMask: formData.has("mask") || formData.has("mask_url"),
};
}

/** Read a bounded multipart or JSON edit body without dropping mask presence. */
export async function readEditInput(request: Request): Promise<EditInput | null> {
const contentType = request.headers.get("content-type") || "";
let bodySizeSettings: Record<string, unknown> | undefined;
try {
bodySizeSettings = await getCachedSettings();
} catch {
bodySizeSettings = undefined;
}
const bodySizeLimit = getBodySizeLimit("/api/v1/images/edits", bodySizeSettings);
const rawBody = await readRequestBodyWithLimit(request, bodySizeLimit);
if (contentType.includes("multipart/form-data")) {
try {
const formData = await new Response(rawBody, {
headers: { "content-type": contentType },
}).formData();
return await readMultipartImage(formData);
} catch {
log.warn("IMAGE", "Invalid multipart edit body");
return null;
}
}
if (contentType.includes("application/json")) {
try {
const parsed = ImageEditJsonSchema.safeParse(
JSON.parse(new TextDecoder("utf-8").decode(rawBody)) as unknown
);
if (!parsed.success) {
log.warn("IMAGE", "Invalid JSON edit body shape");
return null;
}
return {
...extractImageEditInputFromJson(parsed.data),
hasMask: Object.hasOwn(parsed.data, "mask") || Object.hasOwn(parsed.data, "mask_url"),
};
} catch {
log.warn("IMAGE", "Invalid JSON edit body");
return null;
}
}
return null;
}
Loading
Loading