"use client"; import { useState, useEffect, useCallback } from "react"; import { useParams, useRouter } from "next/navigation"; import Link from "next/link"; import Image from "next/image"; import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal, NoAuthProxyCard } from "@/shared/components"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS, THINKING_CONFIG } from "@/shared/constants/providers"; import { getModelsByProviderId } from "@/shared/constants/models"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { fetchSuggestedModels } from "@/shared/utils/providerModelsFetcher"; import ModelRow from "./ModelRow"; import PassthroughModelsSection from "./PassthroughModelsSection"; import CompatibleModelsSection from "./CompatibleModelsSection"; import ConnectionRow from "./ConnectionRow"; import AddApiKeyModal from "./AddApiKeyModal"; import EditCompatibleNodeModal from "./EditCompatibleNodeModal"; import AddCustomModelModal from "./AddCustomModelModal"; export default function ProviderDetailPage() { const params = useParams(); const router = useRouter(); const providerId = params.id; const [connections, setConnections] = useState([]); const [loading, setLoading] = useState(true); const [providerNode, setProviderNode] = useState(null); const [proxyPools, setProxyPools] = useState([]); const [showOAuthModal, setShowOAuthModal] = useState(false); const [showIFlowCookieModal, setShowIFlowCookieModal] = useState(false); const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [showEditNodeModal, setShowEditNodeModal] = useState(false); const [showBulkProxyModal, setShowBulkProxyModal] = useState(false); const [selectedConnection, setSelectedConnection] = useState(null); const [modelAliases, setModelAliases] = useState({}); const [headerImgError, setHeaderImgError] = useState(false); const [modelTestResults, setModelTestResults] = useState({}); const [modelsTestError, setModelsTestError] = useState(""); const [testingModelId, setTestingModelId] = useState(null); const [showAddCustomModel, setShowAddCustomModel] = useState(false); const [selectedConnectionIds, setSelectedConnectionIds] = useState([]); const [bulkProxyPoolId, setBulkProxyPoolId] = useState("__none__"); const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false); const [providerStrategy, setProviderStrategy] = useState(null); // null = use global, "round-robin" = override const [providerStickyLimit, setProviderStickyLimit] = useState(""); const [thinkingMode, setThinkingMode] = useState("auto"); const [suggestedModels, setSuggestedModels] = useState([]); const [kiloFreeModels, setKiloFreeModels] = useState([]); const { copied, copy } = useCopyToClipboard(); const providerInfo = providerNode ? { id: providerNode.id, name: providerNode.name || (providerNode.type === "anthropic-compatible" ? "Anthropic Compatible" : "OpenAI Compatible"), color: providerNode.type === "anthropic-compatible" ? "#D97757" : "#10A37F", textIcon: providerNode.type === "anthropic-compatible" ? "AC" : "OC", apiType: providerNode.apiType, baseUrl: providerNode.baseUrl, type: providerNode.type, } : (OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId] || FREE_PROVIDERS[providerId] || FREE_TIER_PROVIDERS[providerId] || WEB_COOKIE_PROVIDERS[providerId]); const isOAuth = !!OAUTH_PROVIDERS[providerId] || !!FREE_PROVIDERS[providerId]; const isFreeNoAuth = !!FREE_PROVIDERS[providerId]?.noAuth; const models = getModelsByProviderId(providerId); const providerAlias = getProviderAlias(providerId); const isOpenAICompatible = isOpenAICompatibleProvider(providerId); const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId); const isCompatible = isOpenAICompatible || isAnthropicCompatible; const thinkingConfig = AI_PROVIDERS[providerId]?.thinkingConfig || THINKING_CONFIG.extended; const providerStorageAlias = isCompatible ? providerId : providerAlias; const providerDisplayAlias = isCompatible ? (providerNode?.prefix || providerId) : providerAlias; // Define callbacks BEFORE the useEffect that uses them const fetchAliases = useCallback(async () => { try { const res = await fetch("/api/models/alias"); const data = await res.json(); if (res.ok) { setModelAliases(data.aliases || {}); } } catch (error) { console.log("Error fetching aliases:", error); } }, []); // Fetch free models from Kilo API for kilocode provider useEffect(() => { if (providerId !== "kilocode") return; fetch("/api/providers/kilo/free-models") .then((res) => res.json()) .then((data) => { if (data.models?.length) setKiloFreeModels(data.models); }) .catch(() => {}); }, [providerId]); const fetchConnections = useCallback(async () => { try { const [connectionsRes, nodesRes, proxyPoolsRes, settingsRes] = await Promise.all([ fetch("/api/providers", { cache: "no-store" }), fetch("/api/provider-nodes", { cache: "no-store" }), fetch("/api/proxy-pools?isActive=true", { cache: "no-store" }), fetch("/api/settings", { cache: "no-store" }), ]); const connectionsData = await connectionsRes.json(); const nodesData = await nodesRes.json(); const proxyPoolsData = await proxyPoolsRes.json(); const settingsData = settingsRes.ok ? await settingsRes.json() : {}; if (connectionsRes.ok) { const filtered = (connectionsData.connections || []).filter(c => c.provider === providerId); setConnections(filtered); } if (proxyPoolsRes.ok) { setProxyPools(proxyPoolsData.proxyPools || []); } // Load per-provider strategy override const override = (settingsData.providerStrategies || {})[providerId] || {}; setProviderStrategy(override.fallbackStrategy || null); setProviderStickyLimit(override.stickyRoundRobinLimit != null ? String(override.stickyRoundRobinLimit) : "1"); // Load per-provider thinking config const thinkingCfg = (settingsData.providerThinking || {})[providerId] || {}; setThinkingMode(thinkingCfg.mode || "auto"); if (nodesRes.ok) { let node = (nodesData.nodes || []).find((entry) => entry.id === providerId) || null; // Newly created compatible nodes can be briefly unavailable on one worker. // Retry a few times before showing "Provider not found". if (!node && isCompatible) { for (let attempt = 0; attempt < 3; attempt += 1) { await new Promise((resolve) => setTimeout(resolve, 150)); const retryRes = await fetch("/api/provider-nodes", { cache: "no-store" }); if (!retryRes.ok) continue; const retryData = await retryRes.json(); node = (retryData.nodes || []).find((entry) => entry.id === providerId) || null; if (node) break; } } setProviderNode(node); } } catch (error) { console.log("Error fetching connections:", error); } finally { setLoading(false); } }, [providerId, isCompatible]); const handleUpdateNode = async (formData) => { try { const res = await fetch(`/api/provider-nodes/${providerId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(formData), }); const data = await res.json(); if (res.ok) { setProviderNode(data.node); await fetchConnections(); setShowEditNodeModal(false); } } catch (error) { console.log("Error updating provider node:", error); } }; const saveProviderStrategy = async (strategy, stickyLimit) => { try { const settingsRes = await fetch("/api/settings", { cache: "no-store" }); const settingsData = settingsRes.ok ? await settingsRes.json() : {}; const current = settingsData.providerStrategies || {}; // Build override: null strategy means remove override, use global const override = {}; if (strategy) override.fallbackStrategy = strategy; if (strategy === "round-robin" && stickyLimit !== "") { override.stickyRoundRobinLimit = Number(stickyLimit) || 3; } const updated = { ...current }; if (Object.keys(override).length === 0) { delete updated[providerId]; } else { updated[providerId] = override; } await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ providerStrategies: updated }), }); } catch (error) { console.log("Error saving provider strategy:", error); } }; const handleRoundRobinToggle = (enabled) => { const strategy = enabled ? "round-robin" : null; const sticky = enabled ? (providerStickyLimit || "1") : providerStickyLimit; if (enabled && !providerStickyLimit) setProviderStickyLimit("1"); setProviderStrategy(strategy); saveProviderStrategy(strategy, sticky); }; const handleStickyLimitChange = (value) => { setProviderStickyLimit(value); saveProviderStrategy("round-robin", value); }; const saveThinkingConfig = async (mode) => { try { const settingsRes = await fetch("/api/settings", { cache: "no-store" }); const settingsData = settingsRes.ok ? await settingsRes.json() : {}; const current = settingsData.providerThinking || {}; const updated = { ...current }; if (!mode || mode === "auto") { delete updated[providerId]; } else { updated[providerId] = { mode }; } await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ providerThinking: updated }), }); } catch (error) { console.log("Error saving thinking config:", error); } }; const handleThinkingModeChange = (mode) => { setThinkingMode(mode); saveThinkingConfig(mode); }; useEffect(() => { fetchConnections(); fetchAliases(); }, [fetchConnections, fetchAliases]); // Fetch suggested models from provider's public API (if configured) useEffect(() => { const fetcher = (OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId] || FREE_PROVIDERS[providerId] || FREE_TIER_PROVIDERS[providerId])?.modelsFetcher; if (!fetcher) return; fetchSuggestedModels(fetcher).then(setSuggestedModels); }, [providerId]); const handleSetAlias = async (modelId, alias, providerAliasOverride = providerAlias) => { const fullModel = `${providerAliasOverride}/${modelId}`; try { const res = await fetch("/api/models/alias", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: fullModel, alias }), }); if (res.ok) { await fetchAliases(); } else { const data = await res.json(); alert(data.error || "Failed to set alias"); } } catch (error) { console.log("Error setting alias:", error); } }; const handleDeleteAlias = async (alias) => { try { const res = await fetch(`/api/models/alias?alias=${encodeURIComponent(alias)}`, { method: "DELETE", }); if (res.ok) { await fetchAliases(); } } catch (error) { console.log("Error deleting alias:", error); } }; const handleDelete = async (id) => { if (!confirm("Delete this connection?")) return; try { const res = await fetch(`/api/providers/${id}`, { method: "DELETE" }); if (res.ok) { setConnections(connections.filter(c => c.id !== id)); } } catch (error) { console.log("Error deleting connection:", error); } }; const handleOAuthSuccess = () => { fetchConnections(); setShowOAuthModal(false); }; const handleIFlowCookieSuccess = () => { fetchConnections(); setShowIFlowCookieModal(false); }; const handleSaveApiKey = async (formData) => { try { const res = await fetch("/api/providers", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider: providerId, ...formData }), }); if (res.ok) { await fetchConnections(); setShowAddApiKeyModal(false); } } catch (error) { console.log("Error saving connection:", error); } }; const handleUpdateConnection = async (formData) => { try { const res = await fetch(`/api/providers/${selectedConnection.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(formData), }); if (res.ok) { await fetchConnections(); setShowEditModal(false); } } catch (error) { console.log("Error updating connection:", error); } }; const handleUpdateConnectionStatus = async (id, isActive) => { try { const res = await fetch(`/api/providers/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ isActive }), }); if (res.ok) { setConnections(prev => prev.map(c => c.id === id ? { ...c, isActive } : c)); } } catch (error) { console.log("Error updating connection status:", error); } }; const handleSwapPriority = async (index1, index2) => { // Optimistic update state const newConnections = [...connections]; [newConnections[index1], newConnections[index2]] = [newConnections[index2], newConnections[index1]]; setConnections(newConnections); try { await Promise.all([ fetch(`/api/providers/${newConnections[index1].id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ priority: index1 }), }), fetch(`/api/providers/${newConnections[index2].id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ priority: index2 }), }), ]); } catch (error) { console.log("Error swapping priority:", error); await fetchConnections(); } }; const selectedConnections = connections.filter((conn) => selectedConnectionIds.includes(conn.id)); const allSelected = connections.length > 0 && selectedConnectionIds.length === connections.length; const toggleSelectConnection = (connectionId) => { setSelectedConnectionIds((prev) => ( prev.includes(connectionId) ? prev.filter((id) => id !== connectionId) : [...prev, connectionId] )); }; const toggleSelectAllConnections = () => { if (allSelected) { setSelectedConnectionIds([]); return; } setSelectedConnectionIds(connections.map((conn) => conn.id)); }; const clearSelection = () => { setSelectedConnectionIds([]); setBulkProxyPoolId("__none__"); }; useEffect(() => { setSelectedConnectionIds((prev) => prev.filter((id) => connections.some((conn) => conn.id === id))); }, [connections]); const selectedProxySummary = (() => { if (selectedConnections.length === 0) return ""; const poolIds = new Set(selectedConnections.map((conn) => conn.providerSpecificData?.proxyPoolId || "__none__")); if (poolIds.size === 1) { const onlyId = [...poolIds][0]; if (onlyId === "__none__") return "All selected currently unbound"; const pool = proxyPools.find((p) => p.id === onlyId); return `All selected currently bound to ${pool?.name || onlyId}`; } return "Selected connections have mixed proxy bindings"; })(); const openBulkProxyModal = () => { if (selectedConnections.length === 0) return; const uniquePoolIds = [...new Set(selectedConnections.map((conn) => conn.providerSpecificData?.proxyPoolId || "__none__"))]; setBulkProxyPoolId(uniquePoolIds.length === 1 ? uniquePoolIds[0] : "__none__"); setShowBulkProxyModal(true); }; const closeBulkProxyModal = () => { if (bulkUpdatingProxy) return; setShowBulkProxyModal(false); }; const handleBulkApplyProxyPool = async () => { if (selectedConnectionIds.length === 0) return; const proxyPoolId = bulkProxyPoolId === "__none__" ? null : bulkProxyPoolId; setBulkUpdatingProxy(true); try { const results = []; for (const connectionId of selectedConnectionIds) { try { const res = await fetch(`/api/providers/${connectionId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ proxyPoolId }), }); results.push(res.ok); } catch (e) { console.log("Error applying bulk proxy pool for", connectionId, e); results.push(false); } } const failedCount = results.filter((ok) => !ok).length; if (failedCount > 0) { alert(`Updated with ${failedCount} failed request(s).`); } await fetchConnections(); clearSelection(); setShowBulkProxyModal(false); } catch (error) { console.log("Error applying bulk proxy pool:", error); } finally { setBulkUpdatingProxy(false); } }; const isSelected = (connectionId) => selectedConnectionIds.includes(connectionId); const connectionsList = (
{connections .map((conn, index) => (
handleSwapPriority(index, index - 1)} onMoveDown={() => handleSwapPriority(index, index + 1)} onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)} onUpdateProxy={async (proxyPoolId) => { try { const res = await fetch(`/api/providers/${conn.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ proxyPoolId: proxyPoolId || null }), }); if (res.ok) { setConnections(prev => prev.map(c => c.id === conn.id ? { ...c, providerSpecificData: { ...c.providerSpecificData, proxyPoolId: proxyPoolId || null } } : c )); } } catch (error) { console.log("Error updating proxy:", error); } }} onEdit={() => { setSelectedConnection(conn); setShowEditModal(true); }} onDelete={() => handleDelete(conn.id)} />
))}
); const bulkProxyOptions = [ { value: "__none__", label: "None" }, ...proxyPools.map((pool) => ({ value: pool.id, label: pool.name })), ]; const bulkHint = selectedConnectionIds.length === 0 ? "Select one or more connections, then click Proxy Action." : selectedProxySummary; const canApplyBulkProxy = selectedConnectionIds.length > 0 && !bulkUpdatingProxy; const bulkActionModal = (
handleThinkingModeChange(e.target.value)} className="text-xs px-2 py-1 border border-border rounded-md bg-background focus:outline-none focus:border-primary" > {thinkingConfig.options.map((opt) => ( ))}
)} */} {/* Round Robin toggle */}
Round Robin {providerStrategy === "round-robin" && (
Sticky: handleStickyLimitChange(e.target.value)} placeholder="1" className="w-14 px-2 py-1 text-xs border border-border rounded-md bg-background focus:outline-none focus:border-primary" />
)}
{connections.length === 0 ? (
{isOAuth ? "lock" : "key"}

No connections yet

Add your first connection to get started

{!isCompatible && (
{providerId === "iflow" && ( )}
)}
) : ( <> {connectionsList} {!isCompatible && (
{providerId === "iflow" && ( )}
)} )} )} {/* Models */}

{"Available Models"}

{!!modelsTestError && (

{modelsTestError}

)} {renderModelsSection()}
{bulkActionModal} {/* Modals */} {providerId === "kiro" ? ( setShowOAuthModal(false)} /> ) : providerId === "cursor" ? ( setShowOAuthModal(false)} /> ) : providerId === "gitlab" ? ( setShowOAuthModal(false)} /> ) : ( setShowOAuthModal(false)} /> )} {providerId === "iflow" && ( setShowIFlowCookieModal(false)} /> )} setShowAddApiKeyModal(false)} /> setShowEditModal(false)} /> {isCompatible && ( setShowEditNodeModal(false)} isAnthropic={isAnthropicCompatible} /> )} {!isCompatible && ( { // For passthrough providers (OpenRouter), use last segment as alias to avoid slash conflicts const alias = providerInfo?.passthroughModels ? modelId.split("/").pop() : modelId; await handleSetAlias(modelId, alias, providerStorageAlias); setShowAddCustomModel(false); }} onClose={() => setShowAddCustomModel(false)} /> )} ); }