From a4d6ad7da42eddd0ab3d78c3974554caba654c2b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Tue, 18 Aug 2026 10:50:01 -0300 Subject: [PATCH] fix(sse): bridge generic compatible-provider type id to concrete node id in credential lookup (#10434) * fix(sse): bridge generic compatible-provider type id to concrete node id in credential lookup getProviderSearchPool only bridged a provider string to a node id via the node's prefix, never via the generic derived type id (openai-compatible-chat / openai-compatible-responses / anthropic-compatible) that resolveProviderNodeForConnection already accepts at connection-creation time (#4421). A connection persisted under the generic type id was therefore unreachable when the chat path resolved the concrete uuid node id, surfacing "No active credentials for provider: openai-compatible-chat-" even though the key and model catalog were valid. Closes #10085 * fix(sse): register #10085 mutation-coverage test file in stryker.conf.json check:mutation-test-coverage --strict flagged tests/unit/10085-compatible-generic-vs-uuid-credential.test.ts as a covering test for src/sse/services/auth.ts that was missing from stryker.conf.json's tap.testFiles, per the CI Fast Quality Gates run on PR #10434. * fix(sse): disambiguate compatible provider credential lookup Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(sse): require unambiguous type in both credential-lookup bridge directions (#10434) getProviderSearchPool()'s generic-type<->concrete-node-id bridge (#4421, #10085) only applied the "exactly one node of this derived type" ambiguity guard to the concrete-id -> generic-type direction. The generic-type -> concrete-id direction added every node sharing a derived type to the search pool unconditionally, so a bare generic-type lookup could resolve to a connection scoped to one specific node's baseUrl/headers even when a second node shares the same derived type -- leaking that node's credentials/upstream URL into an unrelated node's request. Both directions now share the same typeIsUnambiguous gate, mirroring the rule already enforced by selectProviderNodeForConnection() for connection creation (src/lib/db/providerNodeSelect.ts, #4421). --------- Co-authored-by: adevwithpurpose --- ...085-compatible-chat-credential-mismatch.md | 1 + src/sse/services/auth.ts | 57 +++- stryker.conf.json | 1 + ...patible-generic-vs-uuid-credential.test.ts | 243 ++++++++++++++++++ 4 files changed, 297 insertions(+), 5 deletions(-) create mode 100644 changelog.d/fixes/10085-compatible-chat-credential-mismatch.md create mode 100644 tests/unit/10085-compatible-generic-vs-uuid-credential.test.ts diff --git a/changelog.d/fixes/10085-compatible-chat-credential-mismatch.md b/changelog.d/fixes/10085-compatible-chat-credential-mismatch.md new file mode 100644 index 0000000000..773d4ed3cb --- /dev/null +++ b/changelog.d/fixes/10085-compatible-chat-credential-mismatch.md @@ -0,0 +1 @@ +- fix(sse): bridge generic openai-compatible/anthropic-compatible provider type ids to their concrete uuid node id in credential lookup (#10085) diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index da70522843..318d762e3c 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -1,4 +1,5 @@ import { randomUUID, createHash } from "crypto"; +import { nodeTypeFromId } from "@/lib/db/providerNodeSelect"; import { extractGoogApiKeyHeader } from "./googApiKeyAuth.ts"; import { getCachedRawProviderConnections, @@ -991,18 +992,64 @@ async function getProviderSearchPool(provider: string): Promise { // internal provider ids like openai-compatible-responses-. try { const providerNodes = await getCachedProviderNodes(); - for (const node of Array.isArray(providerNodes) ? providerNodes : []) { + const compatibleNodes = Array.isArray(providerNodes) ? providerNodes : []; + const nodeTypes = new Map(); + for (const node of compatibleNodes) { + const nodeRecord = asRecord(node); + const nodeId = typeof nodeRecord.id === "string" ? nodeRecord.id.trim() : ""; + if (!nodeId) continue; + const derivedType = nodeTypeFromId(nodeId); + nodeTypes.set(derivedType, (nodeTypes.get(derivedType) || 0) + 1); + } + + for (const node of compatibleNodes) { const nodeRecord = asRecord(node); const nodePrefix = typeof nodeRecord.prefix === "string" ? nodeRecord.prefix.trim() : ""; const nodeId = typeof nodeRecord.id === "string" ? nodeRecord.id.trim() : ""; - if (!nodePrefix || !nodeId) continue; + if (!nodeId) continue; if ( - nodePrefix === provider || - nodePrefix === canonicalProvider || - nodePrefix === canonicalAlias + nodePrefix && + (nodePrefix === provider || nodePrefix === canonicalProvider || nodePrefix === canonicalAlias) ) { searchPool.add(nodeId); } + + // #10085: bridge the concrete uuid node id (what the chat path resolves, + // "-") to the GENERIC derived type id (what + // resolveProviderNodeForConnection also accepts for connection creation, + // #4421) -- and back. A connection created via the bare generic type + // (e.g. "openai-compatible-chat") must still be found when the chat path + // looks up the concrete node id, and vice versa. + // + // #10434: both bridging directions MUST require the derived type to be + // unambiguous (exactly one provider node of that type) before falling + // back to a generic-type match -- an explicit ownership check, not just + // a string-format coincidence. This mirrors the exact rule already + // enforced by selectProviderNodeForConnection() for connection CREATION + // (src/lib/db/providerNodeSelect.ts, #4421): "only when exactly one such + // node exists, so an ambiguous type never silently picks the wrong + // node". Without this guard on the generic->concrete direction, a bare + // generic-type lookup would pool in EVERY node sharing that derived + // type, including a connection scoped (via its own providerSpecificData + // baseUrl/headers) to one specific node -- leaking that node's + // credentials/upstream URL into a lookup for a different, unrelated + // node of the same generic type. + const derivedType = nodeTypeFromId(nodeId); + if (derivedType && derivedType !== nodeId) { + const typeIsUnambiguous = nodeTypes.get(derivedType) === 1; + if (typeIsUnambiguous) { + if (nodeId === provider || nodeId === canonicalProvider || nodeId === canonicalAlias) { + searchPool.add(derivedType); + } + if ( + derivedType === provider || + derivedType === canonicalProvider || + derivedType === canonicalAlias + ) { + searchPool.add(nodeId); + } + } + } } } catch { // Best-effort alias expansion only. diff --git a/stryker.conf.json b/stryker.conf.json index 13f95dd37c..bd69fbc147 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -53,6 +53,7 @@ "tests/unit/8396-cooldown-429-cap.test.ts", "tests/unit/8488-capability-filter-fail-closed.test.ts", "tests/unit/8779-agy-prefix-credential-lookup.test.ts", + "tests/unit/10085-compatible-generic-vs-uuid-credential.test.ts", "tests/unit/account-fallback-anthropic-quota.test.ts", "tests/unit/account-fallback-cf1010-no-retry-8775.test.ts", "tests/unit/account-fallback-lockout-eviction.test.ts", diff --git a/tests/unit/10085-compatible-generic-vs-uuid-credential.test.ts b/tests/unit/10085-compatible-generic-vs-uuid-credential.test.ts new file mode 100644 index 0000000000..cf1a72ad7e --- /dev/null +++ b/tests/unit/10085-compatible-generic-vs-uuid-credential.test.ts @@ -0,0 +1,243 @@ +/** + * #10085 -- a custom openai-compatible provider connection persisted under the + * GENERIC derived type id ("openai-compatible-chat") must still be reachable + * when the chat path looks up the concrete uuid node id + * ("openai-compatible-chat-"), and vice versa. + * + * `resolveProviderNodeForConnection` (src/lib/db/providers/nodes.ts, #4421) + * already accepts the bare generic type id when a connection is created via + * `/api/providers`. But `getProviderSearchPool` (src/sse/services/auth.ts) + * only bridged the search pool via a node's `prefix`, never via the generic + * type id <-> concrete node id relationship, so a connection created under + * the generic type id went permanently unreachable from the chat path -- + * "No active credentials for provider: openai-compatible-chat-", the + * exact error reported in #10085. + */ +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-10085-compat-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const nodesDb = await import("../../src/lib/db/providers/nodes.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const auth = await import("../../src/sse/services/auth.ts"); + +const NODE_PREFIX = "my-compat-10085"; +const NODE_ID = `openai-compatible-chat-458d982b-0000-4000-8000-000000000000`; +const NODE_B_ID = `openai-compatible-chat-558d982b-0000-4000-8000-000000000000`; + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +async function seedNode() { + await nodesDb.createProviderNode({ + id: NODE_ID, + type: "openai-compatible", + name: "My Compat", + prefix: NODE_PREFIX, + apiType: "chat", + baseUrl: "https://example.test/v1", + }); +} + +async function seedSecondNode() { + await nodesDb.createProviderNode({ + id: NODE_B_ID, + type: "openai-compatible", + name: "My Compat B", + prefix: "my-compat-10085-b", + apiType: "chat", + baseUrl: "https://example-b.test/v1", + }); +} + +test("a connection stored under the GENERIC type id is reachable when chat resolves the uuid node id (#10085)", async () => { + await resetStorage(); + await seedNode(); + await providersDb.createProviderConnection({ + provider: "openai-compatible-chat", // generic type id, NOT the uuid node id + authType: "apikey", + apiKey: "sk-test-10085", + name: "test-compat", + isActive: true, + testStatus: "active", + priority: 1, + providerSpecificData: { prefix: NODE_PREFIX, baseUrl: "https://example.test/v1" }, + }); + + const creds = await auth.getProviderCredentials(NODE_ID); + + assert.ok( + creds, + `chat looked up "${NODE_ID}" but the connection is parked under the generic ` + + `"openai-compatible-chat" provider id -- getProviderSearchPool never bridges the ` + + `generic type id to the concrete node id. This matches #10085 exactly.` + ); +}); + +test("the bridge works in the other direction too: a uuid-stored connection is reachable via the generic type id", async () => { + await resetStorage(); + await seedNode(); + await providersDb.createProviderConnection({ + provider: NODE_ID, // concrete uuid node id + authType: "apikey", + apiKey: "sk-test-10085-b", + name: "test-compat-b", + isActive: true, + testStatus: "active", + priority: 1, + providerSpecificData: { prefix: NODE_PREFIX, baseUrl: "https://example.test/v1" }, + }); + + const creds = await auth.getProviderCredentials("openai-compatible-chat"); + + assert.ok( + creds, + `a connection stored under the uuid node id "${NODE_ID}" must also be reachable via ` + + `a lookup using the bare generic type id "openai-compatible-chat"` + ); +}); + +test("a concrete second node does not inherit the first node's generic credentials", async () => { + await resetStorage(); + await seedNode(); + await seedSecondNode(); + await providersDb.createProviderConnection({ + provider: "openai-compatible-chat", + authType: "apikey", + apiKey: "sk-test-10085-node-a", + name: "test-compat-node-a", + isActive: true, + testStatus: "active", + priority: 1, + providerSpecificData: { + nodeId: NODE_ID, + prefix: NODE_PREFIX, + baseUrl: "https://example-a.test/v1", + }, + }); + + const creds = await auth.getProviderCredentials(NODE_B_ID); + + assert.equal( + creds, + null, + "node B must not receive node A's generic connection when both nodes share a type" + ); +}); + +test("control: a connection stored under the uuid node id is found by a uuid node id lookup", async () => { + await resetStorage(); + await seedNode(); + await providersDb.createProviderConnection({ + provider: NODE_ID, + authType: "apikey", + apiKey: "sk-test-10085-c", + name: "test-compat-c", + isActive: true, + testStatus: "active", + priority: 1, + providerSpecificData: { prefix: NODE_PREFIX, baseUrl: "https://example.test/v1" }, + }); + + assert.ok(await auth.getProviderCredentials(NODE_ID)); +}); + +test("control: a connection stored under the uuid node id is found via prefix lookup", async () => { + await resetStorage(); + await seedNode(); + await providersDb.createProviderConnection({ + provider: NODE_ID, + authType: "apikey", + apiKey: "sk-test-10085-d", + name: "test-compat-d", + isActive: true, + testStatus: "active", + priority: 1, + providerSpecificData: { prefix: NODE_PREFIX, baseUrl: "https://example.test/v1" }, + }); + + assert.ok(await auth.getProviderCredentials(NODE_PREFIX)); +}); + +test("the bridge does not make unrelated generic types findable", async () => { + await resetStorage(); + await seedNode(); + await providersDb.createProviderConnection({ + provider: "openai-compatible-chat", + authType: "apikey", + apiKey: "sk-test-10085-e", + name: "test-compat-e", + isActive: true, + testStatus: "active", + priority: 1, + providerSpecificData: { prefix: NODE_PREFIX, baseUrl: "https://example.test/v1" }, + }); + + // A different generic type (responses, not chat) must stay unrelated. + assert.equal(await auth.getProviderCredentials("openai-compatible-responses"), null); +}); + +// #10434 -- the ambiguity guard added for #10085 was only applied to the +// concrete-id -> generic-type direction (`getProviderSearchPool`'s first +// bridging branch). The generic-type -> concrete-id direction (second +// branch) added every node sharing the derived type to the search pool +// UNCONDITIONALLY, with no ambiguity check. `selectProviderNodeForConnection` +// (src/lib/db/providerNodeSelect.ts, #4421) already established the +// project-wide rule for this exact generic-type fallback: "only when exactly +// one such node exists, so an ambiguous type never silently picks the wrong +// node". `getProviderSearchPool` must apply that SAME rule symmetrically in +// both directions -- otherwise a bare generic-type lookup (e.g. resolved by +// some caller without a concrete node id) silently pools in a connection +// that is scoped to one specific node's baseUrl/headers, sending traffic to +// the wrong upstream with the wrong credentials whenever a second node of +// the same generic type exists. +test( + "a bare generic-type lookup must not leak a node-scoped connection when the " + + "type is ambiguous across multiple nodes (#10434)", + async () => { + await resetStorage(); + await seedNode(); + await seedSecondNode(); + // Connection is scoped to node A specifically (stored under A's concrete + // uuid id, with A's own baseUrl) -- NOT under the bare generic type. + await providersDb.createProviderConnection({ + provider: NODE_ID, + authType: "apikey", + apiKey: "sk-test-10434-node-a", + name: "test-compat-10434-node-a", + isActive: true, + testStatus: "active", + priority: 1, + providerSpecificData: { prefix: NODE_PREFIX, baseUrl: "https://example.test/v1" }, + }); + + // A lookup by the BARE generic type (no concrete node id) must not + // resolve to node A's connection: two nodes (A and B) share the derived + // type "openai-compatible-chat", so the generic type is ambiguous and + // must not silently pick node A's credentials/baseUrl. + const creds = await auth.getProviderCredentials("openai-compatible-chat"); + + assert.equal( + creds, + null, + "a bare generic-type lookup resolved to node A's node-scoped connection even " + + "though the type is ambiguous (node B also derives 'openai-compatible-chat') -- " + + "this can route a request meant for a different node through node A's baseUrl " + + "and credentials." + ); + } +);