mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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.
This commit is contained in:
@@ -1248,6 +1248,73 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
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": {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, CompatModelRow>;
|
||||
type LocalProviderMetadata = {
|
||||
name?: string;
|
||||
localDefault?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function buildCompatMap(rows: CompatModelRow[]): CompatModelMap {
|
||||
const m = new Map<string, CompatModelRow>();
|
||||
@@ -5338,11 +5345,37 @@ const DEFAULT_PROVIDER_BASE_URLS: Record<string, string> = {
|
||||
"searxng-search": "http://localhost:8888/search",
|
||||
};
|
||||
|
||||
function getLocalProviderMetadata(providerId?: string | null) {
|
||||
if (!providerId || !isSelfHostedChatProvider(providerId)) return null;
|
||||
return (LOCAL_PROVIDERS as Record<string, LocalProviderMetadata>)[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, unknown>) => 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
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
label={isSearxng ? t("apiKeyOptionalLabel") : t("apiKeyLabel")}
|
||||
label={apiKeyOptional ? t("apiKeyOptionalLabel") : t("apiKeyLabel")}
|
||||
type="password"
|
||||
value={formData.apiKey}
|
||||
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
|
||||
placeholder={isVertex ? t("vertexServiceAccountPlaceholder") : t("enterNewApiKey")}
|
||||
hint={isSearxng ? t("apiKeyOptionalHint") : t("leaveBlankKeepCurrentApiKey")}
|
||||
hint={apiCredentialHint}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="pt-6">
|
||||
|
||||
@@ -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<string, string> {
|
||||
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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {}) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user