mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-20 22:02:19 +03:00
Compare commits
2 Commits
fix/13922-
...
fix/13452-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be9f1ca5e9 | ||
|
|
94f1b3eb5b |
1
changelog.d/fixes/13452-provider-node-baseurl-ignored.md
Normal file
1
changelog.d/fixes/13452-provider-node-baseurl-ignored.md
Normal file
@@ -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
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<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,
|
||||
@@ -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 =
|
||||
|
||||
95
src/sse/services/compatibleNodeBaseUrl.ts
Normal file
95
src/sse/services/compatibleNodeBaseUrl.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
66
tests/unit/issue-13452-node-baseurl-ignored.test.ts
Normal file
66
tests/unit/issue-13452-node-baseurl-ignored.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user