mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
committed by
GitHub
parent
9bc2c89924
commit
fa0aa1e25d
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
60
tests/unit/image-generation-baseurl-3205.test.ts
Normal file
60
tests/unit/image-generation-baseurl-3205.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user