"use client"; import { useState, useEffect } from "react"; import PropTypes from "prop-types"; import { Card, CardSkeleton, Badge, Button, Toggle, } from "@/shared/components"; import ProviderIcon from "@/shared/components/ProviderIcon"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config"; import { FREE_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX, } from "@/shared/constants/providers"; import Link from "next/link"; import { getErrorCode, getRelativeTime } from "@/shared/utils"; import { useNotificationStore } from "@/store/notificationStore"; import { useHeaderSearchStore } from "@/store/headerSearchStore"; import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge"; import AddCompatibleModal from "./components/AddCompatibleModal"; function getStatusDisplay(connected, error, errorCode) { const parts = []; if (connected > 0) { parts.push( {connected} Connected , ); } if (error > 0) { const errText = errorCode ? `${error} Error (${errorCode})` : `${error} Error`; parts.push( {errText} , ); } if (parts.length === 0) { return No connections; } return parts; } function getConnectionErrorTag(connection) { if (!connection) return null; const explicitType = connection.lastErrorType; if (explicitType === "runtime_error") return "RUNTIME"; if ( explicitType === "upstream_auth_error" || explicitType === "auth_missing" || explicitType === "token_refresh_failed" || explicitType === "token_expired" ) return "AUTH"; if (explicitType === "upstream_rate_limited") return "429"; if (explicitType === "upstream_unavailable") return "5XX"; if (explicitType === "network_error") return "NET"; const numericCode = Number(connection.errorCode); if (Number.isFinite(numericCode) && numericCode >= 400) return String(numericCode); const fromMessage = getErrorCode(connection.lastError); if (fromMessage === "401" || fromMessage === "403") return "AUTH"; if (fromMessage && fromMessage !== "ERR") return fromMessage; const msg = (connection.lastError || "").toLowerCase(); if ( msg.includes("runtime") || msg.includes("not runnable") || msg.includes("not installed") ) return "RUNTIME"; if ( msg.includes("invalid api key") || msg.includes("token invalid") || msg.includes("revoked") || msg.includes("unauthorized") ) return "AUTH"; return "ERR"; } const APIKEY_INITIAL_VISIBLE = 20; export default function ProvidersPage() { const [connections, setConnections] = useState([]); const [providerNodes, setProviderNodes] = useState([]); const [loading, setLoading] = useState(true); const [showAllApikey, setShowAllApikey] = useState(false); const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false); const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] = useState(false); const [testingMode, setTestingMode] = useState(null); const [testResults, setTestResults] = useState(null); const notify = useNotificationStore(); const searchQuery = useHeaderSearchStore((s) => s.query); const registerSearch = useHeaderSearchStore((s) => s.register); const unregisterSearch = useHeaderSearchStore((s) => s.unregister); useEffect(() => { registerSearch("Search providers..."); return () => unregisterSearch(); }, [registerSearch, unregisterSearch]); const matchSearch = (name) => !searchQuery.trim() || name.toLowerCase().includes(searchQuery.trim().toLowerCase()); const sortByPriority = (entries, authType) => [...entries].sort(([ka, a], [kb, b]) => { const pa = a.priority ?? 999; const pb = b.priority ?? 999; if (pa !== pb) return pa - pb; const sa = getProviderStats(ka, authType); const sb = getProviderStats(kb, authType); const ca = sa.connected > 0 ? 1 : 0; const cb = sb.connected > 0 ? 1 : 0; if (ca !== cb) return cb - ca; return (a.name || "").localeCompare(b.name || ""); }); const sortItemsByPriority = (items, authType) => [...items].sort((a, b) => { const pa = a.priority ?? 999; const pb = b.priority ?? 999; if (pa !== pb) return pa - pb; const sa = getProviderStats(a.id, authType); const sb = getProviderStats(b.id, authType); const ca = sa.connected > 0 ? 1 : 0; const cb = sb.connected > 0 ? 1 : 0; if (ca !== cb) return cb - ca; return (a.name || "").localeCompare(b.name || ""); }); useEffect(() => { const fetchData = async () => { try { const [connectionsRes, nodesRes] = await Promise.all([ fetch("/api/providers"), fetch("/api/provider-nodes"), ]); const connectionsData = await connectionsRes.json(); const nodesData = await nodesRes.json(); if (connectionsRes.ok) setConnections(connectionsData.connections || []); if (nodesRes.ok) setProviderNodes(nodesData.nodes || []); } catch (error) { console.log("Error fetching data:", error); } finally { setLoading(false); } }; fetchData(); }, []); const getProviderStats = (providerId, authType) => { const providerConnections = connections.filter( (c) => c.provider === providerId && c.authType === authType, ); const getEffectiveStatus = (conn) => { const isCooldown = Object.entries(conn).some( ([k, v]) => k.startsWith("modelLock_") && v && new Date(v).getTime() > Date.now(), ); return conn.testStatus === "unavailable" && !isCooldown ? "active" : conn.testStatus; }; const connected = providerConnections.filter((c) => { const status = getEffectiveStatus(c); return status === "active" || status === "success"; }).length; const errorConns = providerConnections.filter((c) => { const status = getEffectiveStatus(c); return ( status === "error" || status === "expired" || status === "unavailable" ); }); const error = errorConns.length; const total = providerConnections.length; const allDisabled = total > 0 && providerConnections.every((c) => c.isActive === false); const latestError = errorConns.sort( (a, b) => new Date(b.lastErrorAt || 0) - new Date(a.lastErrorAt || 0), )[0]; const errorCode = latestError ? getConnectionErrorTag(latestError) : null; const errorTime = latestError?.lastErrorAt ? getRelativeTime(latestError.lastErrorAt) : null; return { connected, error, total, errorCode, errorTime, allDisabled }; }; // Toggle all connections for a provider on/off const handleToggleProvider = async (providerId, authType, newActive) => { const providerConns = connections.filter( (c) => c.provider === providerId && c.authType === authType, ); setConnections((prev) => prev.map((c) => c.provider === providerId && c.authType === authType ? { ...c, isActive: newActive } : c, ), ); await Promise.allSettled( providerConns.map((c) => fetch(`/api/providers/${c.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ isActive: newActive }), }), ), ); }; const handleBatchTest = async (mode, providerId = null) => { if (testingMode) return; setTestingMode(mode === "provider" ? providerId : mode); setTestResults(null); try { const res = await fetch("/api/providers/test-batch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode, providerId }), }); const data = await res.json(); setTestResults(data); if (data.summary) { const { passed, failed, total } = data.summary; if (failed === 0) notify.success(`All ${total} tests passed`); else notify.warning(`${passed}/${total} passed, ${failed} failed`); } } catch (error) { setTestResults({ error: "Test request failed" }); notify.error("Provider test failed"); } finally { setTestingMode(null); } }; const compatibleProviders = providerNodes .filter((node) => node.type === "openai-compatible") .map((node) => ({ id: node.id, name: node.name || "OpenAI Compatible", color: "#10A37F", textIcon: "OC", apiType: node.apiType, })) .filter((p) => matchSearch(p.name)); const anthropicCompatibleProviders = providerNodes .filter((node) => node.type === "anthropic-compatible") .map((node) => ({ id: node.id, name: node.name || "Anthropic Compatible", color: "#D97757", textIcon: "AC", })) .filter((p) => matchSearch(p.name)); const oauthEntries = sortByPriority( Object.entries(OAUTH_PROVIDERS).filter(([, info]) => !info.hidden && matchSearch(info.name)), "oauth", ); const freeEntries = Object.entries(FREE_PROVIDERS).filter( ([, info]) => !info.hidden && matchSearch(info.name), ); const freeTierEntries = sortByPriority( Object.entries(FREE_TIER_PROVIDERS).filter( ([, info]) => !info.hidden && matchSearch(info.name), ), "freeTier", ); // API Key: connected providers first, then alphabetical by name const apikeyEntries = Object.entries(APIKEY_PROVIDERS) .filter( ([, info]) => !info.hidden && (info.serviceKinds ?? ["llm"]).includes("llm") && matchSearch(info.name), ) .sort(([ka, a], [kb, b]) => { const ca = getProviderStats(ka, "apikey").total > 0 ? 0 : 1; const cb = getProviderStats(kb, "apikey").total > 0 ? 0 : 1; if (ca !== cb) return ca - cb; return (a.name || "").localeCompare(b.name || ""); }); const isApikeySearching = !!searchQuery.trim(); const visibleApikeyEntries = isApikeySearching || showAllApikey ? apikeyEntries : apikeyEntries.slice(0, APIKEY_INITIAL_VISIBLE); const hiddenApikeyCount = apikeyEntries.length - APIKEY_INITIAL_VISIBLE; if (loading) { return (
); } const hasAnyResult = oauthEntries.length > 0 || freeEntries.length > 0 || freeTierEntries.length > 0 || apikeyEntries.length > 0 || compatibleProviders.length > 0 || anthropicCompatibleProviders.length > 0; return (
{!hasAnyResult && (
search_off

No providers match your search

)} {/* Custom Providers (OpenAI/Anthropic Compatible) — dynamic */}

Custom Providers (OpenAI/Anthropic Compatible){" "}

{compatibleProviders.length === 0 && anthropicCompatibleProviders.length === 0 ? (
extension No custom providers — use buttons above to add OpenAI/Anthropic compatible endpoints
) : (
{[...compatibleProviders, ...anthropicCompatibleProviders].map( (info) => ( handleToggleProvider(info.id, "apikey", active) } /> ), )}
)}
{/* OAuth Providers */} {oauthEntries.length > 0 && (

OAuth Providers

{oauthEntries.map(([key, info]) => ( handleToggleProvider(key, "oauth", active)} /> ))}
)} {/* Free Tier Providers */} {(freeEntries.length > 0 || freeTierEntries.length > 0) && (

Free Tier Providers

{freeEntries.map(([key, info]) => ( handleToggleProvider(key, "oauth", active)} /> ))} {freeTierEntries.map(([key, info]) => ( handleToggleProvider(key, "apikey", active)} /> ))}
)} {/* API Key Providers — fixed list */} {apikeyEntries.length > 0 && (

API Key Providers{" "}

{visibleApikeyEntries.map(([key, info]) => ( handleToggleProvider(key, "apikey", active)} /> ))}
{!isApikeySearching && !showAllApikey && hiddenApikeyCount > 0 && ( )}
)} {/* Web Cookie Providers — use browser subscription cookie instead of API key */} {/*

Web Cookie Providers{" "}

{Object.entries(WEB_COOKIE_PROVIDERS).map(([key, info]) => ( handleToggleProvider(key, "apikey", active)} /> ))}
*/} setShowAddCompatibleModal(false)} onCreated={(node) => { setProviderNodes((prev) => [...prev, node]); setShowAddCompatibleModal(false); }} /> setShowAddAnthropicCompatibleModal(false)} onCreated={(node) => { setProviderNodes((prev) => [...prev, node]); setShowAddAnthropicCompatibleModal(false); }} /> {/* Test Results Modal */} {testResults && (
setTestResults(null)} >
e.stopPropagation()} >

