diff --git a/open-sse/config/embeddingRegistry.ts b/open-sse/config/embeddingRegistry.ts index e907e32509..85758e4535 100644 --- a/open-sse/config/embeddingRegistry.ts +++ b/open-sse/config/embeddingRegistry.ts @@ -413,6 +413,11 @@ export const EMBEDDING_PROVIDERS: Record = { const EMBEDDING_PROVIDER_ALIASES: Record = { jina: "jina-ai", voyage: "voyage-ai", + // The dashboard stores LM Studio connections under the hyphenated provider + // id "lm-studio" while the embedding registry keys the provider "lmstudio" + // (#11233). Alias the dashboard id so "lm-studio/" resolves instead + // of failing with an unknown-provider 400. + "lm-studio": "lmstudio", }; /** Family name used by clients; Jina's public SKU is omni-small. */ diff --git a/open-sse/handlers/embeddings.ts b/open-sse/handlers/embeddings.ts index df9fe26283..7cf322610d 100644 --- a/open-sse/handlers/embeddings.ts +++ b/open-sse/handlers/embeddings.ts @@ -182,12 +182,8 @@ export async function handleEmbedding({ ) : []; const nativeModalities = [ - ...(isJinaNativeEmbeddingInput(body.input) - ? collectJinaNativeModalities(body.input) - : []), - ...(isGeminiNativeEmbeddingInput(body.input) - ? collectGeminiNativeModalities(body.input) - : []), + ...(isJinaNativeEmbeddingInput(body.input) ? collectJinaNativeModalities(body.input) : []), + ...(isGeminiNativeEmbeddingInput(body.input) ? collectGeminiNativeModalities(body.input) : []), ].filter((modality) => modality !== "text"); if (structuredItems.length > 0 || nativeModalities.length > 0) { const supportedModalities = getEmbeddingModelModalities(providerConfig, model); @@ -266,7 +262,10 @@ export async function handleEmbedding({ } let upstreamUrl = providerConfig.baseUrl; - if (provider === "ollama-local") { + if (provider === "ollama-local" || provider === "lmstudio") { + // Keyless local servers (#2824 ollama-local, #11233 lmstudio): honor the + // configured connection's baseUrl when one was hydrated, and fall back to + // the static localhost registry default otherwise. const configuredBaseUrl = credentials?.providerSpecificData?.baseUrl; const rawBaseUrl = typeof configuredBaseUrl === "string" && configuredBaseUrl.trim().length > 0 @@ -277,11 +276,11 @@ export async function handleEmbedding({ // (CodeQL js/polynomial-redos) since baseUrl is operator-configured // per-connection data. See open-sse/utils/urlSanitize.ts. const normalizedBaseUrl = stripTrailingSlashes(rawBaseUrl.trim()); - const ollamaHost = normalizedBaseUrl + const localServerHost = normalizedBaseUrl .replace(/\/v1\/(?:chat\/completions|embeddings)$/i, "") .replace(/\/api\/chat$/i, "") .replace(/\/v1$/i, ""); - upstreamUrl = `${ollamaHost}/v1/embeddings`; + upstreamUrl = `${localServerHost}/v1/embeddings`; } let normalizeProviderResponse: ((data: Record) => Record) | null = null; @@ -321,10 +320,7 @@ export async function handleEmbedding({ // become N embeddings. Native multimodal parts take the same path. const useGeminiNativeTransport = providerConfig.structuredInputProtocol === "gemini-embed-content" && - (isGeminiEmbedding2Family(model) || - canonicalStructured || - geminiNative || - jinaNative); + (isGeminiEmbedding2Family(model) || canonicalStructured || geminiNative || jinaNative); if (providerConfig.structuredInputProtocol === "jina-v1" && jinaNative && canonicalStructured) { try { @@ -462,13 +458,7 @@ export async function handleEmbedding({ // best-effort. if (connectionId) { try { - await markAccountUnavailable( - connectionId, - response.status, - errorText, - provider, - model - ); + await markAccountUnavailable(connectionId, response.status, errorText, provider, model); } catch { // swallow — the upstream error response takes priority } diff --git a/src/lib/embeddings/service.ts b/src/lib/embeddings/service.ts index 3341de2899..dd399396bb 100644 --- a/src/lib/embeddings/service.ts +++ b/src/lib/embeddings/service.ts @@ -249,11 +249,14 @@ export async function createEmbeddingResponse( `[${provider}] All ${credentials.expiredCount || 1} connection(s) authentication expired — please reconnect in the dashboard` ); } - } else if (provider === "ollama-local") { - // Ollama is keyless, but a configured connection can still provide a - // custom local host. Hydrate that optional connection without imposing an - // authentication requirement, then keep the static localhost default when - // no connection exists. + } else if (provider === "ollama-local" || provider === "lmstudio") { + // Ollama and LM Studio are keyless, but a configured connection can still + // provide a custom local host. Hydrate that optional connection without + // imposing an authentication requirement, then keep the static localhost + // default when no connection exists. getProviderCredentials("lmstudio") + // resolves the dashboard's hyphenated "lm-studio" connection via the + // provider search pool/alias (#11233); a selection or rate-limit failure + // must not break the flow — proceed without credentials. const localCredentials = await getProviderCredentials(credentialsProviderId); if ( localCredentials && diff --git a/tests/unit/lmstudio-connection-baseurl-11233.test.ts b/tests/unit/lmstudio-connection-baseurl-11233.test.ts new file mode 100644 index 0000000000..78af8690e4 --- /dev/null +++ b/tests/unit/lmstudio-connection-baseurl-11233.test.ts @@ -0,0 +1,144 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const TEST_DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-lmstudio-embedding-11233-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { parseEmbeddingModel } = await import("../../open-sse/config/embeddingRegistry.ts"); +const { handleEmbedding } = await import("../../open-sse/handlers/embeddings.ts"); +const core = await import("../../src/lib/db/core.ts"); +const { createProviderConnection } = await import("../../src/lib/db/providers.ts"); +const { createEmbeddingResponse } = await import("../../src/lib/embeddings/service.ts"); + +test.after(() => { + core.resetDbInstance(); + rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// Issue #11233: the dashboard stores LM Studio connections under the provider +// id "lm-studio" (hyphenated), but the embedding registry keys the provider as +// "lmstudio" with no alias. Two symptoms resulted: +// 1. "lm-studio/" embedding requests failed with 400 unknown provider. +// 2. "lmstudio/" requests always hit the hardcoded localhost:1234 +// endpoint, ignoring the baseUrl of the configured connection. +// The fix mirrors the ollama-local pattern from #2824/#9225: an embedding +// provider alias plus optional (non-auth) connection hydration and the same +// baseUrl normalization in the handler. + +test("lm-studio model strings resolve to the lmstudio embedding provider", () => { + assert.deepEqual(parseEmbeddingModel("lm-studio/nomic-embed-text"), { + provider: "lmstudio", + model: "nomic-embed-text", + }); +}); + +test("lmstudio routes to the configured connection baseUrl", async () => { + const originalFetch = globalThis.fetch; + let capturedUrl: string | null = null; + globalThis.fetch = async (url) => { + capturedUrl = String(url); + return new Response( + JSON.stringify({ + data: [{ object: "embedding", embedding: [0.1, 0.2], index: 0 }], + usage: { prompt_tokens: 2, total_tokens: 2 }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + try { + const result = await handleEmbedding({ + body: { model: "lmstudio/nomic-embed-text", input: "hello" }, + resolvedProvider: { + id: "lmstudio", + baseUrl: "http://localhost:1234/v1/embeddings", + authType: "none", + authHeader: "none", + models: [], + }, + resolvedModel: "nomic-embed-text", + credentials: { + providerSpecificData: { baseUrl: "http://192.168.1.50:1234/v1" }, + }, + log: null, + }); + + assert.equal(result.success, true); + } finally { + globalThis.fetch = originalFetch; + } + + assert.equal(capturedUrl, "http://192.168.1.50:1234/v1/embeddings"); +}); + +test("lmstudio keeps the static localhost default without credentials", async () => { + const originalFetch = globalThis.fetch; + let capturedUrl: string | null = null; + globalThis.fetch = async (url) => { + capturedUrl = String(url); + return new Response( + JSON.stringify({ + data: [{ object: "embedding", embedding: [0.3, 0.4], index: 0 }], + usage: { prompt_tokens: 2, total_tokens: 2 }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + try { + const result = await handleEmbedding({ + body: { model: "lmstudio/nomic-embed-text", input: "hello" }, + credentials: null, + log: null, + }); + + assert.equal(result.success, true); + } finally { + globalThis.fetch = originalFetch; + } + + assert.equal(capturedUrl, "http://localhost:1234/v1/embeddings"); +}); + +test("lmstudio service hydrates the lm-studio connection host without requiring a key", async () => { + await createProviderConnection({ + provider: "lm-studio", + authType: "none", + name: "LAN LM Studio", + isActive: true, + providerSpecificData: { baseUrl: "http://10.20.0.60:1234/v1/" }, + }); + + const originalFetch = globalThis.fetch; + let captured: { url: string; headers: Record } | null = null; + globalThis.fetch = async (url, options = {}) => { + captured = { + url: String(url), + headers: (options.headers as Record) || {}, + }; + return new Response( + JSON.stringify({ + data: [{ object: "embedding", embedding: [0.5, 0.6], index: 0 }], + usage: { prompt_tokens: 2, total_tokens: 2 }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + try { + const response = await createEmbeddingResponse({ + model: "lm-studio/nomic-embed-text", + input: "hello", + }); + assert.equal(response.status, 200); + } finally { + globalThis.fetch = originalFetch; + } + + assert.ok(captured); + assert.equal(captured.url, "http://10.20.0.60:1234/v1/embeddings"); + assert.equal(captured.headers.Authorization, undefined); +});