Compare commits

..

1 Commits

Author SHA1 Message Date
adevwithpurpose
ff6d465140 fix(dashboard): remap Kimi Code API-key save to admitted managed id (#10096)
The unified Kimi Code card's API-key branch posted provider: "kimi-coding"
to POST /api/providers. "kimi-coding" is an OAuth-primary managed id, not
an admitted API-key/dual-auth connection id, so the backend correctly
rejected it with 400 "Invalid provider" even though key validation passed.

Add resolveApiKeySaveProviderId() in useApiKeySave.ts to remap the posted
provider id to the dedicated, admitted managed API-key id
"kimi-coding-apikey" for the API-key save flow only. The OAuth flow
(handleOAuthSuccess in ProviderDetailPageClient.tsx) never calls this hook
and keeps posting "kimi-coding" unchanged.

Regression test: tests/unit/bug-10096-kimi-coding-apikey-save.test.ts
2026-08-14 18:38:39 -03:00
6 changed files with 59 additions and 178 deletions

View File

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

View File

@@ -0,0 +1 @@
- 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,6 +32,19 @@ 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,
@@ -48,7 +61,10 @@ export function useApiKeySave({
const res = await fetch("/api/providers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: providerId, ...formData }),
body: JSON.stringify({
provider: resolveApiKeySaveProviderId(providerId),
...formData,
}),
});
if (res.ok) {
const connectionData = await res.json();

View File

@@ -1,5 +1,4 @@
import { randomUUID, createHash } from "crypto";
import { nodeTypeFromId } from "@/lib/db/providerNodeSelect";
import { extractGoogApiKeyHeader } from "./googApiKeyAuth.ts";
import {
getCachedRawProviderConnections,
@@ -968,33 +967,14 @@ 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 (!nodeId) continue;
if (!nodePrefix || !nodeId) continue;
if (
nodePrefix &&
(nodePrefix === provider || nodePrefix === canonicalProvider || nodePrefix === canonicalAlias)
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

@@ -1,152 +0,0 @@
/**
* #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

@@ -0,0 +1,37 @@
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");
});