fix(image): support OpenRouter reference-image edits (#10197) (#10363)

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)
This commit is contained in:
Tiangao
2026-08-20 23:04:22 +02:00
committed by GitHub
parent 871832820f
commit c79faa45fb
3 changed files with 336 additions and 0 deletions

View File

@@ -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<string, unknown> = {
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<string, string> = {
"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,

View File

@@ -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(

View File

@@ -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<string, string> | undefined)?.authorization ||
(headers as Record<string, string> | 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> }).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 /"));
});