Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/13452-provider-node-baseurl-ignored.md
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions open-sse/config/providerRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
);
}
6 changes: 3 additions & 3 deletions open-sse/executors/base.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions open-sse/executors/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}`;
Expand All @@ -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(
Expand Down
30 changes: 2 additions & 28 deletions src/sse/services/auth.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<JsonRecord> {
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<string, unknown>).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,
Expand All @@ -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<string, KeyHealth> | undefined;
if (apiKeyHealth) syncHealthFromDB(connection.id, apiKeyHealth);
const releaseOAuthSession =
Expand Down
95 changes: 95 additions & 0 deletions src/sse/services/compatibleNodeBaseUrl.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

/**
* 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<JsonRecord> {
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<string, unknown>).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<JsonRecord> {
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;
}
}
11 changes: 5 additions & 6 deletions tests/unit/executor-default-base.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 },
},
},
Expand Down Expand Up @@ -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 },
},
},
Expand Down Expand Up @@ -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
},
})
);
Expand Down
66 changes: 66 additions & 0 deletions tests/unit/issue-13452-node-baseurl-ignored.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Loading