Compare commits

..

1 Commits

Author SHA1 Message Date
adevwithpurpose
61e39bfe54 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-<uuid>" even
though the key and model catalog were valid.

Closes #10085
2026-08-14 21:30:39 -03:00
6 changed files with 178 additions and 59 deletions

View File

@@ -0,0 +1 @@
- fix(sse): bridge generic openai-compatible/anthropic-compatible provider type ids to their concrete uuid node id in credential lookup (#10085)

View File

@@ -1 +0,0 @@
- fix(dashboard): remap unified Kimi Code card API-key save to the admitted `kimi-coding-apikey` connection id, fixing 400 "Invalid provider" on Save (#10096)

View File

@@ -32,19 +32,6 @@ type UseApiKeySaveParams = {
t: ProviderMessageTranslator;
};
// Issue #10096: the unified Kimi Code dashboard card shares one page/providerId
// ("kimi-coding") between OAuth and API-key auth. "kimi-coding" is an
// OAuth-primary managed id and is NOT an admitted API-key/dual-auth connection
// id (see isManagedProviderConnectionId in src/lib/providers/catalog.ts), so
// posting it here 400s with "Invalid provider". The dedicated managed
// API-key id "kimi-coding-apikey" IS admitted — remap only the POST payload
// so the saved connection lands under the correct managed id. The OAuth flow
// (handleOAuthSuccess in ProviderDetailPageClient.tsx) does not go through
// this hook, so it keeps posting "kimi-coding" unchanged.
export function resolveApiKeySaveProviderId(providerId: string): string {
return providerId === "kimi-coding" ? "kimi-coding-apikey" : providerId;
}
export function useApiKeySave({
providerId,
fetchConnections,
@@ -61,10 +48,7 @@ export function useApiKeySave({
const res = await fetch("/api/providers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: resolveApiKeySaveProviderId(providerId),
...formData,
}),
body: JSON.stringify({ provider: providerId, ...formData }),
});
if (res.ok) {
const connectionData = await res.json();

View File

@@ -1,4 +1,5 @@
import { randomUUID, createHash } from "crypto";
import { nodeTypeFromId } from "@/lib/db/providerNodeSelect";
import { extractGoogApiKeyHeader } from "./googApiKeyAuth.ts";
import {
getCachedRawProviderConnections,
@@ -967,14 +968,33 @@ async function getProviderSearchPool(provider: string): Promise<string[]> {
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,
// "<generic-type>-<uuid>") 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.
const derivedType = nodeTypeFromId(nodeId);
if (derivedType && derivedType !== nodeId) {
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.

View File

@@ -0,0 +1,152 @@
/**
* #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-<uuid>"), 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-<uuid>", 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`;
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",
});
}
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("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);
});

View File

@@ -1,37 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
// Issue #10096: Kimi Code API key validates OK but Save returns 400 "Invalid provider".
//
// Root cause: the unified Kimi Code dashboard card's API-key branch posted
// provider: "kimi-coding" (an OAuth-primary managed id, NOT an admitted
// API-key connection id) to POST /api/providers, which the backend rejects.
// The dedicated managed API-key id "kimi-coding-apikey" IS admitted.
//
// Fix: resolveApiKeySaveProviderId() in useApiKeySave.ts remaps the posted
// provider id to "kimi-coding-apikey" for the API-key save flow only, while
// the OAuth flow (which never calls this hook) keeps posting "kimi-coding".
const { isManagedProviderConnectionId } = await import("../../src/lib/providers/catalog.ts");
const { resolveApiKeySaveProviderId } = await import(
"../../src/app/(dashboard)/dashboard/providers/[id]/hooks/useApiKeySave.ts"
);
test("Kimi Code API-key save flow remaps to the admitted managed API-key id", () => {
assert.equal(
resolveApiKeySaveProviderId("kimi-coding"),
"kimi-coding-apikey",
"the unified Kimi Code card's API-key save flow must post kimi-coding-apikey, not kimi-coding"
);
assert.equal(
isManagedProviderConnectionId(resolveApiKeySaveProviderId("kimi-coding")),
true,
"the remapped id must be an admitted managed provider connection id (POST /api/providers accepts it)"
);
});
test("resolveApiKeySaveProviderId leaves every other provider id untouched", () => {
assert.equal(resolveApiKeySaveProviderId("openai"), "openai");
assert.equal(resolveApiKeySaveProviderId("kimi-coding-apikey"), "kimi-coding-apikey");
assert.equal(resolveApiKeySaveProviderId("qoder"), "qoder");
});