diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 98f5f32c19..bdd5943f88 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -87,6 +87,50 @@ const OPENAI_IMAGE_TO_IMAGE_MODELS = new Set([ const IMAGE_ASPECT_RATIO_PATTERN = /^\d+:\d+$/; +/** + * Resolve the upstream images endpoint for a custom (OpenAI-compatible) image + * provider node (#3205). + * + * Custom provider nodes store their base URL the same way the chat path does: + * in `credentials.providerSpecificData.baseUrl` (e.g. `https://example.com/v1`), + * NOT as a top-level `credentials.baseUrl`. Older callers may still pass a + * top-level `baseUrl`, so we honor that as a secondary source. When neither is + * present we fall back to `fallback` (the built-in Gemini OpenAI endpoint). + * + * Resolution order: providerSpecificData.baseUrl → credentials.baseUrl → fallback. + * + * A node base URL like `https://example.com/v1` is normalized and the + * OpenAI-compatible `/images/generations` path appended (mirroring + * `buildOpenAICompatibleUrl` in services/provider.ts). A node URL that already + * ends in `/images/generations` is returned as-is (no double-append). The + * `fallback` value is assumed to already be a complete URL and is returned + * verbatim. + */ +export function resolveImageBaseUrl( + credentials: + | { baseUrl?: unknown; providerSpecificData?: { baseUrl?: unknown } | null } + | null + | undefined, + fallback: string +): string { + const psd = credentials?.providerSpecificData; + const psdBaseUrl = + psd && typeof psd === "object" && typeof psd.baseUrl === "string" && psd.baseUrl.trim() + ? psd.baseUrl.trim() + : null; + const topLevelBaseUrl = + typeof credentials?.baseUrl === "string" && credentials.baseUrl.trim() + ? credentials.baseUrl.trim() + : null; + const nodeBaseUrl = psdBaseUrl || topLevelBaseUrl; + + if (!nodeBaseUrl) return fallback; + + const normalized = nodeBaseUrl.replace(/\/+$/, ""); + if (/\/images\/generations$/.test(normalized)) return normalized; + return `${normalized}/images/generations`; +} + function normalizeImageAspectRatio(value: unknown, fallbackSize: unknown): string { if (typeof value === "string") { const trimmedValue = value.trim(); @@ -257,9 +301,16 @@ export async function handleImageGeneration({ const syntheticConfig = { id: provider, - baseUrl: - credentials?.baseUrl || - `https://generativelanguage.googleapis.com/v1beta/openai/images/generations`, + // #3205: custom OpenAI-compatible nodes store their base URL in + // credentials.providerSpecificData.baseUrl (same as the chat path — + // see executors/default.ts:buildUrl / services/provider.ts:buildProviderUrl). + // Previously only the (always-absent) top-level credentials.baseUrl was + // read, so every custom image node fell back to the Gemini endpoint and + // returned "Please pass a valid API key". + baseUrl: resolveImageBaseUrl( + credentials, + `https://generativelanguage.googleapis.com/v1beta/openai/images/generations` + ), authType: "apikey", authHeader: "bearer", format: "openai", diff --git a/src/app/api/v1/images/generations/route.ts b/src/app/api/v1/images/generations/route.ts index 080248e8f6..2f4ad82c7a 100644 --- a/src/app/api/v1/images/generations/route.ts +++ b/src/app/api/v1/images/generations/route.ts @@ -20,6 +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 { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; /** @@ -117,6 +118,43 @@ 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 { @@ -136,6 +174,14 @@ 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); + } + // Parse model to get provider let { provider } = parseImageModel(body.model); let isCustomModel = false; diff --git a/tests/unit/image-generation-baseurl-3205.test.ts b/tests/unit/image-generation-baseurl-3205.test.ts new file mode 100644 index 0000000000..de12ad5416 --- /dev/null +++ b/tests/unit/image-generation-baseurl-3205.test.ts @@ -0,0 +1,60 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { resolveImageBaseUrl } from "@omniroute/open-sse/handlers/imageGeneration.ts"; + +const GEMINI_FALLBACK = + "https://generativelanguage.googleapis.com/v1beta/openai/images/generations"; + +test("#3205: custom node baseUrl from providerSpecificData is used (not Gemini fallback)", () => { + const credentials = { providerSpecificData: { baseUrl: "https://example.com/v1" } }; + const resolved = resolveImageBaseUrl(credentials, GEMINI_FALLBACK); + + assert.ok( + resolved.startsWith("https://example.com/"), + `expected resolved URL to point to example.com, got: ${resolved}` + ); + assert.ok( + !resolved.includes("generativelanguage.googleapis.com"), + `resolved URL must not fall back to the Gemini endpoint, got: ${resolved}` + ); + // A node configured as https://example.com/v1 should yield the OpenAI-compatible + // images path appended. + assert.equal(resolved, "https://example.com/v1/images/generations"); +}); + +test("#3205: providerSpecificData.baseUrl wins over top-level credentials.baseUrl", () => { + const credentials = { + baseUrl: "https://toplevel.example/v1", + providerSpecificData: { baseUrl: "https://psd.example/v1" }, + }; + const resolved = resolveImageBaseUrl(credentials, GEMINI_FALLBACK); + assert.equal(resolved, "https://psd.example/v1/images/generations"); +}); + +test("#3205: trailing slash on node baseUrl is normalized (no double slash)", () => { + const credentials = { providerSpecificData: { baseUrl: "https://example.com/v1/" } }; + const resolved = resolveImageBaseUrl(credentials, GEMINI_FALLBACK); + assert.equal(resolved, "https://example.com/v1/images/generations"); +}); + +test("#3205: an already-complete images URL is not double-appended", () => { + const credentials = { + providerSpecificData: { baseUrl: "https://example.com/v1/images/generations" }, + }; + const resolved = resolveImageBaseUrl(credentials, GEMINI_FALLBACK); + assert.equal(resolved, "https://example.com/v1/images/generations"); +}); + +test("#3205: top-level credentials.baseUrl is honored when no providerSpecificData", () => { + const credentials = { baseUrl: "https://legacy.example/v1" }; + const resolved = resolveImageBaseUrl(credentials, GEMINI_FALLBACK); + assert.equal(resolved, "https://legacy.example/v1/images/generations"); +}); + +test("#3205: falls back to provided default when no node baseUrl present", () => { + const resolved = resolveImageBaseUrl({}, GEMINI_FALLBACK); + assert.equal(resolved, GEMINI_FALLBACK); + const resolvedNull = resolveImageBaseUrl(null, GEMINI_FALLBACK); + assert.equal(resolvedNull, GEMINI_FALLBACK); +});