"use client"; import { useState, useEffect } from "react"; import Image from "next/image"; import PropTypes from "prop-types"; import { Card, CardSkeleton, Badge, Button, Input, Modal, Select, Toggle } from "@/shared/components"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config"; import { FREE_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 ModelAvailabilityBadge from "./components/ModelAvailabilityBadge"; 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"; } export default function ProvidersPage() { const [connections, setConnections] = useState([]); const [providerNodes, setProviderNodes] = useState([]); const [loading, setLoading] = useState(true); const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false); const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] = useState(false); const [testingMode, setTestingMode] = useState(null); const [testResults, setTestResults] = useState(null); const notify = useNotificationStore(); 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, })); const anthropicCompatibleProviders = providerNodes .filter((node) => node.type === "anthropic-compatible") .map((node) => ({ id: node.id, name: node.name || "Anthropic Compatible", color: "#D97757", textIcon: "AC", })); if (loading) { return (
); } return (
{/* OAuth Providers */}

OAuth Providers

{Object.entries(OAUTH_PROVIDERS).map(([key, info]) => ( handleToggleProvider(key, "oauth", active)} /> ))}
{/* Free Providers */}

Free Providers

{Object.entries(FREE_PROVIDERS).map(([key, info]) => ( handleToggleProvider(key, "oauth", active)} /> ))}
{/* API Key Providers — fixed list */}

API Key Providers{" "}

{Object.entries(APIKEY_PROVIDERS).map(([key, info]) => ( handleToggleProvider(key, "apikey", active)} /> ))}
{/* API Key Compatible Providers — dynamic (OpenAI/Anthropic compatible) */}

API Key Compatible Providers{" "}

{/* {(compatibleProviders.length > 0 || anthropicCompatibleProviders.length > 0) && ( )} */}
{compatibleProviders.length === 0 && anthropicCompatibleProviders.length === 0 ? (
extension

No compatible providers added yet

Use the buttons above to add OpenAI or Anthropic compatible endpoints

) : (
{[...compatibleProviders, ...anthropicCompatibleProviders].map((info) => ( handleToggleProvider(info.id, "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 [imgError, setImgError] = useState(false); 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 (
{imgError ? ( {provider.textIcon || provider.id.slice(0, 2).toUpperCase()} ) : ( {provider.name} setImgError(true)} /> )}

{provider.name}

{allDisabled ? ( pause_circle Disabled ) : ( <> {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 [imgError, setImgError] = useState(false); 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 (
{imgError ? ( {provider.textIcon || provider.id.slice(0, 2).toUpperCase()} ) : ( {provider.name} setImgError(true)} /> )}

{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 AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) { const [formData, setFormData] = useState({ name: "", prefix: "", apiType: "chat", baseUrl: "https://api.openai.com/v1", }); const [submitting, setSubmitting] = useState(false); const [checkKey, setCheckKey] = useState(""); const [validating, setValidating] = useState(false); const [validationResult, setValidationResult] = useState(null); const apiTypeOptions = [ { value: "chat", label: "Chat Completions" }, { value: "responses", label: "Responses API" }, ]; useEffect(() => { const defaultBaseUrl = "https://api.openai.com/v1"; setFormData((prev) => ({ ...prev, baseUrl: defaultBaseUrl })); }, [formData.apiType]); const handleSubmit = async () => { if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return; setSubmitting(true); try { const res = await fetch("/api/provider-nodes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: formData.name, prefix: formData.prefix, apiType: formData.apiType, baseUrl: formData.baseUrl, type: "openai-compatible", }), }); const data = await res.json(); if (res.ok) { onCreated(data.node); setFormData({ name: "", prefix: "", apiType: "chat", baseUrl: "https://api.openai.com/v1" }); setCheckKey(""); setValidationResult(null); } } catch (error) { console.log("Error creating OpenAI Compatible node:", error); } finally { setSubmitting(false); } }; const handleValidate = async () => { setValidating(true); try { const res = await fetch("/api/provider-nodes/validate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ baseUrl: formData.baseUrl, apiKey: checkKey, type: "openai-compatible" }), }); const data = await res.json(); setValidationResult(data.valid ? "success" : "failed"); } catch { setValidationResult("failed"); } finally { setValidating(false); } }; return (
setFormData({ ...formData, name: e.target.value })} placeholder="OpenAI Compatible (Prod)" hint="Required. A friendly label for this node." /> setFormData({ ...formData, prefix: e.target.value })} placeholder="oc-prod" hint="Required. Used as the provider prefix for model IDs." /> setFormData({ ...formData, baseUrl: e.target.value })} placeholder="https://api.openai.com/v1" hint="Use the base URL (ending in /v1) for your OpenAI-compatible API." />
setCheckKey(e.target.value)} className="flex-1" />
{validationResult && ( {validationResult === "success" ? "Valid" : "Invalid"} )}
); } AddOpenAICompatibleModal.propTypes = { isOpen: PropTypes.bool.isRequired, onClose: PropTypes.func.isRequired, onCreated: PropTypes.func.isRequired, }; function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) { const [formData, setFormData] = useState({ name: "", prefix: "", baseUrl: "https://api.anthropic.com/v1", }); const [submitting, setSubmitting] = useState(false); const [checkKey, setCheckKey] = useState(""); const [validating, setValidating] = useState(false); const [validationResult, setValidationResult] = useState(null); useEffect(() => { if (isOpen) { setValidationResult(null); setCheckKey(""); } }, [isOpen]); const handleSubmit = async () => { if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return; setSubmitting(true); try { const res = await fetch("/api/provider-nodes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: formData.name, prefix: formData.prefix, baseUrl: formData.baseUrl, type: "anthropic-compatible", }), }); const data = await res.json(); if (res.ok) { onCreated(data.node); setFormData({ name: "", prefix: "", baseUrl: "https://api.anthropic.com/v1" }); setCheckKey(""); setValidationResult(null); } } catch (error) { console.log("Error creating Anthropic Compatible node:", error); } finally { setSubmitting(false); } }; const handleValidate = async () => { setValidating(true); try { const res = await fetch("/api/provider-nodes/validate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ baseUrl: formData.baseUrl, apiKey: checkKey, type: "anthropic-compatible" }), }); const data = await res.json(); setValidationResult(data.valid ? "success" : "failed"); } catch { setValidationResult("failed"); } finally { setValidating(false); } }; return (
setFormData({ ...formData, name: e.target.value })} placeholder="Anthropic Compatible (Prod)" hint="Required. A friendly label for this node." /> setFormData({ ...formData, prefix: e.target.value })} placeholder="ac-prod" hint="Required. Used as the provider prefix for model IDs." /> setFormData({ ...formData, baseUrl: e.target.value })} placeholder="https://api.anthropic.com/v1" hint="Use the base URL (ending in /v1) for your Anthropic-compatible API. The system will append /messages." />
setCheckKey(e.target.value)} className="flex-1" />
{validationResult && ( {validationResult === "success" ? "Valid" : "Invalid"} )}
); } AddAnthropicCompatibleModal.propTypes = { isOpen: PropTypes.bool.isRequired, onClose: PropTypes.func.isRequired, onCreated: PropTypes.func.isRequired, }; 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, };