From c79faa45fb52ef38c153f3ac4ac2a2ad1ed50885 Mon Sep 17 00:00:00 2001 From: Tiangao <53409436+tiangao88@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:04:22 +0200 Subject: [PATCH] fix(image): support OpenRouter reference-image edits (#10197) (#10363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Obrigado — feature real e bem verificada: POST /v1/images/edits rejeitava o provider built-in openrouter mesmo ele suportando edição por imagem de referência via sua Image API unificada. Traduz a imagem de entrada para o formato input_references documentado do OpenRouter e despacha para /api/v1/images, removendo o prefixo do provider do model id antes de encaminhar. Nota: o contribuidor não conseguiu rodar o teste localmente (better-sqlite3 ausente no ambiente dele) — rodei aqui. Validação (worktree própria a partir de origin/release/v3.8.50, merge limpo, 0 conflitos): - typecheck:core limpo, complexity/cognitive-complexity dentro do baseline - tests/unit/10197-openrouter-image-edits-route.test.ts — 3/3 passando (forward bem-sucedido, credenciais ausentes 401, rate-limit) --- open-sse/handlers/imageGeneration.ts | 101 ++++++++++ src/app/api/v1/images/edits/route.ts | 50 +++++ ...10197-openrouter-image-edits-route.test.ts | 185 ++++++++++++++++++ 3 files changed, 336 insertions(+) create mode 100644 tests/unit/10197-openrouter-image-edits-route.test.ts diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 5d50f125f9..a2053bb556 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -1346,6 +1346,107 @@ export async function handleOpenAIImageEdit({ return result; } +/** + * Handle OpenRouter's unified Image API reference-image flow. + * + * OpenRouter does not expose `/images/edits`; image-to-image requests use + * `POST /api/v1/images` with `input_references` containing data-URL images. + * Keep this separate from the generic multipart `/images/edits` forwarder, + * whose contract is used by custom OpenAI-compatible nodes (#10197). + */ +export async function handleOpenRouterImageEdit({ + model, + provider, + baseUrl, + credentials, + prompt, + imageBytes, + imageMime, + size, + n = 1, + log, +}: { + model: string; + provider: string; + baseUrl: string; + credentials: + | { + apiKey?: string; + accessToken?: string; + } + | null + | undefined; + prompt: string; + imageBytes: Buffer; + imageMime?: string | null; + size?: string | null; + n?: number; + log?: { info: (tag: string, message: string) => void } | null; +}) { + const startTime = Date.now(); + let url = baseUrl.trim(); + while (url.endsWith("/")) url = url.slice(0, -1); + if (url.endsWith("/images/generations")) { + url = url.slice(0, -"/images/generations".length) + "/images"; + } else if (!url.endsWith("/images")) { + url += "/images"; + } + + const mime = imageMime || "image/png"; + const upstreamBody: Record = { + model, + prompt, + input_references: [ + { + type: "image_url", + image_url: { + url: `data:${mime};base64,${imageBytes.toString("base64")}`, + }, + }, + ], + n: n || 1, + }; + if (size) upstreamBody.size = size; + + const headers: Record = { + "Content-Type": "application/json", + }; + const token = credentials?.apiKey || credentials?.accessToken; + if (token) headers.Authorization = `Bearer ${token}`; + + log?.info( + "IMAGE", + `${provider}/${model} (reference edit) | prompt: "${prompt.slice(0, 60)}..." -> ${url}` + ); + + const result = await fetchImageEndpoint( + url, + headers, + JSON.stringify(upstreamBody), + 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; +} + export async function handleImageEdit({ provider, model, diff --git a/src/app/api/v1/images/edits/route.ts b/src/app/api/v1/images/edits/route.ts index 9c8871d6bf..282c83a119 100644 --- a/src/app/api/v1/images/edits/route.ts +++ b/src/app/api/v1/images/edits/route.ts @@ -3,6 +3,7 @@ import { handleCodexImageEdit, handleImageEdit, handleOpenAIImageEdit, + handleOpenRouterImageEdit, } from "@omniroute/open-sse/handlers/imageGeneration.ts"; import { handleFalAIImageEdit, @@ -585,6 +586,55 @@ async function postHandler(request: Request, _context?: unknown) { }); } + // Built-in OpenRouter uses its unified Image API for reference-image + // edits: POST /api/v1/images with input_references. Forward through the + // provider-specific adapter (#10197), rather than the multipart + // /images/edits path used by custom OpenAI-compatible nodes. + if (providerConfig?.id === "openrouter") { + const credentials = await getProviderCredentialsWithQuotaPreflight( + 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 handleOpenRouterImageEdit({ + provider: parsed.provider, + model: parsed.model, + baseUrl: providerConfig.baseUrl, + credentials, + prompt, + imageBytes, + imageMime, + size: size ?? undefined, + n: 1, + log, + }); + + if (result.success) { + await clearRecoveredProviderState(credentials); + return jsonResponse(result.data); + } + return jsonResponse( + toJsonErrorPayload(result.error, "Image edit provider error"), + result.status + ); + } + // Other built-in providers do not expose an OpenAI-compatible edit endpoint. if (providerConfig) { return errorResponse( diff --git a/tests/unit/10197-openrouter-image-edits-route.test.ts b/tests/unit/10197-openrouter-image-edits-route.test.ts new file mode 100644 index 0000000000..a99846ef3a --- /dev/null +++ b/tests/unit/10197-openrouter-image-edits-route.test.ts @@ -0,0 +1,185 @@ +// #10197 (tiangao88): route-level coverage for the built-in OpenRouter branch +// that /v1/images/edits gained in this PR. Exercises the actual POST(request) +// handler so the credentials / rate-limit / unified-Image-API forwarding branches +// added to route.ts itself are proven, not just the downstream service call. +// +// Before this change: POST /v1/images/edits rejected the built-in `openrouter` +// provider ("Image edit is not supported for built-in provider"), so image +// Combos routing through OpenRouter could generate but never edit. OpenRouter's +// current reference-image contract is POST /api/v1/images with input_references. +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"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-openrouter-edits-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "openrouter-edits-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const imageEditRoute = await import("../../src/app/api/v1/images/edits/route.ts"); +const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); + +interface ErrorResponseBody { + error: { message: string; code?: string }; +} + +interface ImageResponseBody { + data: Array<{ b64_json?: string; url?: string }>; +} + +const originalFetch = globalThis.fetch; + +async function resetStorage() { + globalThis.fetch = originalFetch; + apiKeysDb.resetApiKeyState(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + v1ModelsCatalog.__resetCatalogBuilderRunsForTest(); +} + +function seedOpenRouterConnection(overrides: { rateLimitedUntil?: string | null } = {}) { + return providersDb.createProviderConnection({ + provider: "openrouter", + authType: "apikey", + name: "openrouter-test", + apiKey: "sk-or-test-openrouter-edits", + isActive: true, + testStatus: "active", + rateLimitedUntil: overrides.rateLimitedUntil ?? null, + }); +} + +function dataUrlPng(bytes: number[]): string { + return `data:image/png;base64,${Buffer.from(bytes).toString("base64")}`; +} + +const REF_A = dataUrlPng([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1]); + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + globalThis.fetch = originalFetch; + apiKeysDb.resetApiKeyState(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#10197 v1 image edit POST forwards built-in openrouter edits to the unified Image API", async () => { + await seedOpenRouterConnection(); + + let hitUrl: string | null = null; + let hitAuth: string | null = null; + let hitBody = ""; + + globalThis.fetch = async (url, init: RequestInit = {}) => { + hitUrl = String(url); + const headers = init.headers; + hitAuth = + headers instanceof Headers + ? String(headers.get("authorization") || "") + : String( + (headers as Record | undefined)?.authorization || + (headers as Record | undefined)?.Authorization || + "" + ); + // The OpenRouter adapter sends JSON with input_references, not multipart. + const raw = init.body; + if (typeof raw === "string") hitBody = raw; + else if (raw instanceof Uint8Array) hitBody = Buffer.from(raw).toString("utf8"); + else if (raw instanceof ArrayBuffer) hitBody = Buffer.from(raw).toString("utf8"); + else if (raw && typeof (raw as { arrayBuffer?: unknown }).arrayBuffer === "function") { + hitBody = Buffer.from(await (raw as { arrayBuffer(): Promise }).arrayBuffer()).toString("utf8"); + } + return new Response( + JSON.stringify({ data: [{ b64_json: Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString("base64") }] }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + const response = await imageEditRoute.POST( + new Request("http://localhost/api/v1/images/edits", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "openrouter/google/gemini-3.1-flash-image-preview", + prompt: "add a red hat", + images: [REF_A], + }), + }) + ); + const body = (await response.json()) as ImageResponseBody; + + assert.equal(response.status, 200); + assert.ok(body.data[0].b64_json, "edit must return an image payload"); + + // OpenRouter's current image-to-image endpoint is the unified Image API. + assert.equal(hitUrl, "https://openrouter.ai/api/v1/images"); + // Must carry the OpenRouter connection key as a Bearer token. + assert.equal(hitAuth, "Bearer sk-or-test-openrouter-edits"); + assert.ok(hitBody, "JSON body must be captured"); + const forwarded = JSON.parse(hitBody) as { + model?: string; + prompt?: string; + input_references?: Array<{ image_url?: { url?: string } }>; + }; + assert.equal(forwarded.model, "google/gemini-3.1-flash-image-preview"); + assert.equal(forwarded.prompt, "add a red hat"); + assert.equal(forwarded.input_references?.length, 1); + assert.match(forwarded.input_references?.[0]?.image_url?.url || "", /^data:image\/png;base64,/); +}); + +test("#10197 v1 image edit POST surfaces missing openrouter credentials", async () => { + // No openrouter connection seeded at all. + globalThis.fetch = async () => { + throw new Error("Missing-credentials path must not reach upstream"); + }; + + const response = await imageEditRoute.POST( + new Request("http://localhost/api/v1/images/edits", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "openrouter/openai/gpt-5-image-mini", + prompt: "edit this", + images: [REF_A], + }), + }) + ); + const body = (await response.json()) as ErrorResponseBody; + + assert.equal(response.status, 401); + assert.match(body.error.message, /No credentials for provider: openrouter/); + // Hard Rule #12 — error responses must never leak a raw stack trace. + assert.ok(!body.error.message.includes("at /")); +}); + +test("#10197 v1 image edit POST surfaces openrouter rate-limit sentinel", async () => { + await seedOpenRouterConnection({ rateLimitedUntil: new Date(Date.now() + 60_000).toISOString() }); + globalThis.fetch = async () => { + throw new Error("Rate-limited path must not reach upstream"); + }; + + const response = await imageEditRoute.POST( + new Request("http://localhost/api/v1/images/edits", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "openrouter/openai/gpt-5.4-image-2", + prompt: "edit this", + images: [REF_A], + }), + }) + ); + const body = (await response.json()) as ErrorResponseBody; + + assert.equal(response.status, 429); + assert.match(body.error.message, /All accounts rate limited/); + assert.ok(!body.error.message.includes("at /")); +});