fix(images): use provider node base_url + resolve prefix for custom image providers (#3205) (#3208)

The image-generation handler read credentials.baseUrl (always undefined),
so custom OpenAI-compatible image providers fell back to the Gemini endpoint
(401). Resolve from providerSpecificData.baseUrl like the chat path, and
rewrite prefix/model to the internal node id before the exact-id lookup.

Co-authored-by: ngocquynh85 <ngocquynh85@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-05 02:50:54 -03:00
committed by GitHub
parent 9bc2c89924
commit fa0aa1e25d
3 changed files with 160 additions and 3 deletions

View File

@@ -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",

View File

@@ -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<string, string> {
return out;
}
/**
* Resolve a `prefix/model` image request to the internal `<nodeId>/<model>`
* form (#3205).
*
* The custom-model lookup below only matches the full internal id
* (`<nodeId>/<modelId>`), 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<string> {
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 `<nodeId>/<model>` 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;

View File

@@ -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);
});