Compare commits

..

2 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza
6b83df1a75 Merge branch 'release/v3.8.50' into fix/11233-lmstudio-embedding-baseurl 2026-08-23 13:18:24 -03:00
Xiangzhe
592a7efc18 fix(embeddings): honor configured LM Studio connection URL via lm-studio alias (#11233)
The dashboard stores LM Studio connections under the hyphenated provider id
"lm-studio", but the embedding registry keys the provider as "lmstudio"
with no alias. As a result, "lm-studio/<model>" embedding requests failed
with a 400 unknown-provider error, and "lmstudio/<model>" requests always
hit the hardcoded http://localhost:1234/v1/embeddings endpoint, ignoring the
baseUrl of the configured connection.

Mirror the ollama-local pattern from #2824/#9225:

- embeddingRegistry: add "lm-studio" -> "lmstudio" to
  EMBEDDING_PROVIDER_ALIASES (registry key unchanged so existing
  "lmstudio/<model>" clients keep working).
- embeddings service: extend the optional keyless-connection hydration to
  lmstudio; getProviderCredentials("lmstudio") already resolves the
  "lm-studio" connection via the provider search pool/alias, and a
  selection/rate-limit failure still proceeds without credentials.
- embeddings handler: apply the same baseUrl override + normalization
  (strip trailing slashes and /v1, /v1/chat/completions, /v1/embeddings
  suffixes, then rebuild <host>/v1/embeddings) to lmstudio, keeping the
  static localhost fallback when no connection or empty baseUrl.

TDD: tests/unit/lmstudio-connection-baseurl-11233.test.ts failed on the
alias, override and service-hydration asserts before the fix and passes
after; ollama-local (#2824) and lmstudio registry (#7601) sibling tests
remain green.
2026-08-23 12:49:59 -03:00
9 changed files with 174 additions and 337 deletions

View File

@@ -22,15 +22,8 @@ const VALID_FORMATS = new Set(["json", "env"]);
const SECURE_FILE_MODE = 0o600;
export function registerAuthExport(program) {
// #11226: `.command("auth export")` does NOT register a two-word command — commander
// parses the bare word `export` as a required positional argument of `auth`, so the
// action received (exportArgValue, options, command) while expecting (options, command)
// and crashed with "cmd.optsWithGlobals is not a function". Register `export` as a
// proper nested subcommand instead; the CLI surface stays `omniroute auth export`.
program
.command("auth")
.description(t("authExport.description"))
.command("export")
.command("auth export")
.description(t("authExport.description"))
.option("--id <id>", t("authExport.idOpt"))
.option("--format <format>", t("authExport.formatOpt"), "json")

View File

@@ -10,10 +10,6 @@ const PROVIDER_TEST_CONFIGS = {
format: "openai",
baseUrl: "https://openrouter.ai/api/v1",
model: "openai/gpt-4o-mini",
// #11226: /models is public on OpenRouter (200 with any or no key) — probe the
// authenticated key-info endpoint instead so a bad key fails the test here
// instead of on the first real chat request.
keyCheckPath: "/auth/key",
},
groq: {
format: "openai",
@@ -105,19 +101,13 @@ async function testOpenAILikeProvider(input, config) {
"Content-Type": "application/json",
};
// Providers whose /models endpoint is public (e.g. OpenRouter) declare a
// keyCheckPath pointing at an authenticated endpoint so the probe actually
// exercises the key instead of the public catalog.
const probeRes = await fetchWithTimeout(
joinUrl(config.baseUrl, config.keyCheckPath || "/models"),
{
method: "GET",
headers,
}
);
const modelsRes = await fetchWithTimeout(joinUrl(config.baseUrl, "/models"), {
method: "GET",
headers,
});
if (probeRes.ok || probeRes.status === 401 || probeRes.status === 403) {
return classifyResponse(probeRes);
if (modelsRes.ok || modelsRes.status === 401 || modelsRes.status === 403) {
return classifyResponse(modelsRes);
}
const chatRes = await fetchWithTimeout(joinUrl(config.baseUrl, "/chat/completions"), {

View File

@@ -413,6 +413,11 @@ export const EMBEDDING_PROVIDERS: Record<string, EmbeddingProvider> = {
const EMBEDDING_PROVIDER_ALIASES: Record<string, string> = {
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/<model>" resolves instead
// of failing with an unknown-provider 400.
"lm-studio": "lmstudio",
};
/** Family name used by clients; Jina's public SKU is omni-small. */

View File

@@ -9,11 +9,6 @@ export const openrouterProvider: RegistryEntry = {
authType: "apikey",
authHeader: "bearer",
defaultContextLength: 128000,
// #11226: OpenRouter's /api/v1/models is PUBLIC (200 with any or no key), so the
// generic /models probe validated every key — even garbage ones — and bad keys
// only surfaced later as upstream 401 "User not found." on real chat traffic.
// /api/v1/auth/key is the authenticated key-info endpoint: 200 = valid, 401 = invalid.
testKeyModelsUrl: "https://openrouter.ai/api/v1/auth/key",
headers: {
"HTTP-Referer": "https://endpoint-proxy.local",
"X-Title": "Endpoint Proxy",

View File

@@ -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<string, unknown>) => Record<string, unknown>) | 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
}

View File

@@ -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 &&

View File

@@ -1,131 +0,0 @@
// #11226 — `omniroute auth export` crashed with "cmd.optsWithGlobals is not a
// function" because the command was registered as `.command("auth export")`:
// commander parses the bare word `export` as a REQUIRED POSITIONAL ARGUMENT, so
// the action received ("export", options, command) while its signature expected
// (options, command) — the classic opts/cmd swap. The fix registers `export` as
// a proper nested subcommand of `auth`, restoring the documented CLI surface
// (docs/reference/CLI-TOOLS.md): `omniroute auth export [--force] [--id] [--format] [--out]`.
//
// These tests exercise the REAL commander wiring via createProgram() — no DB is
// touched on any of these paths (the no-force gate prints and returns before any
// DB access; an invalid --format fails validation before opening the DB).
import test from "node:test";
import assert from "node:assert/strict";
import { createProgram } from "../../bin/cli/program.mjs";
function captureConsole(): { captured: { logs: string[]; errors: string[] }; restore: () => void } {
const originalLog = console.log;
const originalError = console.error;
const captured = { logs: [] as string[], errors: [] as string[] };
console.log = (msg?: unknown) => {
captured.logs.push(String(msg ?? ""));
};
console.error = (msg?: unknown) => {
captured.errors.push(String(msg ?? ""));
};
return {
captured,
restore: () => {
console.log = originalLog;
console.error = originalError;
},
};
}
function stubProcessExit(): { exitCodes: number[]; restore: () => void } {
const originalExit = process.exit;
const exitCodes: number[] = [];
process.exit = ((code?: number) => {
exitCodes.push(code ?? 0);
}) as typeof process.exit;
return {
exitCodes,
restore: () => {
process.exit = originalExit;
},
};
}
test("auth command exposes 'export' as a subcommand, not a positional argument", () => {
const program = createProgram();
const auth = program.commands.find((c) => c.name() === "auth");
assert.ok(auth, "auth command exists");
const exportCmd = auth.commands.find((c) => c.name() === "export");
assert.ok(exportCmd, "export must be a nested subcommand of auth");
const registeredArgs = (auth as unknown as { registeredArguments?: unknown[] })
.registeredArguments;
assert.equal(
registeredArgs?.length ?? 0,
0,
"auth must not declare positional arguments (a bare word in .command() becomes one)"
);
});
test("auth export action receives (options, command): flags reach the handler end-to-end", async () => {
const program = createProgram();
const exitStub = stubProcessExit();
const { captured, restore } = captureConsole();
try {
// --format bogus makes runAuthExportCommand return 1 BEFORE any DB access;
// the action must then call process.exit(1). With the opts/cmd swap this
// parse rejects with "cmd.optsWithGlobals is not a function" instead.
await program.parseAsync([
"node",
"omniroute",
"auth",
"export",
"--force",
"--format",
"bogus",
]);
} finally {
restore();
exitStub.restore();
}
assert.deepEqual(
exitStub.exitCodes,
[1],
"handler must receive --format and exit 1 on bogus value"
);
assert.ok(
captured.errors.join("\n").includes("Invalid format"),
`expected the invalid-format error, got: ${captured.errors.join(" | ")}`
);
});
test("auth export without --force prints the confirmation gate (no crash, no DB)", async () => {
const program = createProgram();
const exitStub = stubProcessExit();
const { captured, restore } = captureConsole();
try {
await program.parseAsync(["node", "omniroute", "auth", "export"]);
} finally {
restore();
exitStub.restore();
}
assert.deepEqual(exitStub.exitCodes, [], "dry run exits 0 without calling process.exit");
assert.ok(
captured.logs.join("\n").includes("DECRYPTED"),
`expected the confirmation gate, got: ${captured.logs.join(" | ")}`
);
});
test("auth rejects an unknown positional (was silently accepted as the 'export' argument)", async () => {
const program = createProgram();
await assert.rejects(
program.parseAsync(["node", "omniroute", "auth", "bogus-word"]),
(err: unknown) => {
assert.ok(err instanceof Error);
assert.match(
(err as { code?: string }).code || "",
/commander\.(unknownCommand|helpDisplayed)/
);
return true;
}
);
});

View File

@@ -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/<model>" embedding requests failed with 400 unknown provider.
// 2. "lmstudio/<model>" 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<string, string> } | null = null;
globalThis.fetch = async (url, options = {}) => {
captured = {
url: String(url),
headers: (options.headers as Record<string, string>) || {},
};
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);
});

View File

@@ -1,152 +0,0 @@
// #11226 — OpenRouter key validation was vacuous: the probe targeted the PUBLIC
// /api/v1/models endpoint, which answers 200 to any key (or no key at all), so a
// bad key was saved as "valid" and only failed later on real chat traffic with the
// upstream 401 "User not found.". The authenticated key-info endpoint
// (/api/v1/auth/key) is the correct probe: 200 = valid, 401 = invalid.
//
// The fetch stubs below mimic the REAL OpenRouter behavior verified live:
// GET /api/v1/models → 200 without any auth (public catalog)
// GET /api/v1/auth/key → 401 {"error":{"message":"User not found.","code":401}} for a bad key
import { describe, it } from "node:test";
import assert from "node:assert/strict";
const { getRegistryEntry } = await import("../../open-sse/config/providerRegistry.ts");
const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts");
const { testProviderApiKey } = await import("../../bin/cli/provider-test.mjs");
const AUTH_KEY_URL = "https://openrouter.ai/api/v1/auth/key";
const PUBLIC_MODELS_URL = "https://openrouter.ai/api/v1/models";
const BAD_KEY = "sk-or-v1-definitely-invalid-key";
const GOOD_KEY = "sk-or-v1-valid-key";
interface RecordedCall {
url: string;
authorization: string | null;
}
/**
* Stub fetch with the real OpenRouter behavior: /models is public (always 200),
* /auth/key requires a valid bearer (401 "User not found." otherwise).
*/
function stubRealOpenRouter() {
const calls: RecordedCall[] = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const headers = new Headers(
init?.headers ?? (input instanceof Request ? input.headers : undefined)
);
calls.push({ url, authorization: headers.get("authorization") });
if (url.startsWith(AUTH_KEY_URL)) {
const bearer = headers.get("authorization") || "";
if (bearer === `Bearer ${GOOD_KEY}`) {
return new Response(JSON.stringify({ data: { label: "ok", is_free_tier: false } }), {
status: 200,
});
}
return new Response(JSON.stringify({ error: { message: "User not found.", code: 401 } }), {
status: 401,
});
}
if (url.includes("/models")) {
// Public catalog — answers 200 regardless of the Authorization header.
return new Response(JSON.stringify({ data: [] }), { status: 200 });
}
return new Response("{}", { status: 404 });
}) as typeof fetch;
return {
calls,
restore: () => {
globalThis.fetch = originalFetch;
},
};
}
describe("openrouter registry — authenticated key-validation endpoint (#11226)", () => {
it("declares the authenticated /auth/key probe as its key-test endpoint", () => {
const entry = getRegistryEntry("openrouter");
assert.ok(entry, "openrouter must be registered in the execution registry");
assert.equal(entry.testKeyModelsUrl, AUTH_KEY_URL);
});
it("marks a bad key INVALID even though the public /models endpoint answers 200", async () => {
const stub = stubRealOpenRouter();
try {
const result = await validateProviderApiKey({ provider: "openrouter", apiKey: BAD_KEY });
assert.equal(result.valid, false, "bad key must not validate against the public catalog");
assert.equal(result.error, "Invalid API key");
assert.deepEqual(
stub.calls.map((c) => c.url),
[AUTH_KEY_URL],
"must probe the authenticated key endpoint, not the public /models"
);
assert.equal(stub.calls[0].authorization, `Bearer ${BAD_KEY}`);
} finally {
stub.restore();
}
});
it("marks a good key VALID via /auth/key and never falls back to the chat probe", async () => {
const stub = stubRealOpenRouter();
try {
const result = await validateProviderApiKey({ provider: "openrouter", apiKey: GOOD_KEY });
assert.equal(result.valid, true);
assert.equal(result.error, null);
assert.deepEqual(
stub.calls.map((c) => c.url),
[AUTH_KEY_URL]
);
} finally {
stub.restore();
}
});
});
describe("omniroute providers test — openrouter probe (#11226)", () => {
it("marks a bad key INVALID even though the public /models endpoint answers 200", async () => {
const stub = stubRealOpenRouter();
try {
const result = await testProviderApiKey({ provider: "openrouter", apiKey: BAD_KEY });
assert.equal(result.valid, false, "CLI test must not trust the public /models endpoint");
assert.equal(result.error, "Invalid API key");
assert.deepEqual(
stub.calls.map((c) => c.url),
[AUTH_KEY_URL]
);
} finally {
stub.restore();
}
});
it("marks a good key VALID via /auth/key", async () => {
const stub = stubRealOpenRouter();
try {
const result = await testProviderApiKey({ provider: "openrouter", apiKey: GOOD_KEY });
assert.equal(result.valid, true);
assert.equal(result.error, null);
assert.deepEqual(
stub.calls.map((c) => c.url),
[AUTH_KEY_URL]
);
} finally {
stub.restore();
}
});
it("does not change the probe for other OpenAI-like providers (openai still uses /models)", async () => {
const stub = stubRealOpenRouter();
try {
const result = await testProviderApiKey({ provider: "openai", apiKey: GOOD_KEY });
assert.equal(result.valid, true);
assert.deepEqual(
stub.calls.map((c) => c.url),
["https://api.openai.com/v1/models"]
);
assert.ok(!stub.calls.some((c) => c.url === PUBLIC_MODELS_URL));
} finally {
stub.restore();
}
});
});