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: {