Compare commits

..

1 Commits

6 changed files with 140 additions and 144 deletions

View File

@@ -1 +0,0 @@
- fix(db): scope local-provider apiKey dedup to matching base URL so LM Studio/Ollama-style connections support multiple accounts (#12173)

View File

@@ -0,0 +1 @@
- fix(dashboard): refresh the providers list after deleting a compatible provider node (#12298)

View File

@@ -110,6 +110,7 @@ export default function CompatibleNodeCard({
});
if (res.ok) {
router.push("/dashboard/providers");
router.refresh();
}
} catch (error) {
console.error("Error deleting provider node:", error);

View File

@@ -35,7 +35,6 @@ import {
parseProviderSpecificData,
isMatchingOauthIdentity,
} from "./webSessionDedup";
import { LOCAL_PROVIDERS } from "@/shared/constants/providers";
import { pickCodexConnectionForUser } from "@/lib/oauth/utils/codexConnectionSelection";
import { isMicrosoftDesignerWebRetiredProviderId } from "@/shared/constants/designerWebRetirement";
import { reconcileCodexUsageHistory } from "./providers/usageIdentityReconciliation";
@@ -418,28 +417,6 @@ export function getProviderConnectionDisplayMetadata(
// createProviderConnection to keep that function below the complexity baseline.
// provider_specific_data is plaintext JSON, so the value is compared directly
// without decryption.
/**
* #12173 — the API-key-value dedup (#3023) matches purely on `provider +
* apiKey`, which is correct for hosted providers where the key alone is the
* account identity. Local/self-hosted providers (LM Studio, Ollama, vLLM,
* llama.cpp, ...) commonly ship an optional/cosmetic API key, so users
* legitimately reuse the same placeholder value (e.g. "lm-studio") across two
* physically distinct servers that are actually distinguished by base URL.
* Gate the extra baseUrl check to this provider set only — hosted-provider
* dedup must stay untouched.
*/
function isLocalProviderId(providerId: unknown): boolean {
return (
typeof providerId === "string" &&
Object.prototype.hasOwnProperty.call(LOCAL_PROVIDERS, providerId)
);
}
/** Trim + strip a trailing slash so cosmetic differences don't defeat the match. */
function normalizeBaseUrlForDedup(value: unknown): string {
return typeof value === "string" ? value.trim().replace(/\/+$/, "") : "";
}
function findExistingCookieConnection(
db: DbLike,
provider: unknown,
@@ -574,25 +551,15 @@ export async function createProviderConnection(data: JsonRecord) {
// plaintext (trimmed) instead.
const newApiKey = typeof data.apiKey === "string" ? data.apiKey.trim() : "";
if (!existing && newApiKey) {
const isLocal = isLocalProviderId(data.provider);
const newBaseUrl = normalizeBaseUrlForDedup(providerSpecificData.baseUrl);
const apiKeyRows = db
.prepare("SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'apikey'")
.all(data.provider) as JsonRecord[];
for (const row of apiKeyRows) {
const decrypted = decryptConnectionFields(toRecord(rowToCamel(row)));
if (toStringOrNull(decrypted.apiKey)?.trim() !== newApiKey) continue;
// #12173 — for local/self-hosted providers, a differing base URL means
// this is a different physical server, not the same account; fall
// through to inserting a new connection even though the apiKey matches.
if (isLocal) {
const existingBaseUrl = normalizeBaseUrlForDedup(
parseProviderSpecificData(row.provider_specific_data)?.baseUrl
);
if (existingBaseUrl !== newBaseUrl) continue;
if (toStringOrNull(decrypted.apiKey)?.trim() === newApiKey) {
existing = row;
break;
}
existing = row;
break;
}
}
} else if (data.authType === "cookie") {

View File

@@ -1,107 +0,0 @@
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-lmstudio-multi-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "lmstudio-multi-test-secret";
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(resetStorage);
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
function connectionId(connection: unknown): unknown {
return (connection as { id?: unknown })?.id;
}
async function apiKeyConnections(provider: string) {
const all = await providersDb.getProviderConnections({});
return (all as Array<Record<string, unknown>>).filter(
(c) => c.provider === provider && c.authType === "apikey"
);
}
// #12173 — two distinct local LM Studio servers (different name, different
// providerSpecificData.baseUrl) that happen to share the same optional API
// key value must NOT collapse into one connection. The apikey-value dedup
// (#3023) was written for hosted providers where the key IS the account
// identity; for local/self-hosted providers the key is optional and users
// commonly reuse the same placeholder value across independent servers that
// are actually distinguished by base URL.
test("two LM Studio connections with different baseUrl but same optional API key stay separate (#12173)", async () => {
const first = await providersDb.createProviderConnection({
provider: "lm-studio",
authType: "apikey",
name: "lmstudio-main",
apiKey: "lm-studio",
providerSpecificData: { baseUrl: "http://localhost:1234/v1" },
});
const second = await providersDb.createProviderConnection({
provider: "lm-studio",
authType: "apikey",
name: "lmstudio-second",
apiKey: "lm-studio",
providerSpecificData: { baseUrl: "http://192.168.1.50:1234/v1" },
});
const conns = await apiKeyConnections("lm-studio");
assert.equal(conns.length, 2, "distinct-baseUrl local connections must not be deduped onto one row");
assert.notEqual(connectionId(second), connectionId(first), "the second add must create a new connection, not overwrite the first");
});
// Same baseUrl + same apiKey for a local provider must still dedup to 1 row
// (re-adding the same server should update, not duplicate).
test("two LM Studio connections with the same baseUrl and same API key still dedup to one row (#12173)", async () => {
await providersDb.createProviderConnection({
provider: "lm-studio",
authType: "apikey",
name: "lmstudio-main",
apiKey: "lm-studio",
providerSpecificData: { baseUrl: "http://localhost:1234/v1" },
});
await providersDb.createProviderConnection({
provider: "lm-studio",
authType: "apikey",
name: "lmstudio-main-renamed",
apiKey: "lm-studio",
providerSpecificData: { baseUrl: "http://localhost:1234/v1/" },
});
const conns = await apiKeyConnections("lm-studio");
assert.equal(conns.length, 1, "re-adding the same local server (same baseUrl, trailing slash aside) must dedup to one row");
});
// Hosted providers (#3023) must keep matching purely on apiKey value —
// no baseUrl carve-out for non-local providers.
test("hosted provider (openai) apiKey-value dedup is unaffected by baseUrl (#12173 regression guard)", async () => {
const first = await providersDb.createProviderConnection({
provider: "openai",
authType: "apikey",
name: "openai-main",
apiKey: "sk-shared-secret",
});
const second = await providersDb.createProviderConnection({
provider: "openai",
authType: "apikey",
name: "openai-second",
apiKey: "sk-shared-secret",
});
const conns = await apiKeyConnections("openai");
assert.equal(conns.length, 1, "hosted-provider apiKey dedup (#3023) must still collapse to one row");
assert.equal(connectionId(second), connectionId(first), "the second add must update the same hosted connection");
});

View File

@@ -0,0 +1,135 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import CompatibleNodeCard from "../../../src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleNodeCard";
const router = vi.hoisted(() => ({
push: vi.fn(),
refresh: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => router,
}));
vi.mock("@/shared/components", () => ({
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Button: ({
children,
onClick,
}: {
children: React.ReactNode;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
}) => <button onClick={onClick}>{children}</button>,
}));
vi.mock("@/shared/components/ProviderIcon", () => ({
default: () => null,
}));
function renderCard(container: HTMLDivElement) {
const root = createRoot(container);
return root;
}
describe("CompatibleNodeCard provider deletion (#12298)", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
router.push.mockClear();
router.refresh.mockClear();
vi.stubGlobal("confirm", vi.fn(() => true));
container = document.createElement("div");
document.body.appendChild(container);
root = renderCard(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
async function clickDelete() {
await act(async () => {
root.render(
<CompatibleNodeCard
providerId="custom-node"
providerNode={{ baseUrl: "https://example.test/v1", apiType: "openai" }}
isCcCompatible={false}
isAnthropicCompatible={false}
isAnthropicProtocolCompatible={false}
gateConnectionFlow={(callback) => callback()}
openApiKeyAddFlow={vi.fn()}
onOpenEditNodeModal={vi.fn()}
t={(key) => key}
/>
);
});
const deleteButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "delete"
);
expect(deleteButton).toBeDefined();
await act(async () => {
deleteButton?.click();
await Promise.resolve();
await Promise.resolve();
});
return deleteButton;
}
it("invalidates the cached providers page after a successful delete and navigation", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true } as Response));
await clickDelete();
expect(fetch).toHaveBeenCalledWith("/api/provider-nodes/custom-node", {
method: "DELETE",
});
expect(router.push).toHaveBeenCalledWith("/dashboard/providers");
expect(router.refresh).toHaveBeenCalledTimes(1);
expect(router.push.mock.invocationCallOrder[0]).toBeLessThan(
router.refresh.mock.invocationCallOrder[0]
);
});
it("does not navigate or refresh when the user cancels the confirm dialog", async () => {
vi.stubGlobal("confirm", vi.fn(() => false));
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true } as Response));
await clickDelete();
expect(fetch).not.toHaveBeenCalled();
expect(router.push).not.toHaveBeenCalled();
expect(router.refresh).not.toHaveBeenCalled();
});
it("does not navigate or refresh when the DELETE response is not ok", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false } as Response));
await clickDelete();
expect(router.push).not.toHaveBeenCalled();
expect(router.refresh).not.toHaveBeenCalled();
});
it("does not navigate or refresh when the DELETE request throws", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down")));
vi.spyOn(console, "error").mockImplementation(() => {});
await clickDelete();
expect(router.push).not.toHaveBeenCalled();
expect(router.refresh).not.toHaveBeenCalled();
});
});