mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 22:52:19 +03:00
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:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
185
tests/unit/10197-openrouter-image-edits-route.test.ts
Normal file
185
tests/unit/10197-openrouter-image-edits-route.test.ts
Normal 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 /"));
|
||||
});
|
||||
Reference in New Issue
Block a user