"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 [disabledModelIds, setDisabledModelIds] = 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; const fetchDisabledModels = useCallback(async () => { try { const res = await fetch(`/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}`, { cache: "no-store" }); const data = await res.json(); if (res.ok) setDisabledModelIds(data.ids || []); } catch (error) { console.log("Error fetching disabled models:", error); } }, [providerStorageAlias]); const handleDisableModel = async (modelId) => { try { const res = await fetch("/api/models/disabled", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ providerAlias: providerStorageAlias, ids: [modelId] }), }); if (res.ok) await fetchDisabledModels(); } catch (error) { console.log("Error disabling model:", error); } }; const handleEnableModel = async (modelId) => { try { const res = await fetch(`/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}&id=${encodeURIComponent(modelId)}`, { method: "DELETE" }); if (res.ok) await fetchDisabledModels(); } catch (error) { console.log("Error enabling model:", error); } }; const handleDisableAll = async (ids) => { if (!ids.length) return; if (!confirm(`Disable all ${ids.length} model(s)?`)) return; try { const res = await fetch("/api/models/disabled", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ providerAlias: providerStorageAlias, ids }), }); if (res.ok) await fetchDisabledModels(); } catch (error) { console.log("Error disabling all models:", error); } }; const handleEnableAll = async () => { try { const res = await fetch(`/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}`, { method: "DELETE" }); if (res.ok) await fetchDisabledModels(); } catch (error) { console.log("Error enabling all models:", error); } }; // 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(); fetchDisabledModels(); }, [fetchConnections, fetchAliases, fetchDisabledModels]); // 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 applyProxyAssignments = async (assignments) => { setBulkUpdatingProxy(true); try { const results = await Promise.all(assignments.map(async ({ connectionId, proxyPoolId }) => { try { const res = await fetch(`/api/providers/${connectionId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ proxyPoolId }), }); return res.ok; } catch (e) { console.log("Error applying proxy for", connectionId, e); return false; } })); const failed = results.filter((ok) => !ok).length; if (failed > 0) alert(`Updated with ${failed} failed request(s).`); await fetchConnections(); setShowBulkProxyModal(false); } finally { setBulkUpdatingProxy(false); } }; const handleApplySinglePool = (proxyPoolId) => { const targets = connections.map((c) => ({ connectionId: c.id, proxyPoolId })); return applyProxyAssignments(targets); }; const handleApplyOneToOne = () => { const activePools = proxyPools.filter((p) => p.isActive === true); if (activePools.length === 0) { alert("No active proxy pools available."); return; } const targets = connections.map((c, i) => ({ connectionId: c.id, proxyPoolId: activePools[i % activePools.length].id, })); return applyProxyAssignments(targets); }; const isSelected = (connectionId) => selectedConnectionIds.includes(connectionId); const connectionsList = (
Apply single pool to all
Applying...
}Suggested free models (≥200k context):
Disabled models ({disabledDisplayModels.length}):
Provider not found
Back to Providers{connections.length} connection{connections.length === 1 ? "" : "s"}
{providerInfo.deprecationNotice}
{isAnthropicCompatible ? "Messages API" : (providerNode.apiType === "responses" ? "Responses API" : "Chat Completions")} · {(providerNode.baseUrl || "").replace(/\/$/, "")}/ {isAnthropicCompatible ? "messages" : (providerNode.apiType === "responses" ? "responses" : "chat/completions")}
Only one connection is allowed per compatible node. Add another node if you need more connections.
)}No connections yet
{modelsTestError}
)} {renderModelsSection()}