mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Add provider auth visibility controls (#3953)
Integrated into release/v3.8.27
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<string>): VirtualAutoComboCandidate[] {
|
||||
function getNoAuthCandidates(
|
||||
excludedProviders: Set<string>,
|
||||
blockedProviders: Set<string>
|
||||
): VirtualAutoComboCandidate[] {
|
||||
const registry = getProviderRegistry();
|
||||
const candidates: VirtualAutoComboCandidate[] = [];
|
||||
|
||||
@@ -132,6 +136,11 @@ function getNoAuthCandidates(excludedProviders: Set<string>): 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<VirtualAutoCombo> {
|
||||
const connections = (await getProviderConnections({ isActive: true })) as VirtualFactoryConn[];
|
||||
const [connections, settings] = await Promise.all([
|
||||
getProviderConnections({ isActive: true }) as Promise<VirtualFactoryConn[]>,
|
||||
getSettings().catch(() => ({}) as Record<string, unknown>),
|
||||
]);
|
||||
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) {
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-8">
|
||||
@@ -448,7 +452,9 @@ export default function ProviderDetailPageClient() {
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{providerId === "zed" && <ZedImportCard fetchConnections={fetchConnections} notify={notify} />}
|
||||
{providerId === "zed" && (
|
||||
<ZedImportCard fetchConnections={fetchConnections} notify={notify} />
|
||||
)}
|
||||
|
||||
{/* CompatibleNodeCard — Phase 1t.2: extracted to components/CompatibleNodeCard.tsx */}
|
||||
{isCompatible && providerNode && (
|
||||
@@ -466,24 +472,12 @@ export default function ProviderDetailPageClient() {
|
||||
)}
|
||||
|
||||
{/* Connections */}
|
||||
{!isUpstreamProxyProvider && isFreeNoAuth && providerId === "mimocode" && (
|
||||
<NoAuthAccountCard
|
||||
{!isUpstreamProxyProvider && isFreeNoAuth && (
|
||||
<NoAuthProviderControls
|
||||
providerId={providerId}
|
||||
providerName="MiMoCode"
|
||||
generateAccountId={() => crypto.randomUUID().replace(/-/g, "")}
|
||||
providerName={providerInfo?.name || providerId}
|
||||
/>
|
||||
)}
|
||||
{!isUpstreamProxyProvider && isFreeNoAuth && providerId === "opencode" && (
|
||||
<NoAuthAccountCard
|
||||
providerId={providerId}
|
||||
providerName="OpenCode"
|
||||
generateAccountId={() => crypto.randomUUID().replace(/-/g, "")}
|
||||
/>
|
||||
)}
|
||||
{!isUpstreamProxyProvider &&
|
||||
isFreeNoAuth &&
|
||||
providerId !== "mimocode" &&
|
||||
providerId !== "opencode" && <NoAuthProviderCard />}
|
||||
{!isUpstreamProxyProvider && !isFreeNoAuth && (
|
||||
<Card>
|
||||
<ConnectionsHeaderToolbar
|
||||
@@ -780,4 +774,3 @@ export default function ProviderDetailPageClient() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string> = {
|
||||
mimocode: "MiMoCode",
|
||||
opencode: "OpenCode",
|
||||
};
|
||||
|
||||
interface NoAuthProviderControlsProps {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
}
|
||||
|
||||
export default function NoAuthProviderControls({
|
||||
providerId,
|
||||
providerName,
|
||||
}: NoAuthProviderControlsProps) {
|
||||
const notify = useNotificationStore();
|
||||
const [blockedProviders, setBlockedProviders] = useState<string[]>([]);
|
||||
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 (
|
||||
<NoAuthAccountCard
|
||||
providerId={providerId}
|
||||
providerName={accountProviderName}
|
||||
generateAccountId={() => crypto.randomUUID().replace(/-/g, "")}
|
||||
enabled={enabled}
|
||||
savingEnabled={savingEnabled}
|
||||
onEnabledChange={handleEnabledChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NoAuthProviderCard
|
||||
enabled={enabled}
|
||||
saving={savingEnabled}
|
||||
onEnabledChange={handleEnabledChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card padding="sm">
|
||||
|
||||
@@ -165,6 +165,7 @@ export default function ProvidersPage() {
|
||||
const [connections, setConnections] = useState<any[]>([]);
|
||||
const [providerNodes, setProviderNodes] = useState<any[]>([]);
|
||||
const [ccCompatibleProviderEnabled, setCcCompatibleProviderEnabled] = useState(false);
|
||||
const [blockedProviders, setBlockedProviders] = useState<string[]>([]);
|
||||
const [expirations, setExpirations] = useState<any>(null);
|
||||
const [codexGlobalServiceMode, setCodexGlobalServiceMode] =
|
||||
useState<CodexGlobalServiceMode>("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 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2 flex-1 min-w-0">
|
||||
|
||||
@@ -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<string, { noAuth?: boolean }>)[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" &&
|
||||
|
||||
@@ -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<string> = 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 =
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<ComboBuilderOptionsPayload> {
|
||||
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<string, unknown>),
|
||||
]);
|
||||
const blockedProviders = new Set(
|
||||
Array.isArray((settings as Record<string, unknown>).blockedProviders)
|
||||
? ((settings as Record<string, unknown>).blockedProviders as string[])
|
||||
: []
|
||||
);
|
||||
|
||||
const providerNodeMap = new Map<string, ProviderNodeLike>();
|
||||
for (const providerNode of providerNodes as ProviderNodeLike[]) {
|
||||
@@ -516,9 +524,14 @@ export async function getComboBuilderOptions(): Promise<ComboBuilderOptionsPaylo
|
||||
}
|
||||
|
||||
// No-auth providers have no rows in provider_connections, so they are never included in the
|
||||
// connectionsByProvider loop above. Add them here so they appear in the combo builder picker.
|
||||
// connectionsByProvider loop above. Add them unless the provider is explicitly blocked.
|
||||
for (const noAuthProvider of Object.values(NOAUTH_PROVIDERS)) {
|
||||
const providerId = noAuthProvider.id;
|
||||
if (
|
||||
blockedProviders.has(providerId) ||
|
||||
(typeof noAuthProvider.alias === "string" && blockedProviders.has(noAuthProvider.alias))
|
||||
)
|
||||
continue;
|
||||
// Skip if already covered (defensive: shouldn't happen for true no-auth providers)
|
||||
if (connectionsByProvider.has(providerId)) continue;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import Card from "./Card";
|
||||
import Button from "./Button";
|
||||
import DistributeProxiesButton from "./DistributeProxiesButton";
|
||||
import NoAuthProviderToggle from "./NoAuthProviderToggle";
|
||||
|
||||
interface NoAuthAccountCardProps {
|
||||
providerId: string;
|
||||
@@ -12,6 +13,9 @@ interface NoAuthAccountCardProps {
|
||||
dataKey?: string;
|
||||
description?: string;
|
||||
addLabel?: string;
|
||||
enabled?: boolean;
|
||||
savingEnabled?: boolean;
|
||||
onEnabledChange?: (enabled: boolean) => 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<Connection[]>([]);
|
||||
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 (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="inline-flex shrink-0 items-center justify-center w-10 h-10 rounded-full bg-green-500/10 text-green-500">
|
||||
<span className="material-symbols-outlined text-[20px]">lock_open</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">No authentication required</p>
|
||||
<p className="text-xs text-text-muted">{description}</p>
|
||||
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="inline-flex shrink-0 items-center justify-center w-10 h-10 rounded-full bg-green-500/10 text-green-500">
|
||||
<span className="material-symbols-outlined text-[20px]">lock_open</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">No authentication required</p>
|
||||
<p className="text-xs text-text-muted">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<NoAuthProviderToggle
|
||||
className="w-full justify-end sm:w-auto"
|
||||
enabled={enabled}
|
||||
saving={savingEnabled}
|
||||
onEnabledChange={onEnabledChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-3 mt-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
Accounts ({loading ? "..." : allAccountIds.length})
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{!loading && allAccountIds.length > 0 && (
|
||||
<DistributeProxiesButton
|
||||
onDistribute={handleDistributeProxies}
|
||||
disabled={adding}
|
||||
disabled={adding || !enabled}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
<Button size="sm" icon="add" onClick={handleAddAccount} disabled={adding}>
|
||||
<Button size="sm" icon="add" onClick={handleAddAccount} disabled={adding || !enabled}>
|
||||
{adding ? "Adding..." : addLabel}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -305,9 +318,13 @@ export default function NoAuthAccountCard({
|
||||
<button
|
||||
onClick={() => openProxyConfig(id)}
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-colors"
|
||||
title={proxy ? `${proxy.type}://${proxy.host}:${proxy.port}` : "Configure proxy"}
|
||||
title={
|
||||
proxy ? `${proxy.type}://${proxy.host}:${proxy.port}` : "Configure proxy"
|
||||
}
|
||||
>
|
||||
<span className={`material-symbols-outlined text-[14px] ${proxy ? "text-blue-400" : "text-text-muted"}`}>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[14px] ${proxy ? "text-blue-400" : "text-text-muted"}`}
|
||||
>
|
||||
{proxy ? "shield" : "shield"}
|
||||
</span>
|
||||
<span className={proxy ? "text-blue-400" : "text-text-muted"}>
|
||||
@@ -328,9 +345,7 @@ export default function NoAuthAccountCard({
|
||||
ref={popoverRef}
|
||||
className="absolute right-0 top-full z-50 mt-1 w-80 rounded-lg border border-black/10 dark:border-white/10 bg-surface shadow-lg p-4"
|
||||
>
|
||||
<p className="text-sm font-medium mb-3">
|
||||
Proxy for Account {i + 1}
|
||||
</p>
|
||||
<p className="text-sm font-medium mb-3">Proxy for Account {i + 1}</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
@@ -339,7 +354,9 @@ export default function NoAuthAccountCard({
|
||||
className="rounded-md border border-black/10 dark:border-white/10 bg-bg px-2.5 py-1.5 text-xs flex-shrink-0"
|
||||
>
|
||||
{PROXY_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import Card from "./Card";
|
||||
import NoAuthProviderToggle from "./NoAuthProviderToggle";
|
||||
|
||||
export default function NoAuthProviderCard() {
|
||||
interface NoAuthProviderCardProps {
|
||||
enabled?: boolean;
|
||||
saving?: boolean;
|
||||
onEnabledChange?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export default function NoAuthProviderCard({
|
||||
enabled = true,
|
||||
saving = false,
|
||||
onEnabledChange,
|
||||
}: NoAuthProviderCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex shrink-0 items-center justify-center w-10 h-10 rounded-full bg-green-500/10 text-green-500">
|
||||
<span className="material-symbols-outlined text-[20px]">lock_open</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">No authentication required</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
This provider is ready to use immediately — no signup or API key needed.
|
||||
</p>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="inline-flex shrink-0 items-center justify-center w-10 h-10 rounded-full bg-green-500/10 text-green-500">
|
||||
<span className="material-symbols-outlined text-[20px]">lock_open</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">No authentication required</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
This provider is ready to use immediately — no signup or API key needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<NoAuthProviderToggle
|
||||
className="w-full justify-end sm:w-auto"
|
||||
enabled={enabled}
|
||||
saving={saving}
|
||||
onEnabledChange={onEnabledChange}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
36
src/shared/components/NoAuthProviderToggle.tsx
Normal file
36
src/shared/components/NoAuthProviderToggle.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
import Toggle from "./Toggle";
|
||||
|
||||
interface NoAuthProviderToggleProps {
|
||||
enabled: boolean;
|
||||
saving?: boolean;
|
||||
onEnabledChange?: (enabled: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function NoAuthProviderToggle({
|
||||
enabled,
|
||||
saving = false,
|
||||
onEnabledChange,
|
||||
className,
|
||||
}: NoAuthProviderToggleProps) {
|
||||
if (!onEnabledChange) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("inline-flex items-center gap-2 rounded-md px-1 py-1", className)}>
|
||||
<span className="text-sm font-medium text-text-main">
|
||||
{saving ? "Saving" : enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<Toggle
|
||||
size="lg"
|
||||
checked={enabled}
|
||||
disabled={saving}
|
||||
onChange={onEnabledChange}
|
||||
ariaLabel="Toggle no-auth provider"
|
||||
title={enabled ? "Disable provider" : "Enable provider"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export interface FeatureFlagDefinition {
|
||||
}
|
||||
|
||||
export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [
|
||||
// ──────────────── Security (7) ────────────────
|
||||
// ──────────────── Security (9) ────────────────
|
||||
{
|
||||
key: "REQUIRE_API_KEY",
|
||||
label: "Require API Key",
|
||||
@@ -93,7 +93,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [
|
||||
requiresRestart: false,
|
||||
warningLevel: "info",
|
||||
},
|
||||
|
||||
{
|
||||
key: "ALLOW_API_KEY_REVEAL",
|
||||
label: "API Key Reveal",
|
||||
description:
|
||||
"Allow authenticated dashboard users to reveal stored API keys instead of only seeing masked values.",
|
||||
descriptionI18nKey: "featureFlagAllowApiKeyRevealDescription",
|
||||
category: "security",
|
||||
defaultValue: "false",
|
||||
type: "boolean",
|
||||
requiresRestart: false,
|
||||
warningLevel: "danger",
|
||||
},
|
||||
// ──────────────── Network (7) ────────────────
|
||||
{
|
||||
key: "ENABLE_TLS_FINGERPRINT",
|
||||
|
||||
@@ -72,6 +72,18 @@ export function isCcCompatibleProviderEnabled(): boolean {
|
||||
return isFeatureFlagEnabled("ENABLE_CC_COMPATIBLE_PROVIDER");
|
||||
}
|
||||
|
||||
export function isApiKeyRevealEnabledFlag(): boolean {
|
||||
try {
|
||||
return isFeatureFlagEnabled("ALLOW_API_KEY_REVEAL");
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[featureFlags] Failed to resolve ALLOW_API_KEY_REVEAL, defaulting to disabled:",
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isModelCatalogNamesEnabled(): boolean {
|
||||
return isFeatureFlagEnabled("MODEL_CATALOG_INCLUDE_NAMES");
|
||||
}
|
||||
|
||||
55
src/shared/utils/noAuthProviders.ts
Normal file
55
src/shared/utils/noAuthProviders.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { NOAUTH_PROVIDERS, getProviderById } from "@/shared/constants/providers";
|
||||
|
||||
type ProviderWithAlias = { alias?: string };
|
||||
type NoAuthProviderEntry = { id: string; alias?: string };
|
||||
|
||||
const noAuthProviderEntries = Object.values(NOAUTH_PROVIDERS) as NoAuthProviderEntry[];
|
||||
|
||||
export function normalizeBlockedProviderSet(blockedProviders: unknown): Set<string> {
|
||||
const entries = blockedProviders instanceof Set ? Array.from(blockedProviders) : blockedProviders;
|
||||
return new Set(
|
||||
Array.isArray(entries)
|
||||
? entries.filter(
|
||||
(provider): provider is string => typeof provider === "string" && provider.length > 0
|
||||
)
|
||||
: []
|
||||
);
|
||||
}
|
||||
|
||||
export function isProviderBlockedByIdOrAlias(
|
||||
providerId: string,
|
||||
blockedProviders: unknown
|
||||
): boolean {
|
||||
const blockedProviderSet = normalizeBlockedProviderSet(blockedProviders);
|
||||
const provider = getProviderById(providerId) as ProviderWithAlias | undefined;
|
||||
return (
|
||||
blockedProviderSet.has(providerId) ||
|
||||
(typeof provider?.alias === "string" && blockedProviderSet.has(provider.alias))
|
||||
);
|
||||
}
|
||||
|
||||
export function isNoAuthProviderKey(...keys: Array<string | null | undefined>): boolean {
|
||||
return noAuthProviderEntries.some((provider) =>
|
||||
keys.some((key) => key === provider.id || key === provider.alias)
|
||||
);
|
||||
}
|
||||
|
||||
export function isNoAuthProviderBlocked(
|
||||
blockedProviders: unknown,
|
||||
...keys: Array<string | null | undefined>
|
||||
): boolean {
|
||||
const blockedProviderSet = normalizeBlockedProviderSet(blockedProviders);
|
||||
return noAuthProviderEntries.some(
|
||||
(provider) =>
|
||||
keys.some((key) => key === provider.id || key === provider.alias) &&
|
||||
(blockedProviderSet.has(provider.id) ||
|
||||
(typeof provider.alias === "string" && blockedProviderSet.has(provider.alias)))
|
||||
);
|
||||
}
|
||||
|
||||
export function isNoAuthRawProviderPrefix(providerId: string, prefix: string): boolean {
|
||||
const provider = noAuthProviderEntries.find((entry) => entry.id === providerId);
|
||||
return (
|
||||
typeof provider?.alias === "string" && provider.alias !== providerId && prefix === providerId
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
WEB_COOKIE_PROVIDERS,
|
||||
} from "@/shared/constants/providers";
|
||||
import { isModelExcludedByConnection } from "@/domain/connectionModelRules";
|
||||
import { isNoAuthProviderBlockedBySettings } from "./noAuthProviderSettings";
|
||||
import * as log from "../utils/logger";
|
||||
import { fisherYatesShuffle, getNextFromDeckSync } from "@/shared/utils/shuffleDeck";
|
||||
|
||||
@@ -776,18 +777,16 @@ function providerCanUseSyntheticNoAuthFallback(providerId: string): boolean {
|
||||
const providerDef = getProviderById(providerId) as
|
||||
| AnonymousFallbackProviderDefinition
|
||||
| undefined;
|
||||
const noAuthProviderDef = (
|
||||
NOAUTH_PROVIDERS as Record<string, AnonymousFallbackProviderDefinition | undefined>
|
||||
)[providerId];
|
||||
const webCookieProviderDef = (
|
||||
WEB_COOKIE_PROVIDERS as Record<string, AnonymousFallbackProviderDefinition | undefined>
|
||||
)[providerId];
|
||||
return (
|
||||
providerDef?.anonymousFallback === true ||
|
||||
Boolean(
|
||||
(NOAUTH_PROVIDERS as Record<string, AnonymousFallbackProviderDefinition | undefined>)[
|
||||
providerId
|
||||
]?.noAuth
|
||||
) ||
|
||||
Boolean(
|
||||
(WEB_COOKIE_PROVIDERS as Record<string, AnonymousFallbackProviderDefinition | undefined>)[
|
||||
providerId
|
||||
]?.noAuth
|
||||
)
|
||||
noAuthProviderDef?.noAuth === true ||
|
||||
webCookieProviderDef?.noAuth === true
|
||||
);
|
||||
}
|
||||
|
||||
@@ -963,6 +962,7 @@ export async function getProviderCredentials(
|
||||
WEB_COOKIE_PROVIDERS as Record<string, { noAuth?: boolean } | undefined>,
|
||||
];
|
||||
if (providerMaps.some((map) => map[resolvedId]?.noAuth)) {
|
||||
if (await isNoAuthProviderBlockedBySettings(resolvedId)) return null;
|
||||
// #3061: there is only one synthetic "noauth" connection for a no-auth
|
||||
// provider. If the caller already tried and excluded it (account-fallback
|
||||
// after a persistent upstream error), do NOT hand it back — that would let
|
||||
|
||||
18
src/sse/services/noAuthProviderSettings.ts
Normal file
18
src/sse/services/noAuthProviderSettings.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { isProviderBlockedByIdOrAlias } from "@/shared/utils/noAuthProviders";
|
||||
import * as log from "../utils/logger";
|
||||
|
||||
export async function isNoAuthProviderBlockedBySettings(providerId: string): Promise<boolean> {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
return isProviderBlockedByIdOrAlias(providerId, settings.blockedProviders);
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
"AUTH",
|
||||
`Could not read blocked provider settings for ${providerId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ process.env.API_KEY_SECRET = "test-api-key-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
const featureFlagsDb = await import("../../src/lib/db/featureFlags.ts");
|
||||
const listRoute = await import("../../src/app/api/keys/route.ts");
|
||||
const revealRoute = await import("../../src/app/api/keys/[id]/reveal/route.ts");
|
||||
|
||||
@@ -124,6 +125,20 @@ test("GET /api/keys/[id]/reveal returns the full key when reveal is enabled", as
|
||||
assert.equal(body.key, created.key);
|
||||
});
|
||||
|
||||
test("GET /api/keys/[id]/reveal honors the ALLOW_API_KEY_REVEAL feature flag override", async () => {
|
||||
featureFlagsDb.setFeatureFlagOverride("ALLOW_API_KEY_REVEAL", "true");
|
||||
const created = await apiKeysDb.createApiKey("Primary Key", MACHINE_ID);
|
||||
const request = new Request(`http://localhost/api/keys/${created.id}/reveal`);
|
||||
|
||||
const response = await revealRoute.GET(request, {
|
||||
params: Promise.resolve({ id: created.id }),
|
||||
});
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.key, created.key);
|
||||
});
|
||||
|
||||
test("GET /api/keys/[id]/reveal returns 404 for unknown keys even when reveal is enabled", async () => {
|
||||
process.env.ALLOW_API_KEY_REVEAL = "true";
|
||||
const request = new Request("http://localhost/api/keys/missing/reveal");
|
||||
|
||||
@@ -34,13 +34,13 @@ const {
|
||||
// Test group 1 — Flag definitions registry
|
||||
// ──────────────────────────────────────────────────────
|
||||
describe("featureFlagDefinitions", () => {
|
||||
it("has exactly 33 flag definitions", () => {
|
||||
assert.strictEqual(FEATURE_FLAG_DEFINITIONS.length, 33);
|
||||
it("has exactly 34 flag definitions", () => {
|
||||
assert.strictEqual(FEATURE_FLAG_DEFINITIONS.length, 34);
|
||||
});
|
||||
|
||||
it("has unique keys for all flags", () => {
|
||||
const keys = FEATURE_FLAG_DEFINITIONS.map((d) => d.key);
|
||||
assert.strictEqual(new Set(keys).size, 33);
|
||||
assert.strictEqual(new Set(keys).size, 34);
|
||||
});
|
||||
|
||||
it("has valid categories for all flags", () => {
|
||||
@@ -264,9 +264,9 @@ describe("resolveFeatureFlag", () => {
|
||||
});
|
||||
|
||||
describe("resolveAllFeatureFlags", () => {
|
||||
it("returns all 33 flags", () => {
|
||||
it("returns all 34 flags", () => {
|
||||
const all = resolveAllFeatureFlags();
|
||||
assert.strictEqual(all.length, 33);
|
||||
assert.strictEqual(all.length, 34);
|
||||
});
|
||||
|
||||
it("marks DB-overridden flags with source 'db'", () => {
|
||||
|
||||
@@ -1477,3 +1477,25 @@ test("v1 models catalog includes noAuth provider models when no DB connections e
|
||||
"catalog must not return opencode/* noAuth aliases because opencode/ routes to opencode-zen"
|
||||
);
|
||||
});
|
||||
|
||||
test("v1 models catalog hides disabled noAuth provider models", async () => {
|
||||
await settingsDb.updateSettings({ blockedProviders: ["opencode", "duckduckgo-web"] });
|
||||
|
||||
const response = await v1ModelsCatalog.getUnifiedModelsResponse(
|
||||
new Request("http://localhost/api/v1/models")
|
||||
);
|
||||
const body = (await response.json()) as any;
|
||||
const ids: string[] = body.data.map((item: any) => item.id);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(
|
||||
ids.some((id) => id.startsWith("oc/")),
|
||||
false,
|
||||
"OpenCode no-auth models must be hidden while no-auth providers are disabled"
|
||||
);
|
||||
assert.equal(
|
||||
ids.some((id) => id.startsWith("ddgw/")),
|
||||
false,
|
||||
"DuckDuckGo no-auth models must be hidden while no-auth providers are disabled"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "catalog-test-secret"
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const modelsDb = await import("../../src/lib/db/models.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
@@ -45,7 +46,12 @@ test("#3200 imported model on a noAuth provider (theoldllm) appears in /api/v1/m
|
||||
// theoldllm is a noAuth provider (alias "tllm") — it never creates a DB connection row.
|
||||
// Import a model that is NOT a built-in theoldllm model, so its presence is solely due
|
||||
// to the custom/imported path (the path the bug breaks).
|
||||
await modelsDb.addCustomModel("theoldllm", "my-imported-model-3200", "My Imported Model", "imported");
|
||||
await modelsDb.addCustomModel(
|
||||
"theoldllm",
|
||||
"my-imported-model-3200",
|
||||
"My Imported Model",
|
||||
"imported"
|
||||
);
|
||||
|
||||
const response = await v1ModelsCatalog.getUnifiedModelsResponse(
|
||||
new Request("http://localhost/api/v1/models")
|
||||
@@ -81,3 +87,26 @@ test("#3200 custom/imported models on auth providers still appear (no regression
|
||||
"auth-provider custom model must stay gated behind an eligible connection"
|
||||
);
|
||||
});
|
||||
|
||||
test("#3200 imported models on noAuth providers are hidden when the provider is disabled", async () => {
|
||||
await settingsDb.updateSettings({ blockedProviders: ["theoldllm"] });
|
||||
await modelsDb.addCustomModel(
|
||||
"theoldllm",
|
||||
"my-imported-model-disabled",
|
||||
"Hidden Imported Model",
|
||||
"imported"
|
||||
);
|
||||
|
||||
const response = await v1ModelsCatalog.getUnifiedModelsResponse(
|
||||
new Request("http://localhost/api/v1/models")
|
||||
);
|
||||
const body = (await response.json()) as { data: Array<{ id: string }> };
|
||||
const ids = new Set(body.data.map((m) => m.id));
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(
|
||||
ids.has("tllm/my-imported-model-disabled"),
|
||||
false,
|
||||
"imported noAuth provider models must stay hidden while the provider is disabled"
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user