Test Results

)}
); } function ProviderCard({ providerId, provider, stats, authType, onToggle }) { const { connected, error, errorCode, errorTime, allDisabled } = stats; const isNoAuth = !!provider.noAuth; const dotColors = { free: "bg-green-500", oauth: "bg-blue-500", apikey: "bg-amber-500", compatible: "bg-orange-500", }; const dotLabels = { free: "Free", oauth: "OAuth", apikey: "API Key", compatible: "Compatible", }; return (
7 ? provider.color : provider.color + "15"}`, }} >

{provider.name}

{allDisabled ? ( pause_circle Disabled ) : isNoAuth ? ( Ready ) : ( <> {getStatusDisplay(connected, error, errorCode)} {errorTime && ( {errorTime} )} )}
{stats.total > 0 && (
{ e.preventDefault(); e.stopPropagation(); onToggle(!allDisabled ? false : true); }} > {}} title={allDisabled ? "Enable provider" : "Disable provider"} />
)}
); } ProviderCard.propTypes = { providerId: PropTypes.string.isRequired, provider: PropTypes.shape({ id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, color: PropTypes.string, textIcon: PropTypes.string, }).isRequired, stats: PropTypes.shape({ connected: PropTypes.number, error: PropTypes.number, errorCode: PropTypes.string, errorTime: PropTypes.string, }).isRequired, authType: PropTypes.string, onToggle: PropTypes.func, }; function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle, }) { const { connected, error, errorCode, errorTime, allDisabled } = stats; const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX); const isAnthropicCompatible = providerId.startsWith( ANTHROPIC_COMPATIBLE_PREFIX, ); const dotColors = { free: "bg-green-500", oauth: "bg-blue-500", apikey: "bg-amber-500", compatible: "bg-orange-500", }; const dotLabels = { free: "Free", oauth: "OAuth", apikey: "API Key", compatible: "Compatible", }; const getIconPath = () => { if (isCompatible) return provider.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png"; if (isAnthropicCompatible) return "/providers/anthropic-m.png"; return `/providers/${provider.id}.png`; }; return (
7 ? provider.color : provider.color + "15"}`, }} >

