From 968cbfeb156951b64767e01bcd79a2cbc81bede0 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 23 Apr 2026 16:55:44 -0300 Subject: [PATCH] fix(providers): support local OpenAI-style endpoints without auth Treat LM Studio, vLLM, Llamafile, Triton, Docker Model Runner, XInference, and oobabooga as managed local providers with default base URLs and passthrough model discovery. Avoid sending empty bearer headers during validation, model discovery, and execution so self-hosted endpoints work without API keys while still honoring custom base URLs and localized dashboard hints. --- open-sse/config/providerRegistry.ts | 67 ++++++++++ open-sse/executors/default.ts | 27 +++- .../dashboard/providers/[id]/page.tsx | 75 +++++++++-- src/app/api/providers/[id]/models/route.ts | 36 ++++-- src/i18n/messages/en.json | 4 +- src/i18n/messages/pt-BR.json | 4 +- src/lib/providers/validation.ts | 17 +-- src/shared/constants/providers.ts | 121 ++++++++++++++++-- src/shared/validation/schemas.ts | 5 +- tests/unit/executor-default-base.test.ts | 14 ++ tests/unit/provider-models-route.test.ts | 26 ++++ .../provider-validation-specialty.test.ts | 37 ++++++ tests/unit/providers-page-utils.test.ts | 25 ++++ .../providers-route-managed-catalog.test.ts | 70 ++++++++++ 14 files changed, 486 insertions(+), 42 deletions(-) diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 7e3ef2080b..3d6c5caf5d 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -1248,6 +1248,73 @@ export const REGISTRY: Record = { authType: "apikey", // Some setups may use proxy with api key authHeader: "bearer", models: [], // Usually dynamic based on local models downloaded + passthroughModels: true, + }, + vllm: { + id: "vllm", + alias: "vllm", + format: "openai", + executor: "default", + baseUrl: "http://localhost:8000/v1", + authType: "apikey", + authHeader: "bearer", + models: [], + passthroughModels: true, + }, + llamafile: { + id: "llamafile", + alias: "llamafile", + format: "openai", + executor: "default", + baseUrl: "http://127.0.0.1:8080/v1", + authType: "apikey", + authHeader: "bearer", + models: [], + passthroughModels: true, + }, + triton: { + id: "triton", + alias: "triton", + format: "openai", + executor: "default", + baseUrl: "http://localhost:8000/v1", + authType: "apikey", + authHeader: "bearer", + models: [], + passthroughModels: true, + }, + "docker-model-runner": { + id: "docker-model-runner", + alias: "dmr", + format: "openai", + executor: "default", + baseUrl: "http://localhost:12434/v1", + authType: "apikey", + authHeader: "bearer", + models: [], + passthroughModels: true, + }, + xinference: { + id: "xinference", + alias: "xinference", + format: "openai", + executor: "default", + baseUrl: "http://localhost:9997/v1", + authType: "apikey", + authHeader: "bearer", + models: [], + passthroughModels: true, + }, + oobabooga: { + id: "oobabooga", + alias: "ooba", + format: "openai", + executor: "default", + baseUrl: "http://localhost:5000/v1", + authType: "apikey", + authHeader: "bearer", + models: [], + passthroughModels: true, }, "ollama-cloud": { diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 25327f7518..2ff1d9e3d2 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -51,6 +51,18 @@ function normalizeGigachatChatUrl(baseUrl) { return `${normalized}/chat/completions`; } +function normalizeOpenAIChatUrl(baseUrl) { + const normalized = normalizeBaseUrl(baseUrl); + if ( + normalized.endsWith("/chat/completions") || + normalized.endsWith("/responses") || + normalized.endsWith("/chat") + ) { + return normalized; + } + return normalized.endsWith("/v1") ? `${normalized}/chat/completions` : normalized; +} + export class DefaultExecutor extends BaseExecutor { constructor(provider) { super(provider, PROVIDERS[provider] || PROVIDERS.openai); @@ -110,6 +122,16 @@ export class DefaultExecutor extends BaseExecutor { const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; return normalizeGigachatChatUrl(baseUrl); } + case "lm-studio": + case "vllm": + case "llamafile": + case "triton": + case "docker-model-runner": + case "xinference": + case "oobabooga": { + const baseUrl = credentials?.providerSpecificData?.baseUrl || this.config.baseUrl; + return normalizeOpenAIChatUrl(baseUrl); + } case "claude": case "glm": case "glmt": @@ -189,7 +211,10 @@ export class DefaultExecutor extends BaseExecutor { headers["anthropic-version"] = "2023-06-01"; } } else { - headers["Authorization"] = `Bearer ${effectiveKey || credentials.accessToken}`; + const bearerToken = effectiveKey || credentials.accessToken; + if (bearerToken) { + headers["Authorization"] = `Bearer ${bearerToken}`; + } } } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 2c1940090b..988c21933f 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -23,10 +23,12 @@ import { ProxyConfigModal, } from "@/shared/components"; import { + LOCAL_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, isClaudeCodeCompatibleProvider, + isSelfHostedChatProvider, supportsApiKeyOnFreeProvider, } from "@/shared/constants/providers"; import { getModelsByProviderId } from "@/shared/constants/models"; @@ -89,6 +91,11 @@ type CompatModelRow = { }; type CompatModelMap = Map; +type LocalProviderMetadata = { + name?: string; + localDefault?: string; + [key: string]: unknown; +}; function buildCompatMap(rows: CompatModelRow[]): CompatModelMap { const m = new Map(); @@ -5338,11 +5345,37 @@ const DEFAULT_PROVIDER_BASE_URLS: Record = { "searxng-search": "http://localhost:8888/search", }; +function getLocalProviderMetadata(providerId?: string | null) { + if (!providerId || !isSelfHostedChatProvider(providerId)) return null; + return (LOCAL_PROVIDERS as Record)[providerId] || null; +} + +function isBaseUrlConfigurableProvider(providerId?: string | null) { + return Boolean( + providerId && + (CONFIGURABLE_BASE_URL_PROVIDERS.has(providerId) || isSelfHostedChatProvider(providerId)) + ); +} + function getProviderBaseUrlDefault(providerId?: string | null) { + const localProvider = getLocalProviderMetadata(providerId); + if (typeof localProvider?.localDefault === "string" && localProvider.localDefault.trim()) { + return localProvider.localDefault; + } return providerId ? DEFAULT_PROVIDER_BASE_URLS[providerId] || "" : ""; } -function getProviderBaseUrlHint(providerId?: string | null, t?: (key: string) => string) { +function getProviderBaseUrlHint( + providerId?: string | null, + t?: ((key: string, values?: Record) => string) | null +) { + const localProvider = getLocalProviderMetadata(providerId); + if (localProvider && t) { + return t("localProviderBaseUrlHint", { + provider: localProvider.name || providerId, + baseUrl: getProviderBaseUrlDefault(providerId), + }); + } switch (providerId) { case "azure-openai": return t ? t("azureOpenAiBaseUrlHint") : undefined; @@ -5364,6 +5397,9 @@ function getProviderBaseUrlHint(providerId?: string | null, t?: (key: string) => } function getProviderBaseUrlPlaceholder(providerId?: string | null) { + if (isSelfHostedChatProvider(providerId || "")) { + return getProviderBaseUrlDefault(providerId); + } switch (providerId) { case "azure-openai": return "https://my-resource.openai.azure.com"; @@ -5434,13 +5470,15 @@ function AddApiKeyModal({ onClose, }: AddApiKeyModalProps) { const t = useTranslations("providers"); - const usesBaseUrl = CONFIGURABLE_BASE_URL_PROVIDERS.has(provider || ""); + const usesBaseUrl = isBaseUrlConfigurableProvider(provider); const defaultBaseUrl = getProviderBaseUrlDefault(provider); const isVertex = provider === "vertex" || provider === "vertex-partner"; const defaultRegion = "us-central1"; const isGlm = provider === "glm" || provider === "glmt"; const isQoder = provider === "qoder"; const isCloudflare = provider === "cloudflare-ai"; + const localProviderMetadata = getLocalProviderMetadata(provider); + const isLocalSelfHostedProvider = !!localProviderMetadata; const isSearxng = provider === "searxng-search"; const isGooglePse = provider === "google-pse-search"; const isGrokWeb = provider === "grok-web"; @@ -5448,7 +5486,7 @@ function AddApiKeyModal({ const isBlackboxWeb = provider === "blackbox-web"; const isMuseSparkWeb = provider === "muse-spark-web"; const isWebSessionProvider = isGrokWeb || isPerplexityWeb || isBlackboxWeb || isMuseSparkWeb; - const apiKeyOptional = isSearxng; + const apiKeyOptional = isSearxng || isLocalSelfHostedProvider; const [formData, setFormData] = useState({ name: "", @@ -5476,7 +5514,7 @@ function AddApiKeyModal({ ? t("personalAccessTokenLabel") : isWebSessionProvider ? t("sessionCookieLabel") - : isSearxng + : apiKeyOptional ? `${t("apiKeyLabel")} (${t("optional").toLowerCase()})` : t("apiKeyLabel"); const apiCredentialPlaceholder = isVertex @@ -5491,7 +5529,7 @@ function AddApiKeyModal({ ? t("museSparkWebCookiePlaceholder") : isQoder ? t("qoderPatPlaceholder") - : isSearxng + : apiKeyOptional ? t("optional") : undefined; const apiCredentialHint = isQoder @@ -5504,9 +5542,13 @@ function AddApiKeyModal({ ? t("blackboxWebCookieHint") : isMuseSparkWeb ? t("museSparkWebCookieHint") - : isSearxng - ? t("apiKeyOptionalHint") - : undefined; + : isLocalSelfHostedProvider + ? t("localProviderApiKeyOptionalHint", { + provider: localProviderMetadata?.name || providerName || provider || "", + }) + : isSearxng + ? t("apiKeyOptionalHint") + : undefined; const handleValidate = async () => { setValidating(true); @@ -5922,18 +5964,27 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec const { emailsVisible: showEmail, toggleEmailVisibility: toggleShowEmail } = useEmailPrivacyStore(); - const usesBaseUrl = CONFIGURABLE_BASE_URL_PROVIDERS.has(connection?.provider || ""); + const usesBaseUrl = isBaseUrlConfigurableProvider(connection?.provider); const defaultBaseUrl = getProviderBaseUrlDefault(connection?.provider); const isVertex = connection?.provider === "vertex" || connection?.provider === "vertex-partner"; const isGlm = connection?.provider === "glm" || connection?.provider === "glmt"; const isCloudflare = connection?.provider === "cloudflare-ai"; const isCodex = connection?.provider === "codex"; const isClaude = connection?.provider === "claude"; + const localProviderMetadata = getLocalProviderMetadata(connection?.provider); + const isLocalSelfHostedProvider = !!localProviderMetadata; const isSearxng = connection?.provider === "searxng-search"; const isGooglePse = connection?.provider === "google-pse-search"; - const apiKeyOptional = isSearxng; + const apiKeyOptional = isSearxng || isLocalSelfHostedProvider; const isCcCompatible = isClaudeCodeCompatibleProvider(connection?.provider); const defaultRegion = "us-central1"; + const apiCredentialHint = isLocalSelfHostedProvider + ? t("localProviderApiKeyOptionalHint", { + provider: localProviderMetadata?.name || connection?.provider || "", + }) + : isSearxng + ? t("apiKeyOptionalHint") + : t("leaveBlankKeepCurrentApiKey"); useEffect(() => { if (connection) { @@ -6361,12 +6412,12 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec <>
setFormData({ ...formData, apiKey: e.target.value })} placeholder={isVertex ? t("vertexServiceAccountPlaceholder") : t("enterNewApiKey")} - hint={isSearxng ? t("apiKeyOptionalHint") : t("leaveBlankKeepCurrentApiKey")} + hint={apiCredentialHint} className="flex-1" />
diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 12ba06e398..700902ab1f 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -1,9 +1,11 @@ import { NextResponse } from "next/server"; import { isClaudeCodeCompatibleProvider, - isOpenAICompatibleProvider, isAnthropicCompatibleProvider, + isOpenAICompatibleProvider, + isSelfHostedChatProvider, } from "@/shared/constants/providers"; +import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts"; import { PROVIDER_MODELS } from "@/shared/constants/models"; import { getProviderConnectionById, @@ -51,6 +53,17 @@ function getProviderBaseUrl(providerSpecificData: unknown): string | null { return typeof baseUrl === "string" && baseUrl.trim().length > 0 ? baseUrl : null; } +function isLocalOpenAIStyleProvider(provider: string): boolean { + return isSelfHostedChatProvider(provider); +} + +function buildOptionalBearerHeaders(token: string | null | undefined): Record { + return { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; +} + function normalizeAntigravityModelsResponse(data: unknown): Array<{ id: string; name: string }> { const payload = asRecord(data).models; @@ -610,14 +623,19 @@ export async function GET( }); }; - if (isOpenAICompatibleProvider(provider)) { + if (isOpenAICompatibleProvider(provider) || isLocalOpenAIStyleProvider(provider)) { const cachedResponse = maybeReturnCachedDiscovery(); if (cachedResponse) return cachedResponse; const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled(); if (autoFetchDisabledResponse) return autoFetchDisabledResponse; - const baseUrl = getProviderBaseUrl(connection.providerSpecificData); + const registryEntry = isLocalOpenAIStyleProvider(provider) + ? getRegistryEntry(provider) + : null; + const baseUrl = + getProviderBaseUrl(connection.providerSpecificData) || + (typeof registryEntry?.baseUrl === "string" ? registryEntry.baseUrl : null); if (!baseUrl) { const fallback = buildDiscoveryFallbackResponse({ cacheWarning: "Base URL unavailable — using cached catalog", @@ -625,7 +643,11 @@ export async function GET( }); if (fallback) return fallback; return NextResponse.json( - { error: "No base URL configured for OpenAI compatible provider" }, + { + error: isOpenAICompatibleProvider(provider) + ? "No base URL configured for OpenAI compatible provider" + : "No base URL configured for local provider", + }, { status: 400 } ); } @@ -650,6 +672,7 @@ export async function GET( const uniqueEndpoints = [...new Set(endpoints)]; let models = null; let lastErrorStatus = null; + const token = apiKey || accessToken; for (const modelsUrl of uniqueEndpoints) { try { @@ -658,10 +681,7 @@ export async function GET( guard: getProviderOutboundGuard(), proxyConfig: proxy, method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, + headers: buildOptionalBearerHeaders(token), }); if (response.ok) { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 876c894ce8..a6e8c99fdd 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2149,6 +2149,7 @@ "museSparkWebCookieHint": "Paste the abra_sess cookie from meta.ai. A full cookie header also works.", "apiKeyOptionalLabel": "API Key (optional)", "apiKeyOptionalHint": "Optional. Leave blank if your SearXNG instance does not require authentication.", + "localProviderApiKeyOptionalHint": "Optional. Leave blank if your {provider} endpoint does not require authentication.", "ccCompatibleNamePlaceholder": "CC Compatible Production", "ccCompatibleNameHint": "Display name for this provider.", "ccCompatiblePrefixPlaceholder": "cc", @@ -2164,7 +2165,8 @@ "herokuBaseUrlHint": "Required: paste the Heroku Inference base URL. The app will append /v1/chat/completions.", "databricksBaseUrlHint": "Required: paste the Databricks serving-endpoints base URL. The app will append /chat/completions.", "snowflakeBaseUrlHint": "Required: paste the Snowflake account base URL. The app will append /api/v2/cortex/inference:complete.", - "searxngBaseUrlHint": "Required: paste your SearXNG instance base URL. The app will use /search and request format=json. Local/private URLs require OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS=true for dashboard validation." + "searxngBaseUrlHint": "Required: paste your SearXNG instance base URL. The app will use /search and request format=json. Local/private URLs require OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS=true for dashboard validation.", + "localProviderBaseUrlHint": "Required: paste your {provider} OpenAI-compatible /v1 base URL (default: {baseUrl}). Local/private URLs require OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS=true for dashboard validation." }, "settings": { "title": "Settings", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index b959961274..fac3e3cb86 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -2150,6 +2150,7 @@ "museSparkWebCookieHint": "Cole o cookie abra_sess do meta.ai. Um cabeçalho de cookie completo também funciona.", "apiKeyOptionalLabel": "Chave de API (opcional)", "apiKeyOptionalHint": "Opcional. Deixe em branco se sua instância SearXNG não exigir autenticação.", + "localProviderApiKeyOptionalHint": "Opcional. Deixe em branco se o endpoint {provider} não exigir autenticação.", "ccCompatibleNamePlaceholder": "Produção CC Compatível", "ccCompatibleNameHint": "Nome de exibição para este provedor.", "ccCompatiblePrefixPlaceholder": "cc", @@ -2165,7 +2166,8 @@ "herokuBaseUrlHint": "Obrigatório: cole a URL base do Heroku Inference. O app adicionará /v1/chat/completions.", "databricksBaseUrlHint": "Obrigatório: cole a URL base do serving-endpoints do Databricks. O app adicionará /chat/completions.", "snowflakeBaseUrlHint": "Obrigatório: cole a URL base da conta Snowflake. O app adicionará /api/v2/cortex/inference:complete.", - "searxngBaseUrlHint": "Obrigatório: cole a URL base da sua instância SearXNG. O app usará /search e request format=json. URLs locais/privadas exigem OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS=true para validação no dashboard." + "searxngBaseUrlHint": "Obrigatório: cole a URL base da sua instância SearXNG. O app usará /search e request format=json. URLs locais/privadas exigem OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS=true para validação no dashboard.", + "localProviderBaseUrlHint": "Obrigatório: cole a URL base OpenAI-compatible /v1 do seu {provider} (padrão: {baseUrl}). URLs locais/privadas exigem OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS=true para validação no dashboard." }, "settings": { "title": "Configurações", diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index f9223ca263..dd038dacf2 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -13,6 +13,7 @@ import { isClaudeCodeCompatibleProvider, isAnthropicCompatibleProvider, isOpenAICompatibleProvider, + isSelfHostedChatProvider, } from "@/shared/constants/providers"; import { SAFE_OUTBOUND_FETCH_PRESETS, @@ -150,13 +151,13 @@ function withCustomUserAgent(init: RequestInit, providerSpecificData: any = {}) } function buildBearerHeaders(apiKey: string, providerSpecificData: any = {}) { - return applyCustomUserAgent( - { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - providerSpecificData - ); + const headers: Record = { + "Content-Type": "application/json", + }; + if (apiKey) { + headers.Authorization = `Bearer ${apiKey}`; + } + return applyCustomUserAgent(headers, providerSpecificData); } async function validationRead(url: string, init: RequestInit) { @@ -1646,7 +1647,7 @@ async function validateMuseSparkWebProvider({ apiKey, providerSpecificData = {} } export async function validateProviderApiKey({ provider, apiKey, providerSpecificData = {} }: any) { - const requiresApiKey = provider !== "searxng-search"; + const requiresApiKey = provider !== "searxng-search" && !isSelfHostedChatProvider(provider); if (!provider || (requiresApiKey && !apiKey)) { return { valid: false, error: "Provider and API key required", unsupported: false }; } diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 17843fe5d4..97ddbb43b9 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -406,15 +406,6 @@ export const APIKEY_PROVIDERS = { textIcon: "NB", website: "https://nanobananaapi.ai", }, - "lm-studio": { - id: "lm-studio", - alias: "lmstudio", - name: "LM Studio", - icon: "server", - color: "#4A148C", - textIcon: "LM", - website: "https://lmstudio.ai", - }, "ollama-cloud": { id: "ollama-cloud", alias: "ollamacloud", @@ -962,6 +953,97 @@ export const APIKEY_PROVIDERS = { // Local / Self-Hosted Providers export const LOCAL_PROVIDERS = { + "lm-studio": { + id: "lm-studio", + alias: "lmstudio", + name: "LM Studio", + icon: "server", + color: "#4A148C", + textIcon: "LM", + website: "https://lmstudio.ai", + authHint: + "API key optional. Configure the local LM Studio OpenAI-compatible base URL (default: http://localhost:1234/v1).", + localDefault: "http://localhost:1234/v1", + passthroughModels: true, + }, + vllm: { + id: "vllm", + alias: "vllm", + name: "vLLM", + icon: "memory", + color: "#0F766E", + textIcon: "VL", + website: "https://github.com/vllm-project/vllm", + authHint: + "API key optional. Configure the local vLLM OpenAI-compatible base URL (default: http://localhost:8000/v1).", + localDefault: "http://localhost:8000/v1", + passthroughModels: true, + }, + llamafile: { + id: "llamafile", + alias: "llamafile", + name: "Llamafile", + icon: "article", + color: "#EA580C", + textIcon: "LF", + website: "https://github.com/Mozilla-Ocho/llamafile", + authHint: + "API key optional. Configure the local Llamafile OpenAI-compatible base URL (default: http://127.0.0.1:8080/v1).", + localDefault: "http://127.0.0.1:8080/v1", + passthroughModels: true, + }, + triton: { + id: "triton", + alias: "triton", + name: "NVIDIA Triton", + icon: "developer_board", + color: "#76B900", + textIcon: "TR", + website: "https://developer.nvidia.com/triton-inference-server", + authHint: + "API key optional. Configure the Triton OpenAI-compatible base URL (default: http://localhost:8000/v1).", + localDefault: "http://localhost:8000/v1", + passthroughModels: true, + }, + "docker-model-runner": { + id: "docker-model-runner", + alias: "dmr", + name: "Docker Model Runner", + icon: "inventory_2", + color: "#2496ED", + textIcon: "DM", + website: "https://docs.docker.com/ai/model-runner/", + authHint: + "API key optional. Configure the local Docker Model Runner OpenAI-compatible base URL (default: http://localhost:12434/v1).", + localDefault: "http://localhost:12434/v1", + passthroughModels: true, + }, + xinference: { + id: "xinference", + alias: "xinference", + name: "XInference", + icon: "hub", + color: "#DC2626", + textIcon: "XI", + website: "https://inference.readthedocs.io", + authHint: + "API key optional. Configure the local XInference OpenAI-compatible base URL (default: http://localhost:9997/v1).", + localDefault: "http://localhost:9997/v1", + passthroughModels: true, + }, + oobabooga: { + id: "oobabooga", + alias: "ooba", + name: "oobabooga", + icon: "dns", + color: "#8B5CF6", + textIcon: "OO", + website: "https://github.com/oobabooga/text-generation-webui", + authHint: + "API key optional. Configure the local oobabooga OpenAI-compatible base URL (default: http://localhost:5000/v1).", + localDefault: "http://localhost:5000/v1", + passthroughModels: true, + }, sdwebui: { id: "sdwebui", alias: "sdwebui", @@ -1174,6 +1256,27 @@ export function isClaudeCodeCompatibleProvider(providerId) { return typeof providerId === "string" && providerId.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX); } +export function isLocalProvider(providerId) { + return ( + typeof providerId === "string" && + Object.prototype.hasOwnProperty.call(LOCAL_PROVIDERS, providerId) + ); +} + +export const SELF_HOSTED_CHAT_PROVIDER_IDS = new Set([ + "lm-studio", + "vllm", + "llamafile", + "triton", + "docker-model-runner", + "xinference", + "oobabooga", +]); + +export function isSelfHostedChatProvider(providerId) { + return typeof providerId === "string" && SELF_HOSTED_CHAT_PROVIDER_IDS.has(providerId); +} + // All providers (combined) export const AI_PROVIDERS = { ...FREE_PROVIDERS, diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 10205fcd9d..69da538de1 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { SUPPORTED_BATCH_ENDPOINTS } from "@/shared/constants/batchEndpoints"; +import { isLocalProvider } from "@/shared/constants/providers"; import { HIDEABLE_SIDEBAR_ITEM_IDS } from "@/shared/constants/sidebarVisibility"; import { isForbiddenUpstreamHeaderName } from "@/shared/constants/upstreamHeaders"; @@ -243,8 +244,8 @@ export const createProviderSchema = z }) .superRefine((data, ctx) => { const apiKey = typeof data.apiKey === "string" ? data.apiKey.trim() : ""; - const apiKeyOptionalProviders = new Set(["searxng-search", "sdwebui", "comfyui"]); - if (!apiKeyOptionalProviders.has(data.provider) && apiKey.length === 0) { + const apiKeyOptional = data.provider === "searxng-search" || isLocalProvider(data.provider); + if (!apiKeyOptional && apiKey.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "API key is required", diff --git a/tests/unit/executor-default-base.test.ts b/tests/unit/executor-default-base.test.ts index ce1d8af75a..40fbc1df16 100644 --- a/tests/unit/executor-default-base.test.ts +++ b/tests/unit/executor-default-base.test.ts @@ -245,6 +245,20 @@ test("DefaultExecutor.buildHeaders handles GLM, default auth and anthropic-compa assert.equal(anthropicHeaders.Accept, "text/event-stream"); }); +test("DefaultExecutor local OpenAI-style providers honor custom base URLs and skip empty bearer headers", () => { + const lmStudio = new DefaultExecutor("lm-studio"); + const vllm = new DefaultExecutor("vllm"); + + const lmStudioUrl = lmStudio.buildUrl("local-model", true, 0, { + providerSpecificData: { baseUrl: "http://127.0.0.1:4321/v1" }, + }); + const vllmHeaders = vllm.buildHeaders({}, false); + + assert.equal(lmStudioUrl, "http://127.0.0.1:4321/v1/chat/completions"); + assert.equal(vllmHeaders.Authorization, undefined); + assert.equal(vllmHeaders.Accept, "application/json"); +}); + test("DefaultExecutor.buildHeaders handles Snowflake PATs and GigaChat access tokens", () => { const snowflake = new DefaultExecutor("snowflake"); const gigachat = new DefaultExecutor("gigachat"); diff --git a/tests/unit/provider-models-route.test.ts b/tests/unit/provider-models-route.test.ts index 2eb15c7303..0b8dd55896 100644 --- a/tests/unit/provider-models-route.test.ts +++ b/tests/unit/provider-models-route.test.ts @@ -208,6 +208,32 @@ test("provider models route returns static catalog entries for providers with ha assert.equal(body.models.length, 8); }); +test("provider models route discovers local OpenAI-style models without requiring an API key", async () => { + process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS = "true"; + + const connection = await seedConnection("lm-studio", { + providerSpecificData: { + baseUrl: "http://localhost:1234/v1", + }, + }); + + globalThis.fetch = async (url, init = {}) => { + assert.equal(String(url), "http://localhost:1234/v1/models"); + assert.equal(init.headers.Authorization, undefined); + return Response.json({ + data: [{ id: "local-model", name: "Local Model" }], + }); + }; + + const response = await callRoute(connection.id); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.equal(body.provider, "lm-studio"); + assert.equal(body.source, "api"); + assert.deepEqual(body.models, [{ id: "local-model", name: "Local Model" }]); +}); + test("provider models route returns the local catalog for built-in image providers", async () => { const connection = await seedConnection("topaz", { apiKey: "topaz-key", diff --git a/tests/unit/provider-validation-specialty.test.ts b/tests/unit/provider-validation-specialty.test.ts index 2a866a154e..6aab7389c6 100644 --- a/tests/unit/provider-validation-specialty.test.ts +++ b/tests/unit/provider-validation-specialty.test.ts @@ -364,6 +364,43 @@ test("google PSE validator requires cx", async () => { assert.equal(result.error, "Programmable Search Engine ID (cx) is required"); }); +test("local OpenAI-style providers validate without sending Authorization when apiKey is blank", async () => { + const originalAllowPrivateProviderUrls = process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS; + process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS = "true"; + const calls = []; + + try { + globalThis.fetch = async (url, init = {}) => { + calls.push({ url: String(url), headers: init.headers || {} }); + return new Response(JSON.stringify({ data: [{ id: "local-model" }] }), { status: 200 }); + }; + + const lmStudio = await validateProviderApiKey({ + provider: "lm-studio", + providerSpecificData: { baseUrl: "http://localhost:1234/v1" }, + }); + const vllm = await validateProviderApiKey({ + provider: "vllm", + providerSpecificData: { baseUrl: "http://localhost:8000/v1" }, + }); + + assert.equal(lmStudio.valid, true); + assert.equal(vllm.valid, true); + assert.deepEqual( + calls.map((call) => call.url), + ["http://localhost:1234/v1/models", "http://localhost:8000/v1/models"] + ); + assert.equal(calls[0].headers.Authorization, undefined); + assert.equal(calls[1].headers.Authorization, undefined); + } finally { + if (originalAllowPrivateProviderUrls === undefined) { + delete process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS; + } else { + process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS = originalAllowPrivateProviderUrls; + } + } +}); + test("OpenAI-compatible validator covers /responses mode and final ping fallback", async () => { const calls = []; globalThis.fetch = async (url, init = {}) => { diff --git a/tests/unit/providers-page-utils.test.ts b/tests/unit/providers-page-utils.test.ts index e1320efd46..1dc7a4f43b 100644 --- a/tests/unit/providers-page-utils.test.ts +++ b/tests/unit/providers-page-utils.test.ts @@ -216,6 +216,7 @@ test("configured-only preference storage round-trips correctly", () => { test("static catalog entries resolve local, search, audio, web-cookie and upstream providers", () => { const localProvider = providerPageUtils.resolveDashboardProviderInfo("sdwebui"); + const localChatProvider = providerPageUtils.resolveDashboardProviderInfo("lm-studio"); const searchProvider = providerPageUtils.resolveDashboardProviderInfo("brave-search"); const audioProvider = providerPageUtils.resolveDashboardProviderInfo("assemblyai"); const webCookieProvider = providerPageUtils.resolveDashboardProviderInfo("grok-web"); @@ -226,6 +227,8 @@ test("static catalog entries resolve local, search, audio, web-cookie and upstre assert.equal(localProvider?.category, "local"); assert.equal(localProvider?.name, providers.LOCAL_PROVIDERS.sdwebui.name); + assert.equal(localChatProvider?.category, "local"); + assert.equal(localChatProvider?.name, providers.LOCAL_PROVIDERS["lm-studio"].name); assert.equal(searchProvider?.category, "search"); assert.equal(searchProvider?.name, providers.SEARCH_PROVIDERS["brave-search"].name); @@ -255,6 +258,8 @@ test("static catalog entries resolve local, search, audio, web-cookie and upstre test("managed provider connection ids include supported static categories and exclude upstream proxy", () => { assert.equal(providerCatalog.isManagedProviderConnectionId("qoder"), true); assert.equal(providerCatalog.isManagedProviderConnectionId("sdwebui"), true); + assert.equal(providerCatalog.isManagedProviderConnectionId("lm-studio"), true); + assert.equal(providerCatalog.isManagedProviderConnectionId("vllm"), true); assert.equal(providerCatalog.isManagedProviderConnectionId("assemblyai"), true); assert.equal(providerCatalog.isManagedProviderConnectionId("grok-web"), true); assert.equal(providerCatalog.isManagedProviderConnectionId("perplexity-web"), true); @@ -270,6 +275,10 @@ test("grok-web taxonomy stays web-cookie only and does not leak into api-key ent assert.equal("grok-web" in providers.WEB_COOKIE_PROVIDERS, true); assert.equal("sdwebui" in providers.APIKEY_PROVIDERS, false); assert.equal("sdwebui" in providers.LOCAL_PROVIDERS, true); + assert.equal("lm-studio" in providers.APIKEY_PROVIDERS, false); + assert.equal("lm-studio" in providers.LOCAL_PROVIDERS, true); + assert.equal("vllm" in providers.APIKEY_PROVIDERS, false); + assert.equal("vllm" in providers.LOCAL_PROVIDERS, true); assert.equal("comfyui" in providers.APIKEY_PROVIDERS, false); assert.equal("comfyui" in providers.LOCAL_PROVIDERS, true); assert.equal("blackbox-web" in providers.APIKEY_PROVIDERS, false); @@ -295,6 +304,14 @@ test("grok-web taxonomy stays web-cookie only and does not leak into api-key ent apiKeyEntries.some((entry) => entry.providerId === "comfyui"), false ); + assert.equal( + apiKeyEntries.some((entry) => entry.providerId === "lm-studio"), + false + ); + assert.equal( + apiKeyEntries.some((entry) => entry.providerId === "vllm"), + false + ); assert.equal( apiKeyEntries.some((entry) => entry.providerId === "grok-web"), false @@ -307,6 +324,14 @@ test("grok-web taxonomy stays web-cookie only and does not leak into api-key ent localEntries.some((entry) => entry.providerId === "comfyui"), true ); + assert.equal( + localEntries.some((entry) => entry.providerId === "lm-studio"), + true + ); + assert.equal( + localEntries.some((entry) => entry.providerId === "vllm"), + true + ); assert.equal( webCookieEntries.some((entry) => entry.providerId === "grok-web"), true diff --git a/tests/unit/providers-route-managed-catalog.test.ts b/tests/unit/providers-route-managed-catalog.test.ts index 09375c9cb8..2fd14f0488 100644 --- a/tests/unit/providers-route-managed-catalog.test.ts +++ b/tests/unit/providers-route-managed-catalog.test.ts @@ -40,6 +40,76 @@ test("providers route accepts managed local, audio, web-cookie and search provid }, }, }, + { + provider: "lm-studio", + body: { + provider: "lm-studio", + name: "LM Studio Local", + providerSpecificData: { + baseUrl: "http://localhost:1234/v1", + }, + }, + }, + { + provider: "vllm", + body: { + provider: "vllm", + name: "vLLM Local", + providerSpecificData: { + baseUrl: "http://localhost:8000/v1", + }, + }, + }, + { + provider: "llamafile", + body: { + provider: "llamafile", + name: "Llamafile Local", + providerSpecificData: { + baseUrl: "http://127.0.0.1:8080/v1", + }, + }, + }, + { + provider: "triton", + body: { + provider: "triton", + name: "Triton Local", + providerSpecificData: { + baseUrl: "http://localhost:8000/v1", + }, + }, + }, + { + provider: "docker-model-runner", + body: { + provider: "docker-model-runner", + name: "Docker Model Runner Local", + providerSpecificData: { + baseUrl: "http://localhost:12434/v1", + }, + }, + }, + { + provider: "xinference", + body: { + provider: "xinference", + name: "XInference Local", + providerSpecificData: { + baseUrl: "http://localhost:9997/v1", + }, + }, + }, + { + provider: "oobabooga", + body: { + provider: "oobabooga", + name: "oobabooga Local", + providerSpecificData: { + baseUrl: "http://localhost:5000/v1", + }, + }, + }, { provider: "assemblyai", body: {