diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index bdd5943f88..a98762a8fc 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -111,7 +111,8 @@ export function resolveImageBaseUrl( | { baseUrl?: unknown; providerSpecificData?: { baseUrl?: unknown } | null } | null | undefined, - fallback: string + fallback: string, + endpoint: "generations" | "edits" = "generations" ): string { const psd = credentials?.providerSpecificData; const psdBaseUrl = @@ -126,9 +127,14 @@ export function resolveImageBaseUrl( if (!nodeBaseUrl) return fallback; + // A single configured node serves both image routes: honor a base URL that already + // points at the requested OpenAI image path, and rewrite one that points at the other + // image endpoint (e.g. `.../images/generations` requested for edits) (#3214/#3215). + const suffix = `/images/${endpoint}`; const normalized = nodeBaseUrl.replace(/\/+$/, ""); - if (/\/images\/generations$/.test(normalized)) return normalized; - return `${normalized}/images/generations`; + if (normalized.endsWith(suffix)) return normalized; + const stripped = normalized.replace(/\/images\/(?:generations|edits)$/, ""); + return `${stripped}${suffix}`; } function normalizeImageAspectRatio(value: unknown, fallbackSize: unknown): string { @@ -971,6 +977,92 @@ async function handleOpenAIImageGeneration({ return result; } +/** + * OpenAI-compatible image *edit* forwarder for custom providers (#3214 / #3215). + * + * Mirrors `handleOpenAIImageGeneration` but posts multipart/form-data to the node's + * `/images/edits` endpoint and returns the upstream OpenAI-compatible response. Kept + * separate from the chatgpt-web edit flow, which continues a saved conversation node + * rather than forwarding a stateless edit. The fetch helper leaves Content-Type unset so + * `fetch` derives the multipart boundary from the FormData body. + */ +export async function handleOpenAIImageEdit({ + model, + provider, + credentials, + prompt, + imageBytes, + imageMime, + size, + responseFormat, + n = 1, + log, +}: { + model: string; + provider: string; + credentials: + | { + apiKey?: string; + accessToken?: string; + baseUrl?: unknown; + providerSpecificData?: { baseUrl?: unknown } | null; + } + | null + | undefined; + prompt: string; + imageBytes: Buffer; + imageMime?: string | null; + size?: string | null; + responseFormat?: string | null; + n?: number; + log?: { info: (tag: string, message: string) => void } | null; +}) { + const startTime = Date.now(); + const url = resolveImageBaseUrl( + credentials, + `https://generativelanguage.googleapis.com/v1beta/openai/images/edits`, + "edits" + ); + + const form = new FormData(); + form.append("model", model); + form.append("prompt", prompt); + if (size) form.append("size", size); + if (responseFormat) form.append("response_format", responseFormat); + form.append("n", String(n || 1)); + const blob = new Blob([imageBytes], { type: imageMime || "image/png" }); + form.append("image", blob, "image.png"); + + const headers: Record = {}; + const token = credentials?.apiKey || credentials?.accessToken; + if (token) headers["Authorization"] = `Bearer ${token}`; + + if (log) { + log.info("IMAGE", `${provider}/${model} (edit) | prompt: "${prompt.slice(0, 60)}..." -> ${url}`); + } + + const result = await fetchImageEndpoint(url, headers, form as unknown as BodyInit, provider, log); + + saveCallLog({ + method: "POST", + path: "/v1/images/edits", + status: result.status || (result.success ? 200 : 502), + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + tokens: { prompt_tokens: 0, completion_tokens: 0 }, + error: result.success + ? null + : typeof result.error === "string" + ? result.error.slice(0, 500) + : null, + requestBody: { model, prompt: prompt.slice(0, 200), size: size || "default", n: n || 1 }, + responseBody: result.success ? { images_count: result.data?.data?.length || 0 } : null, + }).catch(() => {}); + + return result; +} + const CHATGPT_WEB_IMAGE_MARKDOWN_RE = /!\[[^\]]*\]\(([^)\s]+)\)/g; const CHATGPT_WEB_IMAGE_ID_RE = /\/v1\/chatgpt-web\/image\/([a-f0-9]{16,64})(?=[?\s"'<>)]|$)/i; diff --git a/src/app/api/v1/images/edits/route.ts b/src/app/api/v1/images/edits/route.ts index 6bfe33c7c0..9c6b18a0bc 100644 --- a/src/app/api/v1/images/edits/route.ts +++ b/src/app/api/v1/images/edits/route.ts @@ -1,4 +1,7 @@ -import { handleImageEdit } from "@omniroute/open-sse/handlers/imageGeneration.ts"; +import { + handleImageEdit, + handleOpenAIImageEdit, +} from "@omniroute/open-sse/handlers/imageGeneration.ts"; import { getProviderCredentials, clearRecoveredProviderState } from "@/sse/services/auth"; import { parseImageModel, getImageProvider } from "@omniroute/open-sse/config/imageRegistry.ts"; import { errorResponse, unavailableResponse } from "@omniroute/open-sse/utils/error.ts"; @@ -6,20 +9,29 @@ import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import * as log from "@/sse/utils/logger"; import { toJsonErrorPayload } from "@/shared/utils/upstreamError"; import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; +import { + resolveImageRouteModel, + extractImageEditInputFromJson, +} from "@/lib/images/imageRouteModel"; /** - * /v1/images/edits — multipart edit endpoint matching OpenAI's images-edit API. + * /v1/images/edits — OpenAI-compatible image-edit endpoint. * - * Open WebUI's "Image Edit" toggle (images.edit.engine = "openai") posts here - * with `prompt` + `image` (file). For chatgpt-web, an "edit" only makes sense - * if the uploaded image was originally generated through OmniRoute — we then - * have its `{conversationId, parentMessageId}` cached and can continue the - * saved chatgpt.com conversation node, which is the only way to actually edit - * the image instead of generating an unrelated one from scratch. + * Two upstream shapes are supported: + * - **chatgpt-web**: an "edit" only makes sense if the uploaded image was originally + * generated through OmniRoute — we then have its `{conversationId, parentMessageId}` + * cached and can continue the saved chatgpt.com conversation node (the only way to + * actually edit the image instead of generating an unrelated one). + * - **custom OpenAI-compatible providers** (#3214/#3215): forward a multipart edit to + * the node's `{base_url}/images/edits`, mirroring how generations forwards. * - * Without this route, multipart bodies trip Next.js's Server Action handler - * (which intercepts ALL POSTs with multipart/form-data content-type) and the - * client gets a confusing "Failed to find Server Action" 500. + * Input is accepted as multipart/form-data (Open WebUI's "Image Edit" toggle) or as JSON + * with data-URL images (`images: [{ image_url: "data:..." }]`), since some OpenAI-compatible + * clients send the latter. The model may be a built-in id, a `provider/model`, a custom + * provider prefix, or a combo/alias name — all resolved the same as generations. + * + * Without this route, multipart bodies trip Next.js's Server Action handler (which + * intercepts ALL multipart POSTs) and the client gets a confusing 500. */ export async function OPTIONS() { @@ -42,14 +54,16 @@ function publicBaseUrlHeaders(headers: Headers): Record { return out; } -async function readMultipartImage(formData: FormData): Promise<{ +interface EditInput { prompt: string; model: string | null; size: string | null; responseFormat: string | null; imageBytes: Buffer | null; imageMime: string | null; -}> { +} + +async function readMultipartImage(formData: FormData): Promise { const promptRaw = formData.get("prompt"); const prompt = typeof promptRaw === "string" ? promptRaw.trim() : ""; const modelRaw = formData.get("model"); @@ -72,21 +86,45 @@ async function readMultipartImage(formData: FormData): Promise<{ return { prompt, model, size, responseFormat, imageBytes, imageMime }; } +/** Read the edit input from either multipart/form-data or a JSON/data-URL body. */ +async function readEditInput(request: Request): Promise { + const contentType = request.headers.get("content-type") || ""; + if (contentType.includes("multipart/form-data")) { + try { + return await readMultipartImage(await request.formData()); + } catch (err) { + log.warn("IMAGE", `Invalid multipart body: ${err instanceof Error ? err.message : err}`); + return null; + } + } + if (contentType.includes("application/json")) { + try { + return extractImageEditInputFromJson(await request.json()); + } catch (err) { + log.warn("IMAGE", `Invalid JSON edit body: ${err instanceof Error ? err.message : err}`); + return null; + } + } + return null; +} + +function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + export async function POST(request: Request) { - let formData: FormData; - try { - formData = await request.formData(); - } catch (err) { - log.warn( - "IMAGE", - `Invalid multipart body: ${err instanceof Error ? err.message : String(err)}` + const input = await readEditInput(request); + if (!input) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + "Invalid request body. Send multipart/form-data or JSON with a data-URL image." ); - return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid multipart body"); } - const { prompt, model, size, responseFormat, imageBytes, imageMime } = - await readMultipartImage(formData); - + const { prompt, model, size, responseFormat, imageBytes, imageMime } = input; if (!prompt) { return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: prompt"); } @@ -99,74 +137,128 @@ export async function POST(request: Request) { const policy = await enforceApiKeyPolicy(request, fullModel); if (policy.rejection) return policy.rejection; - const parsed = parseImageModel(fullModel); - const providerConfig = getImageProvider(parsed.provider); - if (!providerConfig) { - return errorResponse(HTTP_STATUS.BAD_REQUEST, `Unknown image provider: ${parsed.provider}`); - } - if (providerConfig.format !== "chatgpt-web") { - // We only implement edit for chatgpt-web today; everything else routes - // through generations which doesn't accept image inputs. Surface a - // useful error rather than silently dropping the image. - return errorResponse( - HTTP_STATUS.BAD_REQUEST, - `Image edit is only supported for chatgpt-web models (got ${parsed.provider})` - ); - } - const allowedConnections = policy.apiKeyInfo?.allowedConnections && policy.apiKeyInfo.allowedConnections.length > 0 ? policy.apiKeyInfo.allowedConnections : null; + + // Resolve combo/alias, custom-provider prefix, and built-in ids consistently with + // /v1/images/generations (#3215). + const resolvedModel = await resolveImageRouteModel(fullModel); + const parsed = parseImageModel(resolvedModel); + const providerConfig = parsed.provider ? getImageProvider(parsed.provider) : null; + + // chatgpt-web keeps its conversation-continuation edit flow unchanged. + if (providerConfig?.format === "chatgpt-web") { + const credentials = await getProviderCredentials( + parsed.provider, + null, + allowedConnections, + resolvedModel + ); + if (!credentials) { + return errorResponse( + HTTP_STATUS.UNAUTHORIZED, + `No credentials for provider: ${parsed.provider}` + ); + } + if (credentials.allRateLimited) { + return unavailableResponse( + HTTP_STATUS.RATE_LIMITED, + `[${parsed.provider}] All accounts rate limited`, + credentials.retryAfter, + credentials.retryAfterHuman + ); + } + + const result = await handleImageEdit({ + provider: parsed.provider, + model: parsed.model, + body: { + prompt, + size: size ?? undefined, + response_format: responseFormat ?? undefined, + n: 1, + }, + imageBytes, + imageMime, + credentials, + log, + signal: request.signal, + clientHeaders: publicBaseUrlHeaders(request.headers), + }); + + if (result.success) { + await clearRecoveredProviderState(credentials); + return jsonResponse((result as any).data); + } + return jsonResponse( + toJsonErrorPayload((result as any).error, "Image edit provider error"), + (result as any).status + ); + } + + // Built-in non-chatgpt-web providers do not expose an OpenAI-compatible edit endpoint. + if (providerConfig) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `Image edit is not supported for built-in provider "${parsed.provider}". ` + + `Use chatgpt-web or a custom OpenAI-compatible image provider.` + ); + } + + // Custom OpenAI-compatible node (no built-in config): forward to {base_url}/images/edits. + const slash = resolvedModel.indexOf("/"); + const customProviderId = slash > 0 ? resolvedModel.slice(0, slash) : null; + const customModel = slash > 0 ? resolvedModel.slice(slash + 1) : null; + if (!customProviderId || !customModel) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `Unknown image provider for model "${fullModel}". Use provider/model, a custom ` + + `provider prefix, or a combo/alias name.` + ); + } + const credentials = await getProviderCredentials( - parsed.provider, + customProviderId, null, allowedConnections, - fullModel + resolvedModel ); if (!credentials) { return errorResponse( - HTTP_STATUS.UNAUTHORIZED, - `No credentials for provider: ${parsed.provider}` + HTTP_STATUS.BAD_REQUEST, + `No credentials for custom image provider: ${customProviderId}` ); } if (credentials.allRateLimited) { return unavailableResponse( HTTP_STATUS.RATE_LIMITED, - `[${parsed.provider}] All accounts rate limited`, + `[${customProviderId}] All accounts rate limited`, credentials.retryAfter, credentials.retryAfterHuman ); } - const result = await handleImageEdit({ - provider: parsed.provider, - model: parsed.model, - body: { - prompt, - size: size ?? undefined, - response_format: responseFormat ?? undefined, - n: 1, - }, + const result = await handleOpenAIImageEdit({ + provider: customProviderId, + model: customModel, + credentials, + prompt, imageBytes, imageMime, - credentials, + size, + responseFormat, + n: 1, log, - signal: request.signal, - clientHeaders: publicBaseUrlHeaders(request.headers), }); if (result.success) { await clearRecoveredProviderState(credentials); - return new Response(JSON.stringify((result as any).data), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); + return jsonResponse((result as any).data); } - - const errorPayload = toJsonErrorPayload((result as any).error, "Image edit provider error"); - return new Response(JSON.stringify(errorPayload), { - status: (result as any).status, - headers: { "Content-Type": "application/json" }, - }); + return jsonResponse( + toJsonErrorPayload((result as any).error, "Image edit provider error"), + (result as any).status + ); } diff --git a/src/app/api/v1/images/generations/route.ts b/src/app/api/v1/images/generations/route.ts index 2f4ad82c7a..2b950a9911 100644 --- a/src/app/api/v1/images/generations/route.ts +++ b/src/app/api/v1/images/generations/route.ts @@ -20,7 +20,7 @@ import { v1ImageGenerationSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { getAllCustomModels, resolveProxyForConnection } from "@/lib/localDb"; -import { getProviderNodes } from "@/lib/db/providers"; +import { resolveImageRouteModel } from "@/lib/images/imageRouteModel"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; /** @@ -118,43 +118,6 @@ function publicBaseUrlHeaders(headers: Headers): Record { return out; } -/** - * Resolve a `prefix/model` image request to the internal `/` - * form (#3205). - * - * The custom-model lookup below only matches the full internal id - * (`/`), so a request that uses the user-defined provider - * prefix (e.g. `myImg/gpt-image-2`) never matched and fell through to - * "Invalid image model". This mirrors how chat resolves prefixes in - * `src/sse/services/model.ts` (match on `node.prefix` OR `node.id`). - * - * Returns the rewritten model string, or the original string when no node - * prefix matches (so built-in and already-internal ids are untouched). - */ -async function resolveImageModelPrefix(modelStr: string): Promise { - if (typeof modelStr !== "string") return modelStr; - const slash = modelStr.indexOf("/"); - if (slash <= 0) return modelStr; - - const prefixPart = modelStr.slice(0, slash); - const rest = modelStr.slice(slash + 1); - if (!rest) return modelStr; - - try { - const nodes = await getProviderNodes({ type: "openai-compatible" }); - // Prefer an explicit user-defined prefix match; node.id (internal UUID) is - // already handled by the exact-id loop, so only rewrite when the prefix - // differs from the node id. - const matched = nodes.find((node: any) => node.prefix === prefixPart); - if (matched && matched.id && matched.id !== prefixPart) { - return `${matched.id}/${rest}`; - } - } catch { - // DB unavailable (pre-migration / tests) — leave the model untouched. - } - return modelStr; -} - export async function POST(request) { let rawBody; try { @@ -174,13 +137,11 @@ export async function POST(request) { const policy = await enforceApiKeyPolicy(request, body.model); if (policy.rejection) return policy.rejection; - // #3205: rewrite a user-prefixed custom image model (`myImg/gpt-image-2`) to - // its internal `/` form so the custom-model lookup and - // handler's resolvedProvider extraction resolve correctly. Built-in and - // already-internal ids pass through unchanged. - if (!parseImageModel(body.model).provider) { - body.model = await resolveImageModelPrefix(body.model); - } + // #3205/#3215: resolve a combo/alias name (`image`) or a user-prefixed custom image + // model (`myImg/gpt-image-2`) to its internal `/` form so the + // custom-model lookup and handler's resolvedProvider extraction resolve correctly. + // Built-in and already-internal ids pass through unchanged. Shared with /images/edits. + body.model = await resolveImageRouteModel(body.model); // Parse model to get provider let { provider } = parseImageModel(body.model); diff --git a/src/lib/images/imageRouteModel.ts b/src/lib/images/imageRouteModel.ts new file mode 100644 index 0000000000..1425044f17 --- /dev/null +++ b/src/lib/images/imageRouteModel.ts @@ -0,0 +1,161 @@ +/** + * Shared model resolution for the image routes (#3214 / #3215). + * + * `/v1/images/generations` and `/v1/images/edits` must resolve a requested model the + * same way, and as close as practical to how chat routing resolves models: + * + * 1. Built-in image model id / alias (`cgpt-web/...`, `gpt-image-1`, …) — untouched. + * 2. Custom provider *prefix* form (`myImg/gpt-image-2`) — rewritten to the internal + * `/` id (#3205 did this inline in the generations route only). + * 3. Bare combo / alias name with no slash (`image`) — resolved to the combo's single + * image target, then that target is itself prefix-resolved. + * + * Anything that does not match falls through unchanged, so existing built-in and + * already-internal ids keep working. + */ +import { parseImageModel } from "@omniroute/open-sse/config/imageRegistry.ts"; +import { resolveComboTargets } from "@omniroute/open-sse/services/combo.ts"; + +import { getComboByName, getCombos } from "@/lib/db/combos"; +import { getProviderNodes } from "@/lib/db/providers"; + +/** + * Rewrite a `prefix/model` custom image model to its internal `/` form. + * Returns the original string when no openai-compatible node prefix matches (so built-in + * and already-internal ids pass through). Mirrors `src/sse/services/model.ts` (match on + * `node.prefix` OR `node.id`). + */ +export async function resolveImageModelPrefix(modelStr: string): Promise { + if (typeof modelStr !== "string") return modelStr; + const slash = modelStr.indexOf("/"); + if (slash <= 0) return modelStr; + + const prefixPart = modelStr.slice(0, slash); + const rest = modelStr.slice(slash + 1); + if (!rest) return modelStr; + + try { + const nodes = await getProviderNodes({ type: "openai-compatible" }); + // node.id (internal UUID) is already a valid internal id; only rewrite when a + // user-defined prefix differs from the node id. + const matched = nodes.find((node: { prefix?: unknown }) => node.prefix === prefixPart); + if (matched && typeof matched.id === "string" && matched.id && matched.id !== prefixPart) { + return `${matched.id}/${rest}`; + } + } catch { + // DB unavailable (pre-migration / tests) — leave the model untouched. + } + return modelStr; +} + +/** + * Resolve a bare combo/alias name (e.g. `image`) to its first image model target's + * model string, or null when the name is not a combo / has no usable target. + */ +export async function resolveSingleImageComboTarget(name: string): Promise { + if (typeof name !== "string" || !name.trim()) return null; + try { + const combo = await getComboByName(name); + if (!combo) return null; + const allCombos = await getCombos(); + const targets = resolveComboTargets(combo as never, allCombos as never); + const first = targets.find( + (t: { modelStr?: unknown }) => typeof t?.modelStr === "string" && (t.modelStr as string).trim() + ); + return (first?.modelStr as string) ?? null; + } catch { + return null; + } +} + +/** + * Full image-route model resolver. See module header for the resolution order. + */ +export async function resolveImageRouteModel(modelStr: string): Promise { + if (typeof modelStr !== "string" || !modelStr.trim()) return modelStr; + + // 1. Built-in image model (alias or provider/model) — leave untouched. + if (parseImageModel(modelStr).provider) return modelStr; + + // 3. Bare combo/alias name (no slash): resolve to its single image target, then + // prefix-resolve that target (it may itself be a `prefix/model` custom id). + if (!modelStr.includes("/")) { + const target = await resolveSingleImageComboTarget(modelStr); + if (target && target !== modelStr) return resolveImageModelPrefix(target); + return modelStr; + } + + // 2. Custom provider prefix form — rewrite to internal `/`. + return resolveImageModelPrefix(modelStr); +} + +interface ParsedImageEditInput { + prompt: string; + model: string | null; + size: string | null; + responseFormat: string | null; + imageBytes: Buffer | null; + imageMime: string | null; +} + +/** Parse a `data:;base64,` URL into raw bytes + mime, or null when invalid. */ +export function parseDataUrl(value: unknown): { bytes: Buffer; mime: string } | null { + if (typeof value !== "string") return null; + const match = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(value.trim()); + if (!match) return null; + const mime = match[1] || "image/png"; + const isBase64 = Boolean(match[2]); + const payload = match[3] ?? ""; + try { + const bytes = isBase64 + ? Buffer.from(payload, "base64") + : Buffer.from(decodeURIComponent(payload), "utf8"); + if (bytes.length === 0) return null; + return { bytes, mime }; + } catch { + return null; + } +} + +/** + * Extract an OpenAI-compatible image-edit payload from a JSON body. Some clients send + * edit input as JSON with data-URL images instead of multipart/form-data; accept the + * common shapes (`image: "data:..."`, `images: [{ image_url: "data:..." }]` or + * `images: ["data:..."]`) and surface the same fields the multipart reader produces. + */ +export function extractImageEditInputFromJson(body: unknown): ParsedImageEditInput { + const obj = (body && typeof body === "object" ? body : {}) as Record; + const str = (v: unknown): string | null => + typeof v === "string" && v.trim() ? v.trim() : null; + + const prompt = typeof obj.prompt === "string" ? obj.prompt.trim() : ""; + const model = str(obj.model); + const size = str(obj.size); + const responseFormat = str(obj.response_format); + + const candidates: unknown[] = []; + if (obj.image !== undefined) candidates.push(obj.image); + const images = obj.images; + if (Array.isArray(images)) { + for (const entry of images) { + if (typeof entry === "string") candidates.push(entry); + else if (entry && typeof entry === "object") { + const e = entry as Record; + candidates.push(e.image_url ?? e.url ?? e.b64_json); + } + } + } + + let imageBytes: Buffer | null = null; + let imageMime: string | null = null; + for (const candidate of candidates) { + const parsed = parseDataUrl(candidate); + if (parsed) { + imageBytes = parsed.bytes; + imageMime = parsed.mime; + break; + } + } + + return { prompt, model, size, responseFormat, imageBytes, imageMime }; +} diff --git a/tests/unit/image-routes-combo-edits-3214-3215.test.ts b/tests/unit/image-routes-combo-edits-3214-3215.test.ts new file mode 100644 index 0000000000..f847320a06 --- /dev/null +++ b/tests/unit/image-routes-combo-edits-3214-3215.test.ts @@ -0,0 +1,117 @@ +/** + * #3214 / #3215 — image routes: combo/alias resolution + OpenAI-compatible edits. + * + * Before this change: + * - `/v1/images/generations` resolved built-in ids and `prefix/model` custom ids but NOT + * a bare combo/alias name (`model: "image"`) — it fell through to "Invalid image model". + * - `/v1/images/edits` resolved a base URL only for `/images/generations` and rejected any + * non-chatgpt-web provider, so custom OpenAI-compatible providers could not edit, and + * JSON/data-URL edit clients got "Invalid multipart body". + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Isolate the DB to a throwaway DATA_DIR *before* importing any module that pulls in +// src/lib/db/core (its DATA_DIR/SQLITE_FILE consts are resolved eagerly at import), so the +// combo fixture never touches the real ~/.omniroute database. Mirrors a2a-enabled-route.test. +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-img-routes-3214-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; + +const { resolveImageBaseUrl } = await import("../../open-sse/handlers/imageGeneration.ts"); +const { + parseDataUrl, + extractImageEditInputFromJson, + resolveSingleImageComboTarget, + resolveImageRouteModel, +} = await import("../../src/lib/images/imageRouteModel.ts"); +const { resetDbInstance } = await import("../../src/lib/db/core.ts"); +const { createCombo } = await import("../../src/lib/db/combos.ts"); + +test.after(() => { + resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; +}); + +test("resolveImageBaseUrl appends /images/edits for the edits endpoint", () => { + const creds = { providerSpecificData: { baseUrl: "https://img.example.com/v1" } }; + assert.equal( + resolveImageBaseUrl(creds, "https://fallback/x", "edits"), + "https://img.example.com/v1/images/edits" + ); + // generations stays the default and unchanged + assert.equal( + resolveImageBaseUrl(creds, "https://fallback/x"), + "https://img.example.com/v1/images/generations" + ); +}); + +test("resolveImageBaseUrl rewrites a base URL that points at the other image endpoint", () => { + const creds = { + providerSpecificData: { baseUrl: "https://img.example.com/v1/images/generations" }, + }; + assert.equal( + resolveImageBaseUrl(creds, "https://fallback/x", "edits"), + "https://img.example.com/v1/images/edits" + ); +}); + +test("resolveImageBaseUrl falls back when no node base URL is configured", () => { + assert.equal( + resolveImageBaseUrl(null, "https://fallback/v1/images/edits", "edits"), + "https://fallback/v1/images/edits" + ); +}); + +test("parseDataUrl decodes a base64 data URL", () => { + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + const parsed = parseDataUrl(`data:image/png;base64,${png.toString("base64")}`); + assert.ok(parsed); + assert.equal(parsed!.mime, "image/png"); + assert.deepEqual([...parsed!.bytes], [...png]); + assert.equal(parseDataUrl("not-a-data-url"), null); + assert.equal(parseDataUrl(`data:image/png;base64,`), null); +}); + +test("extractImageEditInputFromJson reads model/prompt and the first data-URL image", () => { + const png = Buffer.from([1, 2, 3, 4]); + const out = extractImageEditInputFromJson({ + model: "image", + prompt: "make it blue", + size: "1024x1024", + response_format: "b64_json", + images: [{ image_url: `data:image/webp;base64,${png.toString("base64")}` }], + }); + assert.equal(out.model, "image"); + assert.equal(out.prompt, "make it blue"); + assert.equal(out.size, "1024x1024"); + assert.equal(out.responseFormat, "b64_json"); + assert.equal(out.imageMime, "image/webp"); + assert.deepEqual([...(out.imageBytes ?? [])], [...png]); + + // also accepts a top-level `image` data URL string + const out2 = extractImageEditInputFromJson({ + prompt: "x", + image: `data:image/png;base64,${png.toString("base64")}`, + }); + assert.ok(out2.imageBytes && out2.imageBytes.length === 4); +}); + +test("resolveImageRouteModel resolves a bare combo/alias to its single image target", async () => { + await createCombo({ name: "image-alias-3215", models: ["myimg/gpt-image-2"], strategy: "priority" }); + + assert.equal(await resolveSingleImageComboTarget("image-alias-3215"), "myimg/gpt-image-2"); + // No openai-compatible node has prefix "myimg" in this test DB, so the prefix step + // leaves it intact — but the combo name itself is now resolved (was the bug). + assert.equal(await resolveImageRouteModel("image-alias-3215"), "myimg/gpt-image-2"); +}); + +test("resolveImageRouteModel leaves built-in / already-resolved ids untouched", async () => { + assert.equal(await resolveImageRouteModel("cgpt-web/gpt-5.3-instant"), "cgpt-web/gpt-5.3-instant"); + assert.equal(await resolveSingleImageComboTarget("definitely-not-a-combo-3215"), null); +});