"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 } from "@/shared/components"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config"; import { OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX } from "@/shared/constants/providers"; import Link from "next/link"; import { getErrorCode, getRelativeTime } from "@/shared/utils"; // Shared helper function to avoid code duplication between ProviderCard and ApiKeyProviderCard 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; } 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); 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 ); // Helper: check if connection is effectively active (cooldown expired) const getEffectiveStatus = (conn) => { const isCooldown = conn.rateLimitedUntil && new Date(conn.rateLimitedUntil).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; // Get latest error info const latestError = errorConns.sort((a, b) => new Date(b.lastErrorAt || 0) - new Date(a.lastErrorAt || 0) )[0]; const errorCode = latestError ? getErrorCode(latestError.lastError) : null; const errorTime = latestError?.lastErrorAt ? getRelativeTime(latestError.lastErrorAt) : null; return { connected, error, total, errorCode, errorTime }; }; 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", })); const apiKeyProviders = { ...APIKEY_PROVIDERS, ...compatibleProviders.reduce((acc, provider) => { acc[provider.id] = provider; return acc; }, {}), ...anthropicCompatibleProviders.reduce((acc, provider) => { acc[provider.id] = provider; return acc; }, {}), }; if (loading) { return (
); } return (
{/* OAuth Providers */}

OAuth Providers

{Object.entries(OAUTH_PROVIDERS).map(([key, info]) => ( ))}
{/* API Key Providers */}

API Key Providers

{Object.entries(apiKeyProviders).map(([key, info]) => ( ))}
setShowAddCompatibleModal(false)} onCreated={(node) => { setProviderNodes((prev) => [...prev, node]); setShowAddCompatibleModal(false); }} /> setShowAddAnthropicCompatibleModal(false)} onCreated={(node) => { setProviderNodes((prev) => [...prev, node]); setShowAddAnthropicCompatibleModal(false); }} />
); } function ProviderCard({ providerId, provider, stats }) { const { connected, error, errorCode, errorTime } = stats; const [imgError, setImgError] = useState(false); return (
{imgError ? ( {provider.textIcon || provider.id.slice(0, 2).toUpperCase()} ) : ( {provider.name} setImgError(true)} /> )}

{provider.name}

{getStatusDisplay(connected, error, errorCode)} {errorTime && • {errorTime}}
chevron_right
); } 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, }; // API Key providers - use image with textIcon fallback (same as OAuth providers) function ApiKeyProviderCard({ providerId, provider, stats }) { const { connected, error, errorCode, errorTime } = stats; const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX); const isAnthropicCompatible = providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX); const [imgError, setImgError] = useState(false); // Determine icon path: OpenAI Compatible providers use specialized icons const getIconPath = () => { if (isCompatible) { return provider.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png"; } if (isAnthropicCompatible) { return "/providers/anthropic-m.png"; // Use Anthropic icon as base } return `/providers/${provider.id}.png`; }; return (
{imgError ? ( {provider.textIcon || provider.id.slice(0, 2).toUpperCase()} ) : ( {provider.name} setImgError(true)} /> )}

{provider.name}

{getStatusDisplay(connected, error, errorCode)} {isCompatible && ( {provider.apiType === "responses" ? "Responses" : "Chat"} )} {isAnthropicCompatible && ( Messages )} {errorTime && • {errorTime}}
chevron_right
); } 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, }; 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(() => { // Reset validation when modal opens 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, };