From 95cb992c322e82d83b30cf0dbc5785d976f56a5a Mon Sep 17 00:00:00 2001 From: Tiangao <53409436+tiangao88@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:34:47 +0200 Subject: [PATCH] fix(providers): honor the base URL override in OpenRouter model discovery (#14001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(providers): honor the base URL override in OpenRouter model discovery Model discovery for the built-in `openrouter` provider resolved its catalog URL from PROVIDER_MODELS_CONFIG, which is pinned to the global `https://openrouter.ai/api/v1/models`. The per-connection base-URL override (`providerSpecificData.baseUrl`, set via "Advanced -> override base URL") was never consulted on the discovery path, while the inference path has honored it since #6147 (open-sse/executors/base.ts `resolveBaseUrl`). A connection pointed at a different OpenRouter region therefore kept importing the global catalog: the per-connection model list, and the auto-sync that maintains it, advertised model ids the configured endpoint cannot serve. Those ids only failed later, at inference time, so a region/catalog mismatch surfaced as what looked like a provider outage. The two catalogs genuinely differ — the global endpoint advertises ~444 model ids, the EU in-region endpoint ~58 (a strict subset) — so discovery and inference disagreed with no signal exposing it. Discovery now prefers the override for this provider, reusing the existing `addModelsSuffix()` normalization (drops a trailing chat/responses/messages path, appends /models, leaves an existing /models untouched). Mirrors the `openai` override handling added for the same class of bug in #5899. When no override is set the built-in global catalog is still used. Tests: tests/unit/openrouter-models-baseurl-override.test.ts covers both the override and the unchanged default. * chore(changelog): add fragment for #14001 --------- Co-authored-by: Tiangao (hermes) --- ...001-openrouter-models-base-url-override.md | 1 + src/app/api/providers/[id]/models/route.ts | 18 +++ ...openrouter-models-baseurl-override.test.ts | 106 ++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 changelog.d/fixes/14001-openrouter-models-base-url-override.md create mode 100644 tests/unit/openrouter-models-baseurl-override.test.ts diff --git a/changelog.d/fixes/14001-openrouter-models-base-url-override.md b/changelog.d/fixes/14001-openrouter-models-base-url-override.md new file mode 100644 index 0000000000..12c63ec0fb --- /dev/null +++ b/changelog.d/fixes/14001-openrouter-models-base-url-override.md @@ -0,0 +1 @@ +- **fix(providers):** OpenRouter model discovery honors the per-connection base URL override instead of always importing the global catalog, so a connection pointed at a regional endpoint (e.g. the EU in-region host) no longer advertises model ids that endpoint cannot serve ([#14001](https://github.com/diegosouzapw/OmniRoute/pull/14001)) — thanks @tiangao88 diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index f9f263c4a8..c77bc39b8e 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -12,6 +12,7 @@ import { resolveAlibabaProviderModelsUrl } from "@/shared/constants/alibabaProvi import { getStaticModelsForProvider } from "@/lib/providers/staticModels"; import { providerUsesCuratedModelsOnly } from "@/lib/providers/modelListingCapability"; import { mergeModelsWithCustomPrecedence } from "@/lib/providers/modelMetadataPrecedence"; +import { addModelsSuffix } from "@/lib/providers/validation/urlHelpers"; import { getCachedProviderConnectionById } from "@/lib/db/readCache"; import { resolveProxyForProvider } from "@/lib/db/proxies"; import { @@ -2168,6 +2169,23 @@ export async function GET( url = `${base}/v1/models`; } } + // OpenRouter is pinned by PROVIDER_MODELS_CONFIG to the *global* catalog + // (https://openrouter.ai/api/v1/models), so it never consulted the + // per-connection base-URL override. A connection pointed at a different + // OpenRouter region — e.g. the EU in-region endpoint + // (https://eu.openrouter.ai/api/v1) — therefore kept importing the global + // catalog and advertised models that endpoint cannot serve. Those ids then + // 404 at inference time even though the per-connection model list, and the + // auto-sync that maintains it, look healthy. Mirrors the `openai` override + // handling above (#5899). addModelsSuffix() reuses the shared normalization: + // it drops a trailing chat/responses/messages path, appends /models, and + // leaves an already-/models URL untouched. + if (provider === "openrouter") { + const customBaseUrl = getProviderBaseUrl(connection.providerSpecificData); + if (customBaseUrl) { + url = addModelsSuffix(customBaseUrl) || url; + } + } if (provider === "cloudflare-ai") { const pData = asRecord(connection.providerSpecificData); const accountId = diff --git a/tests/unit/openrouter-models-baseurl-override.test.ts b/tests/unit/openrouter-models-baseurl-override.test.ts new file mode 100644 index 0000000000..0e5b9804b7 --- /dev/null +++ b/tests/unit/openrouter-models-baseurl-override.test.ts @@ -0,0 +1,106 @@ +// Model discovery for the built-in `openrouter` provider was pinned to the +// GLOBAL catalog URL in PROVIDER_MODELS_CONFIG +// ("https://openrouter.ai/api/v1/models"), so the per-connection base-URL +// override ("Advanced → override base URL", stored as +// providerSpecificData.baseUrl) was silently ignored on the DISCOVERY path — +// while the INFERENCE path honored it all along +// (open-sse/executors/base.ts `resolveBaseUrl`: "Operator's manual override +// always wins (#6147)"). +// +// Consequence: a connection pointed at a different OpenRouter region (for +// example the EU in-region endpoint https://eu.openrouter.ai/api/v1) kept +// importing the global catalog, so the per-connection model list — and the +// auto-sync that maintains it — advertised models that the configured endpoint +// cannot serve. Those ids only failed later, at inference time, which made the +// misconfiguration look like a provider outage rather than a catalog mismatch. +// +// The two OpenRouter catalogs genuinely differ: the global endpoint advertises +// ~444 model ids, the EU in-region endpoint ~58 (a strict subset). +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-baseurl-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const providerModelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts"); + +const originalFetch = globalThis.fetch; + +const EU_BASE_URL = "https://eu.openrouter.ai/api/v1"; +const GLOBAL_CATALOG_URL = "https://openrouter.ai/api/v1/models"; + +async function resetStorage() { + globalThis.fetch = originalFetch; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function seedOpenRouterConnection(providerSpecificData: Record) { + return providersDb.createProviderConnection({ + provider: "openrouter", + authType: "apikey", + name: `openrouter-${Math.random().toString(16).slice(2, 8)}`, + apiKey: "[REDACTED:auth_header]", + isActive: true, + testStatus: "active", + providerSpecificData, + }); +} + +async function discover(connectionId: string) { + const seenUrls: string[] = []; + globalThis.fetch = async (url) => { + seenUrls.push(String(url)); + return Response.json({ data: [{ id: "openai/gpt-5.6-luna", name: "GPT-5.6 Luna" }] }); + }; + + const response = await providerModelsRoute.GET( + new Request(`http://localhost/api/providers/${connectionId}/models?refresh=true`), + { params: { id: connectionId } } + ); + return { response, seenUrls }; +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + globalThis.fetch = originalFetch; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("openrouter model discovery honors the per-connection base URL override", async () => { + const connection = await seedOpenRouterConnection({ baseUrl: EU_BASE_URL }); + + const { response, seenUrls } = await discover(connection.id); + assert.equal(response.status, 200); + + assert.ok( + seenUrls.some((url) => url.startsWith(`${EU_BASE_URL}/models`)), + `expected discovery against the overridden base URL, got: ${JSON.stringify(seenUrls)}` + ); + assert.ok( + !seenUrls.some((url) => url.startsWith(GLOBAL_CATALOG_URL)), + `expected the hardcoded global catalog NOT to be used, got: ${JSON.stringify(seenUrls)}` + ); +}); + +test("openrouter discovery still uses the built-in global catalog when no override is set", async () => { + const connection = await seedOpenRouterConnection({}); + + const { response, seenUrls } = await discover(connection.id); + assert.equal(response.status, 200); + + assert.ok( + seenUrls.some((url) => url.startsWith(GLOBAL_CATALOG_URL)), + `expected the built-in global catalog, got: ${JSON.stringify(seenUrls)}` + ); +});