diff --git a/.env.example b/.env.example index bd6c563402..c216092598 100644 --- a/.env.example +++ b/.env.example @@ -189,7 +189,8 @@ AUTH_COOKIE_SECURE=false REQUIRE_API_KEY=false # Allow revealing full API key values in the Dashboard UI. -# Used by: Dashboard providers page — controls show/hide of key values. +# Used by: src/shared/constants/featureFlagDefinitions.ts — controls show/hide of key values. +# Also configurable from Dashboard > Settings > Feature Flags. # Default: false | Security risk if enabled on shared instances. ALLOW_API_KEY_REVEAL=false diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index dcc10ce3f9..3f2a7de96b 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -225,6 +225,10 @@ Models: ### 🆓 FREE Providers +No-auth free providers have a switch beside **No authentication required** on their provider page. +Turning it off disables that provider, removes it from Providers configured/compact views, and +removes its models from `/v1/models`. + #### Qoder (8 FREE models) ```bash @@ -560,7 +564,7 @@ post_install() { | `NEXT_PUBLIC_CLOUD_URL` | `https://omniroute.dev` | Cloud sync endpoint base URL (replaces legacy `CLOUD_URL`) | | `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC secret for generated API keys | | `REQUIRE_API_KEY` | `false` | Enforce Bearer API key on `/v1/*` | -| `ALLOW_API_KEY_REVEAL` | `false` | Allow Api Manager to copy full API keys on demand | +| `ALLOW_API_KEY_REVEAL` | `false` | Allow authenticated dashboard users to reveal full stored API key values on demand | | `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES` | `70` | Server-side refresh cadence for cached Provider Limits data; UI refresh buttons still trigger manual sync | | `DISABLE_SQLITE_AUTO_BACKUP` | `false` | Disable automatic SQLite snapshots before writes/import/restore; manual backups still work | | `APP_LOG_TO_FILE` | `true` | Enables application and audit log output to disk | diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index c9d38755db..9b2dfdc62f 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -166,19 +166,19 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari ## 4. Security & Authentication -| Variable | Default | Source File | Description | -| --------------------------------------- | ----------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | `src/lib/auth` | Salt combined with hardware identifiers for machine fingerprinting. Change per-deployment for isolation. | -| `OMNIROUTE_CLI_SALT` | `omniroute-cli-auth-v1` | `src/lib/machineToken.ts` | HMAC salt for deriving the local CLI auth token. Changing this value rotates all CLI tokens on the machine. See `docs/security/CLI_TOKEN.md`. | -| `AUTH_COOKIE_SECURE` | `false` | `src/lib/auth` | Sets the `Secure` flag on session cookies. **Must be `true`** when running behind HTTPS. | -| `REQUIRE_API_KEY` | `false` | API middleware | When `true`, all `/v1/*` proxy requests must include a valid API key. | -| `ALLOW_API_KEY_REVEAL` | `false` | Dashboard providers page | Allows revealing full API key values in the Dashboard UI. Security risk on shared instances. | -| `NO_LOG_API_KEY_IDS` | _(empty)_ | `src/lib/compliance/index.ts` | Comma-separated API key IDs that bypass request logging (GDPR compliance). | -| `DEFAULT_RATE_LIMIT_PER_DAY` | `1000` | `src/shared/utils/apiKeyPolicy.ts` | Fallback per-day request budget applied to API keys whose `rate_limits` column is null. Default (unset/empty/malformed) keeps the legacy 1000/day, 5000/week, 20000/month windows. Set explicitly to `0` to opt out (unlimited). Any positive integer N enables N/day, 5N/week, 20N/month. Zod-validated; invalid values log a warning and use the legacy default. | -| `MAX_BODY_SIZE_BYTES` | `10485760` (10 MB) | `src/shared/middleware/bodySizeGuard.ts` | Maximum allowed request body size. Rejects payloads exceeding this limit. | -| `CORS_ORIGIN` | `*` | Next.js middleware | CORS `Access-Control-Allow-Origin` value. Restrict for production. | -| `OUTBOUND_SSRF_GUARD_ENABLED` | `true` | `src/shared/network/outboundUrlGuard.ts` | Block provider calls targeting private/loopback/link-local IP ranges. Disable only in isolated test envs. | -| `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | `false` | `src/shared/network/outboundUrlGuard.ts` | Allow provider URLs pointing to private/local networks (localhost, 192.168.x.x, 10.x.x.x, etc.). **REQUIRED for self-hosted providers** (LM Studio, Ollama, vLLM, Llamafile, Triton, SearXNG). When `false`, the dashboard rejects validation of local URLs. | +| Variable | Default | Source File | Description | +| --------------------------------------- | ----------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | `src/lib/auth` | Salt combined with hardware identifiers for machine fingerprinting. Change per-deployment for isolation. | +| `OMNIROUTE_CLI_SALT` | `omniroute-cli-auth-v1` | `src/lib/machineToken.ts` | HMAC salt for deriving the local CLI auth token. Changing this value rotates all CLI tokens on the machine. See `docs/security/CLI_TOKEN.md`. | +| `AUTH_COOKIE_SECURE` | `false` | `src/lib/auth` | Sets the `Secure` flag on session cookies. **Must be `true`** when running behind HTTPS. | +| `REQUIRE_API_KEY` | `false` | API middleware | When `true`, all `/v1/*` proxy requests must include a valid API key. | +| `ALLOW_API_KEY_REVEAL` | `false` | `src/shared/constants/featureFlagDefinitions.ts` | Allows revealing full API key values in the Dashboard UI. Configurable from Dashboard Feature Flags; security risk on shared instances. | +| `NO_LOG_API_KEY_IDS` | _(empty)_ | `src/lib/compliance/index.ts` | Comma-separated API key IDs that bypass request logging (GDPR compliance). | +| `DEFAULT_RATE_LIMIT_PER_DAY` | `1000` | `src/shared/utils/apiKeyPolicy.ts` | Fallback per-day request budget applied to API keys whose `rate_limits` column is null. Default (unset/empty/malformed) keeps the legacy 1000/day, 5000/week, 20000/month windows. Set explicitly to `0` to opt out (unlimited). Any positive integer N enables N/day, 5N/week, 20N/month. Zod-validated; invalid values log a warning and use the legacy default. | +| `MAX_BODY_SIZE_BYTES` | `10485760` (10 MB) | `src/shared/middleware/bodySizeGuard.ts` | Maximum allowed request body size. Rejects payloads exceeding this limit. | +| `CORS_ORIGIN` | `*` | Next.js middleware | CORS `Access-Control-Allow-Origin` value. Restrict for production. | +| `OUTBOUND_SSRF_GUARD_ENABLED` | `true` | `src/shared/network/outboundUrlGuard.ts` | Block provider calls targeting private/loopback/link-local IP ranges. Disable only in isolated test envs. | +| `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | `false` | `src/shared/network/outboundUrlGuard.ts` | Allow provider URLs pointing to private/local networks (localhost, 192.168.x.x, 10.x.x.x, etc.). **REQUIRED for self-hosted providers** (LM Studio, Ollama, vLLM, Llamafile, Triton, SearXNG). When `false`, the dashboard rejects validation of local URLs. | ### Hardening Checklist diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index e708fe70f7..49e1c7b9b0 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -3,6 +3,7 @@ import { MODE_PACKS } from "./modePacks"; import { DEFAULT_WEIGHTS, ScoringWeights } from "./scoring"; import { AutoVariant } from "./autoPrefix"; import { getProviderConnections } from "@/lib/db/providers"; +import { getSettings } from "@/lib/db/settings"; import { getProviderRegistry } from "./providerRegistryAccessor"; import type { ConnectionFields } from "@/lib/db/encryption"; import { NOAUTH_PROVIDERS } from "@/shared/constants/providers"; @@ -123,7 +124,10 @@ function getFirstRegistryModelId(providerInfo: { models?: Array<{ id?: string }> : undefined; } -function getNoAuthCandidates(excludedProviders: Set): VirtualAutoComboCandidate[] { +function getNoAuthCandidates( + excludedProviders: Set, + blockedProviders: Set +): VirtualAutoComboCandidate[] { const registry = getProviderRegistry(); const candidates: VirtualAutoComboCandidate[] = []; @@ -132,6 +136,11 @@ function getNoAuthCandidates(excludedProviders: Set): VirtualAutoComboCa const providerId = providerDef.id; if (!providerId || excludedProviders.has(providerId)) continue; + if ( + blockedProviders.has(providerId) || + (typeof providerDef.alias === "string" && blockedProviders.has(providerDef.alias)) + ) + continue; const providerInfo = registry[providerId]; const modelId = getFirstRegistryModelId(providerInfo); @@ -206,7 +215,13 @@ export function computeAdvertisedLimits(candidates: Array<{ provider: string; mo export async function createVirtualAutoCombo( variant: AutoVariant | undefined ): Promise { - const connections = (await getProviderConnections({ isActive: true })) as VirtualFactoryConn[]; + const [connections, settings] = await Promise.all([ + getProviderConnections({ isActive: true }) as Promise, + getSettings().catch(() => ({}) as Record), + ]); + const blockedProviders = new Set( + Array.isArray(settings.blockedProviders) ? (settings.blockedProviders as string[]) : [] + ); const validConnections = connections.filter(hasUsableConnectionCredential); @@ -232,7 +247,7 @@ export async function createVirtualAutoCombo( } candidatePool.push( - ...getNoAuthCandidates(new Set(validConnections.map((conn) => conn.provider))) + ...getNoAuthCandidates(new Set(validConnections.map((conn) => conn.provider)), blockedProviders) ); if (candidatePool.length === 0) { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx index b605b06677..393ef31ff6 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx @@ -5,7 +5,7 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import { useParams } from "next/navigation"; import Link from "next/link"; import { useTranslations } from "next-intl"; -import { Card, Button, CardSkeleton, NoAuthProviderCard, NoAuthAccountCard } from "@/shared/components"; +import { Card, Button, CardSkeleton } from "@/shared/components"; import { NOAUTH_PROVIDERS, getProviderAlias, @@ -15,7 +15,10 @@ import { supportsApiKeyOnFreeProvider, } from "@/shared/constants/providers"; import { getModelsByProviderId } from "@/shared/constants/models"; -import { compatibleProviderSupportsModelImport, getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels"; +import { + compatibleProviderSupportsModelImport, + getCompatibleFallbackModels, +} from "@/lib/providers/managedAvailableModels"; import { normalizeModelCatalogSource } from "@/shared/utils/modelCatalogSearch"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import useEmailPrivacyStore from "@/store/emailPrivacyStore"; @@ -46,6 +49,7 @@ import ProviderModalsPanel from "./components/ProviderModalsPanel"; import EmptyConnectionsPlaceholder from "./components/EmptyConnectionsPlaceholder"; import UpstreamProxyCard from "./components/UpstreamProxyCard"; import SearchProviderCard from "./components/SearchProviderCard"; +import NoAuthProviderControls from "./components/NoAuthProviderControls"; // providerText used by UpstreamProxyCard (Phase 1t.7) export default function ProviderDetailPageClient() { @@ -190,11 +194,16 @@ export default function ProviderDetailPageClient() { const subscriptionRisk = providerInfo?.subscriptionRisk === true; // ── Phase 1t.3: connection gate + risk-notice modal state ─────────────── - const { showRiskNoticeModal, gateConnectionFlow, handleConfirmRiskNotice, handleCancelRiskNotice } = - useConnectionGate({ providerId, subscriptionRisk }); + const { + showRiskNoticeModal, + gateConnectionFlow, + handleConfirmRiskNotice, + handleCancelRiskNotice, + } = useConnectionGate({ providerId, subscriptionRisk }); const providerSupportsPat = supportsApiKeyOnFreeProvider(providerId); const isOAuth = providerSupportsOAuth && !providerSupportsPat; + const providerAlias = getProviderAlias(providerId); const isFreeNoAuth = NOAUTH_PROVIDERS[providerId]?.noAuth === true; const registryModels = getModelsByProviderId(providerId); // Prefer synced API-discovered models when available, then merge built-ins @@ -233,7 +242,6 @@ export default function ProviderDetailPageClient() { } return Array.from(deduped.values()); }, [providerId, registryModels, syncedAvailableModels, modelMeta.customModels]); - const providerAlias = getProviderAlias(providerId); const isManagedAvailableModelsProvider = isCompatible || providerId === "openrouter"; // isSearchProvider declared earlier (before hooks) const isUpstreamProxyProvider = providerInfo?.category === "upstream-proxy"; @@ -360,10 +368,7 @@ export default function ProviderDetailPageClient() { } = useAuthFileHandlers({ parseApiErrorMessage, getAttachmentFilename, notify, t }); // Phase 1e: compat-state derivations - const compat = useModelCompatState( - modelMeta.customModels, - modelMeta.modelCompatOverrides - ); + const compat = useModelCompatState(modelMeta.customModels, modelMeta.modelCompatOverrides); const { customMap } = compat; const effectiveModelNormalize = compat.effectiveModelNormalize; const effectiveModelPreserveDeveloper = compat.effectiveModelPreserveDeveloper; @@ -414,7 +419,6 @@ export default function ProviderDetailPageClient() { // renderModelsSection → components/ProviderModelsSection.tsx (Phase 1m) - if (loading) { return (
@@ -448,7 +452,9 @@ export default function ProviderDetailPageClient() { t={t} /> - {providerId === "zed" && } + {providerId === "zed" && ( + + )} {/* CompatibleNodeCard — Phase 1t.2: extracted to components/CompatibleNodeCard.tsx */} {isCompatible && providerNode && ( @@ -466,24 +472,12 @@ export default function ProviderDetailPageClient() { )} {/* Connections */} - {!isUpstreamProxyProvider && isFreeNoAuth && providerId === "mimocode" && ( - crypto.randomUUID().replace(/-/g, "")} + providerName={providerInfo?.name || providerId} /> )} - {!isUpstreamProxyProvider && isFreeNoAuth && providerId === "opencode" && ( - crypto.randomUUID().replace(/-/g, "")} - /> - )} - {!isUpstreamProxyProvider && - isFreeNoAuth && - providerId !== "mimocode" && - providerId !== "opencode" && } {!isUpstreamProxyProvider && !isFreeNoAuth && ( ); } - diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/NoAuthProviderControls.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/NoAuthProviderControls.tsx new file mode 100644 index 0000000000..7ae5305d91 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/NoAuthProviderControls.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { NoAuthAccountCard, NoAuthProviderCard } from "@/shared/components"; +import { getProviderAlias } from "@/shared/constants/providers"; +import { useNotificationStore } from "@/store/notificationStore"; + +const ACCOUNT_PROVIDER_NAMES: Record = { + mimocode: "MiMoCode", + opencode: "OpenCode", +}; + +interface NoAuthProviderControlsProps { + providerId: string; + providerName: string; +} + +export default function NoAuthProviderControls({ + providerId, + providerName, +}: NoAuthProviderControlsProps) { + const notify = useNotificationStore(); + const [blockedProviders, setBlockedProviders] = useState([]); + const [savingEnabled, setSavingEnabled] = useState(false); + const providerAlias = getProviderAlias(providerId); + const enabled = + !blockedProviders.includes(providerId) && + !(typeof providerAlias === "string" && blockedProviders.includes(providerAlias)); + + useEffect(() => { + let cancelled = false; + + async function fetchBlockedProviders() { + try { + const response = await fetch("/api/settings", { cache: "no-store" }); + if (!response.ok) return; + const data = await response.json(); + if (!cancelled && Array.isArray(data.blockedProviders)) { + setBlockedProviders(data.blockedProviders); + } + } catch (error) { + console.error("Failed to fetch provider settings:", error); + } + } + + void fetchBlockedProviders(); + return () => { + cancelled = true; + }; + }, []); + + const handleEnabledChange = useCallback( + async (nextEnabled: boolean) => { + const previous = blockedProviders; + const keysToRemove = new Set([providerId, providerAlias].filter(Boolean)); + const next = nextEnabled + ? previous.filter((item) => !keysToRemove.has(item)) + : Array.from(new Set([...previous.filter((item) => !keysToRemove.has(item)), providerId])); + + setBlockedProviders(next); + setSavingEnabled(true); + try { + const response = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ blockedProviders: next }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data?.error?.message || data?.error || "Failed to update provider"); + } + setBlockedProviders(Array.isArray(data.blockedProviders) ? data.blockedProviders : next); + notify.success(`${providerName} ${nextEnabled ? "enabled" : "disabled"}`); + } catch (error) { + setBlockedProviders(previous); + notify.error(error instanceof Error ? error.message : "Failed to update provider"); + } finally { + setSavingEnabled(false); + } + }, + [blockedProviders, notify, providerAlias, providerId, providerName] + ); + + const accountProviderName = ACCOUNT_PROVIDER_NAMES[providerId]; + if (accountProviderName) { + return ( + crypto.randomUUID().replace(/-/g, "")} + enabled={enabled} + savingEnabled={savingEnabled} + onEnabledChange={handleEnabledChange} + /> + ); + } + + return ( + + ); +} diff --git a/src/app/(dashboard)/dashboard/providers/components/ProviderSummaryCard.tsx b/src/app/(dashboard)/dashboard/providers/components/ProviderSummaryCard.tsx index fb304dae13..83b8d8f1a5 100644 --- a/src/app/(dashboard)/dashboard/providers/components/ProviderSummaryCard.tsx +++ b/src/app/(dashboard)/dashboard/providers/components/ProviderSummaryCard.tsx @@ -124,7 +124,7 @@ export default function ProviderSummaryCard({ label: "Cloud Agent", stat: summaryStats.cloudagent, }, - ]; + ].filter((category) => category.key !== "no-auth" || category.stat.total > 0); return ( diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index 2d59a0aca3..5949beef2f 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -165,6 +165,7 @@ export default function ProvidersPage() { const [connections, setConnections] = useState([]); const [providerNodes, setProviderNodes] = useState([]); const [ccCompatibleProviderEnabled, setCcCompatibleProviderEnabled] = useState(false); + const [blockedProviders, setBlockedProviders] = useState([]); const [expirations, setExpirations] = useState(null); const [codexGlobalServiceMode, setCodexGlobalServiceMode] = useState("none"); @@ -241,6 +242,9 @@ export default function ProvidersPage() { setCcCompatibleProviderEnabled(nodesData.ccCompatibleProviderEnabled === true); } if (expirationsRes.ok && expirationsData) setExpirations(expirationsData); + if (settingsData && Array.isArray(settingsData.blockedProviders)) { + setBlockedProviders(settingsData.blockedProviders); + } setCodexGlobalServiceMode(getCodexGlobalServiceMode(settingsData)); } catch (error) { console.log("Error fetching data:", error); @@ -520,7 +524,12 @@ export default function ProvidersPage() { showFreeOnly ); - const noAuthEntriesAll = buildStaticProviderEntries("no-auth", getProviderStats); + const blockedProviderSet = new Set(blockedProviders); + const rawNoAuthEntriesAll = buildStaticProviderEntries("no-auth", getProviderStats); + const noAuthEntriesAll = rawNoAuthEntriesAll.filter(({ providerId, provider }) => { + const alias = typeof provider.alias === "string" ? provider.alias : null; + return !blockedProviderSet.has(providerId) && !(alias && blockedProviderSet.has(alias)); + }); const noAuthEntries = filterConfiguredProviderEntries( noAuthEntriesAll, effectiveShowConfiguredOnly, @@ -1241,7 +1250,7 @@ export default function ProvidersPage() { )} {/* No Auth Providers */} - {showSection("noauth") && noAuthEntries.length > 0 && ( + {showSection("noauth") && !showFreeOnly && noAuthEntriesAll.length > 0 && (

diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 9014cabe41..95fdfebda3 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -9,8 +9,10 @@ import { import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts"; import { getModelsByProviderId } from "@/shared/constants/models"; import { getStaticModelsForProvider, type LocalCatalogModel } from "@/lib/providers/staticModels"; +import { isProviderBlockedByIdOrAlias } from "@/shared/utils/noAuthProviders"; import { getProviderConnectionById, + getSettings, getModelIsHidden, resolveProxyForProvider, } from "@/lib/localDb"; @@ -723,19 +725,15 @@ export async function GET( const connection = await getProviderConnectionById(id); if (!connection) { - // #3047 — no-auth providers (e.g. OpenCode Free) have no connection rows, - // so the "Import from /models" button had no connection id to fetch from - // and silently no-op'd. When the route is called with a no-auth provider - // id, serve that provider's registry/static model catalog so the import - // flow can populate the custom model list. + // #3047 — no-auth providers have no connection rows; serve their catalog by provider id. const isNoAuthProvider = (NOAUTH_PROVIDERS as Record)[id]?.noAuth === true; if (isNoAuthProvider) { - // #3611 — if the registry entry has a modelsUrl, attempt a live fetch so - // the model picker shows the current catalog instead of the stale - // hardcoded list (opencode provider had 9 hardcoded models while the live - // endpoint exposes many more). No auth header is added because noAuth - // providers are genuinely public. Fall through to local_catalog on any error. + if (isProviderBlockedByIdOrAlias(id, (await getSettings()).blockedProviders)) { + return NextResponse.json({ error: "Provider is disabled" }, { status: 403 }); + } + + // #3611 — prefer the live public modelsUrl when present; fall back to local_catalog. const noAuthRegistryEntry = getRegistryEntry(id); const noAuthModelsUrl = typeof noAuthRegistryEntry?.modelsUrl === "string" && diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 1f60d30ca4..df775cd6b5 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -32,6 +32,12 @@ import { getSyncedCapability } from "@/lib/modelsDevSync"; import { getModelSpec } from "@/shared/constants/modelSpecs"; import { isAuthRequired, isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth"; import { isModelCatalogNamesEnabled } from "@/shared/utils/featureFlags"; +import { + isNoAuthProviderBlocked, + isNoAuthProviderKey, + isNoAuthRawProviderPrefix, + normalizeBlockedProviderSet, +} from "@/shared/utils/noAuthProviders"; import { parseModel } from "@omniroute/open-sse/services/model"; import { getTokenLimit } from "@omniroute/open-sse/services/contextManager"; import { extractApiKey } from "@/sse/services/auth"; @@ -347,9 +353,7 @@ export async function getUnifiedModelsResponse( aliasOrProviderId; // Issue #96: Allow blocking specific providers from the models list - const blockedProviders: Set = new Set( - Array.isArray(settings.blockedProviders) ? settings.blockedProviders : [] - ); + const blockedProviders = normalizeBlockedProviderSet(settings.blockedProviders); // Get active provider connections let connections = []; @@ -412,9 +416,9 @@ export async function getUnifiedModelsResponse( registerConnectionKey(conn.provider, conn); } - // noAuth providers never create DB connection rows, so they are always active. - // Add their IDs and aliases unconditionally so the catalog gate does not filter them. (#2798) + // noAuth providers have no DB rows; settings.blockedProviders disables them. for (const p of Object.values(NOAUTH_PROVIDERS)) { + if (isNoAuthProviderBlocked(blockedProviders, p.id, "alias" in p ? p.alias : null)) continue; activeAliases.add(p.id); if ("alias" in p && typeof p.alias === "string") activeAliases.add(p.alias); } @@ -437,10 +441,9 @@ export async function getUnifiedModelsResponse( const providerId = aliasToProviderId[providerKey] || providerKey; const alias = providerIdToAlias[providerId] || providerKey; // noAuth providers have no connection rows — treat every model as eligible. (#2798) - const isNoAuth = Object.values(NOAUTH_PROVIDERS).some( - (p) => p.id === providerId || p.id === providerKey || ("alias" in p && p.alias === alias) - ); - if (isNoAuth) return true; + const isNoAuth = isNoAuthProviderKey(providerId, providerKey, alias); + if (isNoAuth && !isNoAuthProviderBlocked(blockedProviders, providerId, providerKey, alias)) + return true; return hasEligibleConnectionForModel( getConnectionsForProvider(providerKey, providerId, alias), modelId @@ -729,10 +732,14 @@ export async function getUnifiedModelsResponse( const providerId = aliasToProviderId[alias] || alias; const canonicalProviderId = resolveCanonicalProviderId(alias, providerId); - // Skip blocked providers (Issue #96) - if (blockedProviders.has(alias) || blockedProviders.has(canonicalProviderId)) continue; + if ( + isNoAuthProviderBlocked(blockedProviders, canonicalProviderId, alias) || + blockedProviders.has(alias) || + blockedProviders.has(canonicalProviderId) + ) + continue; + if (isNoAuthRawProviderPrefix(canonicalProviderId, alias)) continue; - // Only include models from providers with active connections if (!activeAliases.has(alias) && !activeAliases.has(canonicalProviderId)) { continue; } @@ -764,6 +771,7 @@ export async function getUnifiedModelsResponse( // This improves compatibility for clients that expect full provider names. if ( canonicalProviderId !== alias && + !isNoAuthProviderKey(canonicalProviderId) && prefixRoutesToProvider(canonicalProviderId, canonicalProviderId) ) { const providerIdModel = `${canonicalProviderId}/${model.id}`; @@ -1179,18 +1187,11 @@ export async function getUnifiedModelsResponse( if (!modelId) continue; if (model.isHidden === true) continue; if (getModelIsHidden(canonicalProviderId, modelId)) continue; - // noAuth providers (e.g. theoldllm) never create DB connection rows, so the - // eligibility gate would drop every imported/custom model for them (#3200). - // Mirror providerSupportsModel's noAuth bypass (#2798) — keep the gate for - // auth providers (preserving parentProviderType for compatible UUID nodes). - const isNoAuthProvider = Object.values(NOAUTH_PROVIDERS).some( - (p) => - p.id === canonicalProviderId || - p.id === providerId || - ("alias" in p && p.alias === alias) - ); + // noAuth providers have no connection rows; keep auth providers gated. (#2798/#3200) + const isNoAuthProvider = isNoAuthProviderKey(canonicalProviderId, providerId, alias); if ( - !isNoAuthProvider && + (!isNoAuthProvider || + isNoAuthProviderBlocked(blockedProviders, canonicalProviderId, providerId, alias)) && !hasEligibleConnectionForModel( getConnectionsForProvider(alias, canonicalProviderId, providerId, parentProviderType), modelId @@ -1248,8 +1249,7 @@ export async function getUnifiedModelsResponse( ...(visionFields || {}), }); - // Only add provider-prefixed version if different from alias - if (canonicalProviderId !== alias && !prefix) { + if (canonicalProviderId !== alias && !prefix && !isNoAuthProvider) { const providerPrefixedId = `${canonicalProviderId}/${modelId}`; if (models.some((m) => m.id === providerPrefixedId)) continue; const providerVisionFields = diff --git a/src/lib/apiKeyExposure.ts b/src/lib/apiKeyExposure.ts index 2a787508aa..bd5a2123d6 100644 --- a/src/lib/apiKeyExposure.ts +++ b/src/lib/apiKeyExposure.ts @@ -1,10 +1,16 @@ +import { isApiKeyRevealEnabledFlag } from "@/shared/utils/featureFlags"; + const ENABLED_VALUES = new Set(["1", "true", "yes", "on"]); export function isApiKeyRevealEnabled(): boolean { - const raw = String(process.env.ALLOW_API_KEY_REVEAL || "") - .trim() - .toLowerCase(); - return ENABLED_VALUES.has(raw); + try { + return isApiKeyRevealEnabledFlag(); + } catch { + const raw = String(process.env.ALLOW_API_KEY_REVEAL || "") + .trim() + .toLowerCase(); + return ENABLED_VALUES.has(raw); + } } export function maskStoredApiKey(key: unknown): string | null { diff --git a/src/lib/combos/builderOptions.ts b/src/lib/combos/builderOptions.ts index cbeea5306d..b343f17c90 100644 --- a/src/lib/combos/builderOptions.ts +++ b/src/lib/combos/builderOptions.ts @@ -5,6 +5,7 @@ import { getModelIsHidden, getProviderConnections, getProviderNodes, + getSettings, } from "@/lib/localDb"; import { getAccountDisplayName, getProviderDisplayName } from "@/lib/display/names"; import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels"; @@ -361,13 +362,20 @@ function normalizeSyncedModels(raw: unknown): SyncedModelLike[] { export async function getComboBuilderOptions(): Promise { getSyncedCapabilities(); - const [connections, providerNodes, customModelsMap, syncedModelsMap, combos] = await Promise.all([ - getProviderConnections(), - getProviderNodes(), - getAllCustomModels(), - getAllSyncedAvailableModels(), - getCombos(), - ]); + const [connections, providerNodes, customModelsMap, syncedModelsMap, combos, settings] = + await Promise.all([ + getProviderConnections(), + getProviderNodes(), + getAllCustomModels(), + getAllSyncedAvailableModels(), + getCombos(), + getSettings().catch(() => ({}) as Record), + ]); + const blockedProviders = new Set( + Array.isArray((settings as Record).blockedProviders) + ? ((settings as Record).blockedProviders as string[]) + : [] + ); const providerNodeMap = new Map(); for (const providerNode of providerNodes as ProviderNodeLike[]) { @@ -516,9 +524,14 @@ export async function getComboBuilderOptions(): Promise void; } interface Connection { @@ -48,6 +52,9 @@ export default function NoAuthAccountCard({ dataKey = "fingerprints", description = "Ready to use — no signup needed. Add accounts for rate-limit rotation.", addLabel = "Add Account", + enabled = true, + savingEnabled = false, + onEnabledChange, }: NoAuthAccountCardProps) { const [connections, setConnections] = useState([]); const [loading, setLoading] = useState(true); @@ -94,9 +101,7 @@ export default function NoAuthAccountCard({ } }, [proxyAccountId]); - const allAccountIds = connections.flatMap( - (c) => c.providerSpecificData?.[dataKey] || [] - ); + const allAccountIds = connections.flatMap((c) => c.providerSpecificData?.[dataKey] || []); const conn = connections[0]; const accountProxies = getAccountProxies(conn); @@ -251,30 +256,38 @@ export default function NoAuthAccountCard({ return ( -
-
- lock_open -
-
-

No authentication required

-

{description}

+
+
+
+ lock_open +
+
+

No authentication required

+

{description}

+
+
-
+
Accounts ({loading ? "..." : allAccountIds.length}) -
+
{!loading && allAccountIds.length > 0 && ( )} -
@@ -305,9 +318,13 @@ export default function NoAuthAccountCard({