{provider.name}

{allDisabled ? ( pause_circle Disabled ) : ( <> {getStatusDisplay(connected, error, errorCode)} {isCompatible && ( {provider.apiType === "responses" ? "Responses" : "Chat"} )} {isAnthropicCompatible && ( Messages )} {errorTime && ( {errorTime} )} )}
{stats.total > 0 && (
{ e.preventDefault(); e.stopPropagation(); onToggle(!allDisabled ? false : true); }} > {}} title={allDisabled ? "Enable provider" : "Disable provider"} />
)}
); } ApiKeyProviderCard.propTypes = { providerId: PropTypes.string.isRequired, provider: PropTypes.shape({ id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, color: PropTypes.string, textIcon: PropTypes.string, apiType: PropTypes.string, }).isRequired, stats: PropTypes.shape({ connected: PropTypes.number, error: PropTypes.number, errorCode: PropTypes.string, errorTime: PropTypes.string, }).isRequired, authType: PropTypes.string, onToggle: PropTypes.func, }; function ProviderTestResultsView({ results }) { if (results.error && !results.results) { return (
error

{results.error}

); } const { summary, mode } = results; const items = results.results || []; const modeLabel = { oauth: "OAuth", free: "Free", apikey: "API Key", provider: "Provider", all: "All", }[mode] || mode; return (
{summary && (
{modeLabel} Test {summary.passed} passed {summary.failed > 0 && ( {summary.failed} failed )} {summary.total} tested
)} {items.map((r, i) => (
{r.valid ? "check_circle" : "error"}
{r.connectionName} ({r.provider})
{r.latencyMs !== undefined && ( {r.latencyMs}ms )} {r.valid ? "OK" : r.diagnosis?.type || "ERROR"}
))} {items.length === 0 && (
No active connections found for this group.
)}
); } ProviderTestResultsView.propTypes = { results: PropTypes.shape({ mode: PropTypes.string, results: PropTypes.array, summary: PropTypes.shape({ total: PropTypes.number, passed: PropTypes.number, failed: PropTypes.number, }), error: PropTypes.string, }).isRequired, };