mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-11 01:13:02 +03:00
Compare commits
1 Commits
fix/12783-
...
fix/12173-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e6bddbc7b |
1
changelog.d/fixes/12173-lmstudio-multi-account.md
Normal file
1
changelog.d/fixes/12173-lmstudio-multi-account.md
Normal 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)
|
||||
@@ -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") {
|
||||
|
||||
107
tests/unit/provider-connection-local-baseurl-dedup.test.ts
Normal file
107
tests/unit/provider-connection-local-baseurl-dedup.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user