Add provider auth visibility controls (#3953)

Integrated into release/v3.8.27
This commit is contained in:
Randi
2026-06-16 08:43:11 -04:00
committed by GitHub
parent ef64392e06
commit 18aa874a4f
24 changed files with 521 additions and 143 deletions

View File

@@ -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

View File

@@ -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 |

View File

@@ -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

View File

@@ -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) {

View File

@@ -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>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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">

View File

@@ -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">

View File

@@ -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" &&

View File

@@ -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 =

View File

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

View File

@@ -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;

View File

@@ -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

View File

@@ -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>
);

View 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>
);
}

View File

@@ -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",

View File

@@ -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");
}

View 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
);
}

View File

@@ -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

View 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;
}
}

View File

@@ -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");

View File

@@ -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'", () => {

View File

@@ -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"
);
});

View File

@@ -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"
);
});