Compare commits

...

1 Commits

Author SHA1 Message Date
diegosouzapw
8e6bddbc7b fix(db): scope local-provider apiKey dedup to base URL for multi-account LM Studio (#12173)
The #3023 apiKey-value dedup in createProviderConnection() matched purely on
provider + apiKey, correct for hosted providers where the key alone is the
account identity. Local/self-hosted providers (LM Studio, Ollama, vLLM,
llama.cpp, ...) commonly carry an optional/cosmetic API key, so reusing the
same placeholder value across two physically distinct servers made the
second add silently overwrite the first connection's baseUrl instead of
creating a second one.

Scope the dedup to also require a matching (trimmed, trailing-slash
insensitive) providerSpecificData.baseUrl for local/self-hosted providers
only (LOCAL_PROVIDERS catalog); hosted-provider dedup is unchanged.
2026-09-10 14:47:00 -03:00
3 changed files with 144 additions and 3 deletions

View File

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

View File

@@ -35,6 +35,7 @@ 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";
@@ -417,6 +418,28 @@ 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,
@@ -551,15 +574,25 @@ 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) {
existing = row;
break;
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;
}
existing = row;
break;
}
}
} else if (data.authType === "cookie") {

View File

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