diff --git a/changelog.d/fixes/12173-lmstudio-multi-account.md b/changelog.d/fixes/12173-lmstudio-multi-account.md new file mode 100644 index 0000000000..848be79448 --- /dev/null +++ b/changelog.d/fixes/12173-lmstudio-multi-account.md @@ -0,0 +1 @@ +- fix(db): scope local-provider apiKey dedup to matching base URL so LM Studio/Ollama-style connections support multiple accounts (#12173) diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index 1cb3f5a73a..01c53ec443 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -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") { diff --git a/tests/unit/provider-connection-local-baseurl-dedup.test.ts b/tests/unit/provider-connection-local-baseurl-dedup.test.ts new file mode 100644 index 0000000000..e801c9594d --- /dev/null +++ b/tests/unit/provider-connection-local-baseurl-dedup.test.ts @@ -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>).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"); +});