diff --git a/changelog.d/fixes/13452-provider-node-baseurl-ignored.md b/changelog.d/fixes/13452-provider-node-baseurl-ignored.md new file mode 100644 index 0000000000..67209917d1 --- /dev/null +++ b/changelog.d/fixes/13452-provider-node-baseurl-ignored.md @@ -0,0 +1 @@ +- **fix(sse):** stop an unhydrated `openai-compatible-*`/`anthropic-compatible-*` connection from silently routing chat requests (and its stored credential) to the real OpenAI/Anthropic API instead of the operator's configured provider-node endpoint (#13452) — thanks @DenXio101 diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index df443a6f7b..64c6416b24 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -319,3 +319,24 @@ export function getClaudeCodeDefaultModels(): { haiku: find(/haiku/i), }; } + +/** + * #13452: shared guard for `*-compatible-*` executors' `buildUrl()`. + * `BaseExecutor`/`DefaultExecutor` used to default an `openai-compatible-*` + * / `anthropic-compatible-*` connection to the real OpenAI/Anthropic API + * when `credentials.providerSpecificData.baseUrl` was absent — silently + * shipping the connection's own stored "API key" as a Bearer/x-api-key + * token to a public third party instead of the operator's intended + * local/self-hosted endpoint. `provider` is embedded in the thrown error + * only for operator debuggability — it is never sent upstream. + */ +export function requireCompatibleBaseUrl( + provider: string | null | undefined, + providerSpecificData: { baseUrl?: unknown } | null | undefined +): string { + const baseUrl = providerSpecificData?.baseUrl; + if (typeof baseUrl === "string" && baseUrl) return baseUrl; + throw new Error( + `provider node "${provider}" has no baseUrl — node missing or connection not hydrated` + ); +} diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 5d07ef73c3..8d0964cb1c 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -1,5 +1,5 @@ import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts"; -import { getRegistryEntry } from "../config/providerRegistry.ts"; +import { getRegistryEntry, requireCompatibleBaseUrl } from "../config/providerRegistry.ts"; import { resolveFetchStartTimeout } from "../utils/fetchStartTimeoutPolicy.ts"; import { resolveAlternateFormat, @@ -30,7 +30,7 @@ import { addParamToBlocklist, isAutoLearnGloballyEnabled, } from "@/lib/db/paramFilters"; -import { applyFingerprint, isCliCompatEnabled, stripInternalBodyFields } from "../config/cliFingerprints.ts"; +import { applyFingerprint, isCliCompatEnabled, stripInternalBodyFields } from "../config/cliFingerprints.ts"; // prettier-ignore import { supportsClaudeMaxEffort, supportsXHighEffort } from "../config/providerModels.ts"; import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts"; import { @@ -380,7 +380,7 @@ export class BaseExecutor { void stream; if (this.provider?.startsWith?.("openai-compatible-")) { const psd = credentials?.providerSpecificData; - const baseUrl = typeof psd?.baseUrl === "string" ? psd.baseUrl : "https://api.openai.com/v1"; + const baseUrl = requireCompatibleBaseUrl(this.provider, psd); // #13452 const normalized = baseUrl.replace(/\/$/, ""); // Sanitize custom path: must start with '/', no path traversal, no null bytes const rawPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null; diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index c267904d9f..c4021071d6 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -11,7 +11,7 @@ import { joinClaudeCodeCompatibleUrl, } from "../services/claudeCodeCompatible.ts"; import { getGigachatAccessToken } from "../services/gigachatAuth.ts"; -import { getRegistryEntry } from "../config/providerRegistry.ts"; +import { getRegistryEntry, requireCompatibleBaseUrl } from "../config/providerRegistry.ts"; import { getModelTargetFormat } from "../config/providerModels.ts"; import { mergeClientAnthropicBeta, @@ -231,7 +231,7 @@ export class DefaultExecutor extends BaseExecutor { void urlIndex; if (this.provider?.startsWith?.("openai-compatible-")) { const psd = credentials?.providerSpecificData; - const baseUrl = psd?.baseUrl || "https://api.openai.com/v1"; + const baseUrl = requireCompatibleBaseUrl(this.provider, psd); // #13452 const normalized = baseUrl.replace(/\/$/, ""); const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null; if (customPath) return `${normalized}${customPath}`; @@ -244,7 +244,7 @@ export class DefaultExecutor extends BaseExecutor { } if (this.provider?.startsWith?.("anthropic-compatible-")) { const psd = credentials?.providerSpecificData; - const baseUrl = psd?.baseUrl || "https://api.anthropic.com/v1"; + const baseUrl = requireCompatibleBaseUrl(this.provider, psd); // #13452 const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null; if (isClaudeCodeCompatible(this.provider)) { return joinClaudeCodeCompatibleUrl( diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index a9be1beefa..8eca43bd97 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -1,5 +1,6 @@ import { randomUUID } from "crypto"; import { nodeTypeFromId } from "@/lib/db/providerNodeSelect"; +import { hydrateConnectionProviderSpecificData } from "./compatibleNodeBaseUrl.ts"; // #13452 import { extractGoogApiKeyHeader } from "./googApiKeyAuth.ts"; import { describeUpstreamFailure } from "@/shared/utils/upstreamError"; import { buildAllExpiredCredentials } from "./authExpiredCredentials.ts"; @@ -1039,33 +1040,6 @@ function planLastUsedCommit( }; } -/** - * Resolve Proxy Pool references on a real connection row at the same boundary - * where credentials become request-ready. The synthetic no-auth fallback above - * already performs this hydration, but a persisted connection (for example the - * OpenCode card's `opencode` row selected through the `opencode-zen` alias) - * bypasses that fallback. Keep inline/legacy entries untouched and only incur a - * registry lookup when at least one by-id reference is present. - */ -async function hydrateAccountProxyReferences( - providerSpecificData: JsonRecord -): Promise { - const entries = providerSpecificData.accountProxies; - if (!Array.isArray(entries)) return providerSpecificData; - - const containsProxyReference = entries.some((entry) => { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false; - const proxyId = (entry as Record).proxyId; - return typeof proxyId === "string" && proxyId.trim().length > 0; - }); - if (!containsProxyReference) return providerSpecificData; - - return { - ...providerSpecificData, - accountProxies: await resolveAccountProxiesFromRegistry(entries), - }; -} - async function materializeConnection( connection: ProviderConnectionView, options: CredentialSelectionOptions, @@ -1074,7 +1048,7 @@ async function materializeConnection( reactivatedFromInactive?: boolean; } = {} ) { - const providerSpecificData = await hydrateAccountProxyReferences(connection.providerSpecificData); + const providerSpecificData = await hydrateConnectionProviderSpecificData(connection); const apiKeyHealth = providerSpecificData.apiKeyHealth as Record | undefined; if (apiKeyHealth) syncHealthFromDB(connection.id, apiKeyHealth); const releaseOAuthSession = diff --git a/src/sse/services/compatibleNodeBaseUrl.ts b/src/sse/services/compatibleNodeBaseUrl.ts new file mode 100644 index 0000000000..a09f90a7f4 --- /dev/null +++ b/src/sse/services/compatibleNodeBaseUrl.ts @@ -0,0 +1,95 @@ +/** + * #13452: self-heal a `*-compatible-*` connection whose `providerSpecificData` + * never received the write-time `baseUrl` copy (`POST /api/providers`'s + * hydration branch, or the `PUT /api/provider-nodes/{id}` backfill loop are + * the only two places that stamp it). A connection created any other way — + * a direct DB insert, a row that predates the hydration logic, or a race + * with node update/deletion — has no `providerSpecificData.baseUrl` at all + * and, left unfixed, `DefaultExecutor.buildUrl()`/`BaseExecutor.buildUrl()` + * silently defaulted to the REAL OpenAI/Anthropic API, shipping the + * connection's own stored credential there as a Bearer/x-api-key token. + * + * Extracted out of `auth.ts` into its own module (`file-size-baseline.json` + * freezes `auth.ts`'s line count). + * + * @module sse/services/compatibleNodeBaseUrl + */ + +import { getCachedProviderNodes } from "@/lib/db/readCache"; +import { selectProviderNodeForConnection } from "@/lib/db/providerNodeSelect"; +import { isCompatibleProviderConnectionId } from "@/shared/utils/compatibleProviderId"; +import { resolveAccountProxiesFromRegistry } from "./noAuthProxyResolution"; + +type JsonRecord = Record; + +/** + * Composes both boundary-hydration steps a persisted connection's + * `providerSpecificData` needs before it becomes request-ready credentials: + * Proxy Pool by-id references (moved here from `auth.ts` verbatim — was + * `hydrateAccountProxyReferences`), then the `*-compatible-*` baseUrl + * self-heal below. Keep inline/legacy `accountProxies` entries untouched and + * only incur a proxy-registry lookup when at least one by-id reference is + * present. + */ +export async function hydrateConnectionProviderSpecificData(connection: { + provider: string; + providerSpecificData: JsonRecord; +}): Promise { + const { providerSpecificData } = connection; + const entries = providerSpecificData.accountProxies; + const containsProxyReference = + Array.isArray(entries) && + entries.some((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false; + const proxyId = (entry as Record).proxyId; + return typeof proxyId === "string" && proxyId.trim().length > 0; + }); + const proxyHydrated = containsProxyReference + ? { ...providerSpecificData, accountProxies: await resolveAccountProxiesFromRegistry(entries) } + : providerSpecificData; + return hydrateCompatibleNodeBaseUrl(connection.provider, proxyHydrated); +} + +/** + * Re-joins `provider_nodes` (via the existing 5s-TTL cache, so this is not + * an extra DB read on the hot path) and stamps the same fields the write-time + * hydration branch stamps. Returns `providerSpecificData` unchanged when the + * connection is not a compatible-node connection, already carries a + * `baseUrl`, or no matching node can be resolved (the executor's fail-loud + * fallback then reports the true "node missing" condition instead of + * silently routing to a public third party). + */ +async function hydrateCompatibleNodeBaseUrl( + provider: string, + providerSpecificData: JsonRecord +): Promise { + if (typeof providerSpecificData.baseUrl === "string" && providerSpecificData.baseUrl) { + return providerSpecificData; + } + if (!isCompatibleProviderConnectionId(provider)) return providerSpecificData; + + try { + const nodes = (await getCachedProviderNodes()) as JsonRecord[]; + const node = selectProviderNodeForConnection(provider, nodes); + if (!node || typeof node.baseUrl !== "string" || !node.baseUrl) return providerSpecificData; + + return { + ...providerSpecificData, + prefix: providerSpecificData.prefix ?? node.prefix, + apiType: providerSpecificData.apiType ?? node.apiType, + baseUrl: node.baseUrl, + nodeName: providerSpecificData.nodeName ?? node.name, + ...(node.chatPath && !providerSpecificData.chatPath ? { chatPath: node.chatPath } : {}), + ...(node.modelsPath && !providerSpecificData.modelsPath + ? { modelsPath: node.modelsPath } + : {}), + ...(node.customHeaders && !providerSpecificData.customHeaders + ? { customHeaders: node.customHeaders } + : {}), + }; + } catch { + // Best-effort self-heal only — a transient DB/cache read failure must + // not throw here; the executor's own fail-loud fallback is the backstop. + return providerSpecificData; + } +} diff --git a/tests/unit/executor-default-base.test.ts b/tests/unit/executor-default-base.test.ts index 469ffe992c..ba628de97b 100644 --- a/tests/unit/executor-default-base.test.ts +++ b/tests/unit/executor-default-base.test.ts @@ -601,9 +601,8 @@ test("DefaultExecutor.execute uses CC-compatible connection defaults to append 1 stream: false, credentials: { apiKey: "cc-key", - providerSpecificData: { - ccSessionId: "session-1", - }, + // #13452: buildUrl() now requires a hydrated baseUrl. + providerSpecificData: { ccSessionId: "session-1", baseUrl: "https://cc.test/v1" }, }, clientHeaders: { "x-app": "cli", @@ -623,6 +622,7 @@ test("DefaultExecutor.execute uses CC-compatible connection defaults to append 1 apiKey: "cc-key", providerSpecificData: { ccSessionId: "session-1", + baseUrl: "https://cc.test/v1", requestDefaults: { context1m: true, redactThinking: true }, }, }, @@ -658,6 +658,7 @@ test("DefaultExecutor.execute uses CC-compatible connection defaults to append 1 apiKey: "cc-key", providerSpecificData: { ccSessionId: "session-1", + baseUrl: "https://cc-proxy.example.test/v1", requestDefaults: { context1m: true }, }, }, @@ -734,9 +735,7 @@ test("DefaultExecutor.execute reports the exact serialized provider request befo stream: false, credentials: { apiKey: "cc-key", - providerSpecificData: { - ccSessionId: "session-1", - }, + providerSpecificData: { ccSessionId: "session-1", baseUrl: "https://cc.test/v1" }, // #13452 }, }) ); diff --git a/tests/unit/issue-13452-node-baseurl-ignored.test.ts b/tests/unit/issue-13452-node-baseurl-ignored.test.ts new file mode 100644 index 0000000000..c985f4f92a --- /dev/null +++ b/tests/unit/issue-13452-node-baseurl-ignored.test.ts @@ -0,0 +1,66 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { DefaultExecutor } from "../../open-sse/executors/default.ts"; +import { BaseExecutor } from "../../open-sse/executors/base.ts"; + +// Issue #13452 — Bug 2: a provider-node ("openai-compatible-*" / +// "anthropic-compatible-*") connection whose credentials carry no +// providerSpecificData.baseUrl (e.g. a connection created any way other than +// the exact POST /api/providers hydration branch, or the node-update +// backfill loop) used to silently fall back to the literal +// "https://api.openai.com/v1" / "https://api.anthropic.com/v1" instead of +// erroring or re-resolving the node's configured baseUrl. Real traffic meant +// for a local OpenAI-compatible endpoint (Ollama/vLLM/LM Studio) was instead +// sent to the real OpenAI API, carrying whatever string was stored as the +// "API key" as a Bearer token to a public third party. +// +// Fix: buildUrl() now fails loudly (throws) instead of defaulting, and the +// credential-selection read path (src/sse/services/auth.ts) self-heals by +// re-joining provider_nodes before a request ever reaches buildUrl(). + +test("issue #13452: DefaultExecutor openai-compatible buildUrl must not silently fall back to the real OpenAI API when providerSpecificData.baseUrl is absent", () => { + const nodeId = "openai-compatible-chat-test-node"; + const executor = new DefaultExecutor(nodeId); + + // Simulates the credentials row a hand-created / non-hydrated connection + // produces: no providerSpecificData.baseUrl at all, even though the node + // itself (in provider_nodes) has baseUrl = "http://localhost:11434/v1". + const credentialsWithoutHydration = { apiKey: "ollama" }; + + assert.throws( + () => executor.buildUrl("qwen3.6:35b-a3b", false, 0, credentialsWithoutHydration), + /baseUrl/, + "buildUrl() must fail loudly instead of silently defaulting an unhydrated openai-compatible " + + "connection to the real OpenAI API — this WAS the reported bug (#13452)" + ); +}); + +test("issue #13452: BaseExecutor openai-compatible buildUrl must not silently fall back to the real OpenAI API when providerSpecificData.baseUrl is absent", () => { + const executor = new BaseExecutor("openai-compatible-responses-test-node", {}); + + assert.throws(() => executor.buildUrl("gpt-5.4", true, 0, { apiKey: "local-key" }), /baseUrl/); +}); + +test("issue #13452: DefaultExecutor anthropic-compatible buildUrl must not silently fall back to the real Anthropic API when providerSpecificData.baseUrl is absent", () => { + const executor = new DefaultExecutor("anthropic-compatible-test-node"); + + assert.throws( + () => executor.buildUrl("claude-sonnet-4-6", true, 0, { apiKey: "local-key" }), + /baseUrl/ + ); +}); + +test("issue #13452 (control): providing providerSpecificData.baseUrl routes correctly (confirms the fallback, not buildUrl() itself, was the defect)", () => { + const nodeId = "openai-compatible-chat-test-node-2"; + const executor = new DefaultExecutor(nodeId); + + const hydratedCredentials = { + apiKey: "ollama", + providerSpecificData: { baseUrl: "http://localhost:11434/v1" }, + }; + + const url = executor.buildUrl("qwen3.6:35b-a3b", false, 0, hydratedCredentials); + + assert.equal(url, "http://localhost:11434/v1/chat/completions"); +});