"use client"; import { useState, useEffect, useCallback } from "react"; import PropTypes from "prop-types"; 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, Toggle, Select } from "@/shared/components"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; import { getModelsByProviderId } from "@/shared/constants/models"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; 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 { 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]); const isOAuth = !!OAUTH_PROVIDERS[providerId] || !!FREE_PROVIDERS[providerId]; const models = getModelsByProviderId(providerId); const providerAlias = getProviderAlias(providerId); const isOpenAICompatible = isOpenAICompatibleProvider(providerId); const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId); const isCompatible = isOpenAICompatible || isAnthropicCompatible; 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); } }, []); const fetchConnections = useCallback(async () => { try { const [connectionsRes, nodesRes, proxyPoolsRes] = 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" }), ]); const connectionsData = await connectionsRes.json(); const nodesData = await nodesRes.json(); const proxyPoolsData = await proxyPoolsRes.json(); if (connectionsRes.ok) { const filtered = (connectionsData.connections || []).filter(c => c.provider === providerId); setConnections(filtered); } if (proxyPoolsRes.ok) { setProxyPools(proxyPoolsData.proxyPools || []); } 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); } }; useEffect(() => { fetchConnections(); fetchAliases(); }, [fetchConnections, fetchAliases]); 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 (conn1, conn2) => { if (!conn1 || !conn2) return; try { // If they have the same priority, we need to ensure the one moving up // gets a lower value than the one moving down. // We use a small offset which the backend re-indexing will fix. let p1 = conn2.priority; let p2 = conn1.priority; if (p1 === p2) { // If moving conn1 "up" (index decreases) const isConn1MovingUp = connections.indexOf(conn1) > connections.indexOf(conn2); if (isConn1MovingUp) { p1 = conn2.priority - 0.5; } else { p1 = conn2.priority + 0.5; } } await Promise.all([ fetch(`/api/providers/${conn1.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ priority: p1 }), }), fetch(`/api/providers/${conn2.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ priority: p2 }), }), ]); await fetchConnections(); } catch (error) { console.log("Error swapping priority:", error); } }; 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 selectionToolbar = connections.length > 0 ? (
{selectedConnectionIds.length > 0 && ( )}
{selectedConnectionIds.length} selected
) : null; const isSelected = (connectionId) => selectedConnectionIds.includes(connectionId); const connectionsList = (
{connections .sort((a, b) => (a.priority || 0) - (b.priority || 0)) .map((conn, index) => (
toggleSelectConnection(conn.id)} className="size-4 rounded border-border bg-transparent" title="Select connection" aria-label={`Select ${conn.name || conn.email || conn.id}`} />
handleSwapPriority(conn, connections[index - 1])} onMoveDown={() => handleSwapPriority(conn, connections[index + 1])} onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)} 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 = (
setNewModel(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleAdd()} placeholder="anthropic/claude-3-opus" className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" />
{/* Models list */} {allModels.length > 0 && (
{allModels.map(({ modelId, fullModel, alias }) => ( onDeleteAlias(alias)} /> ))}
)} ); } PassthroughModelsSection.propTypes = { providerAlias: PropTypes.string.isRequired, modelAliases: PropTypes.object.isRequired, copied: PropTypes.string, onCopy: PropTypes.func.isRequired, onSetAlias: PropTypes.func.isRequired, onDeleteAlias: PropTypes.func.isRequired, }; function PassthroughModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias, onTest, testStatus, isTesting }) { const borderColor = testStatus === "ok" ? "border-green-500/40" : testStatus === "error" ? "border-red-500/40" : "border-border"; const iconColor = testStatus === "ok" ? "#22c55e" : testStatus === "error" ? "#ef4444" : undefined; return (
{testStatus === "ok" ? "check_circle" : testStatus === "error" ? "cancel" : "smart_toy"}

{modelId}

{fullModel} {onTest && ( )}
{/* Delete button */}
); } PassthroughModelRow.propTypes = { modelId: PropTypes.string.isRequired, fullModel: PropTypes.string.isRequired, copied: PropTypes.string, onCopy: PropTypes.func.isRequired, onDeleteAlias: PropTypes.func.isRequired, onTest: PropTypes.func, testStatus: PropTypes.oneOf(["ok", "error"]), isTesting: PropTypes.bool, }; function CompatibleModelsSection({ providerStorageAlias, providerDisplayAlias, modelAliases, copied, onCopy, onSetAlias, onDeleteAlias, connections, isAnthropic }) { const [newModel, setNewModel] = useState(""); const [adding, setAdding] = useState(false); const [importing, setImporting] = useState(false); const [testingModelId, setTestingModelId] = useState(null); const [modelTestResults, setModelTestResults] = useState({}); const handleTestModel = async (modelId) => { if (testingModelId) return; setTestingModelId(modelId); try { const res = await fetch("/api/models/test", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: `${providerStorageAlias}/${modelId}` }), }); const data = await res.json(); setModelTestResults((prev) => ({ ...prev, [modelId]: data.ok ? "ok" : "error" })); } catch { setModelTestResults((prev) => ({ ...prev, [modelId]: "error" })); } finally { setTestingModelId(null); } }; const providerAliases = Object.entries(modelAliases).filter( ([, model]) => model.startsWith(`${providerStorageAlias}/`) ); const allModels = providerAliases.map(([alias, fullModel]) => ({ modelId: fullModel.replace(`${providerStorageAlias}/`, ""), fullModel, alias, })); const generateDefaultAlias = (modelId) => { const parts = modelId.split("/"); return parts[parts.length - 1]; }; const resolveAlias = (modelId) => { const baseAlias = generateDefaultAlias(modelId); if (!modelAliases[baseAlias]) return baseAlias; const prefixedAlias = `${providerDisplayAlias}-${baseAlias}`; if (!modelAliases[prefixedAlias]) return prefixedAlias; return null; }; const handleAdd = async () => { if (!newModel.trim() || adding) return; const modelId = newModel.trim(); const resolvedAlias = resolveAlias(modelId); if (!resolvedAlias) { alert("All suggested aliases already exist. Please choose a different model or remove conflicting aliases."); return; } setAdding(true); try { await onSetAlias(modelId, resolvedAlias, providerStorageAlias); setNewModel(""); } catch (error) { console.log("Error adding model:", error); } finally { setAdding(false); } }; const handleImport = async () => { if (importing) return; const activeConnection = connections.find((conn) => conn.isActive !== false); if (!activeConnection) return; setImporting(true); try { const res = await fetch(`/api/providers/${activeConnection.id}/models`); const data = await res.json(); if (!res.ok) { alert(data.error || "Failed to import models"); return; } const models = data.models || []; if (models.length === 0) { alert("No models returned from /models."); return; } let importedCount = 0; for (const model of models) { const modelId = model.id || model.name || model.model; if (!modelId) continue; const resolvedAlias = resolveAlias(modelId); if (!resolvedAlias) continue; await onSetAlias(modelId, resolvedAlias, providerStorageAlias); importedCount += 1; } if (importedCount === 0) { alert("No new models were added."); } } catch (error) { console.log("Error importing models:", error); } finally { setImporting(false); } }; const canImport = connections.some((conn) => conn.isActive !== false); return (

Add {isAnthropic ? "Anthropic" : "OpenAI"}-compatible models manually or import them from the /models endpoint.

setNewModel(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleAdd()} placeholder={isAnthropic ? "claude-3-opus-20240229" : "gpt-4o"} className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" />
{!canImport && (

Add a connection to enable importing models.

)} {allModels.length > 0 && (
{allModels.map(({ modelId, fullModel, alias }) => ( onDeleteAlias(alias)} onTest={connections.length > 0 ? () => handleTestModel(modelId) : undefined} testStatus={modelTestResults[modelId]} isTesting={testingModelId === modelId} /> ))}
)}
); } CompatibleModelsSection.propTypes = { providerStorageAlias: PropTypes.string.isRequired, providerDisplayAlias: PropTypes.string.isRequired, modelAliases: PropTypes.object.isRequired, copied: PropTypes.string, onCopy: PropTypes.func.isRequired, onSetAlias: PropTypes.func.isRequired, onDeleteAlias: PropTypes.func.isRequired, connections: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string, isActive: PropTypes.bool, })).isRequired, isAnthropic: PropTypes.bool, }; function CooldownTimer({ until }) { const [remaining, setRemaining] = useState(""); useEffect(() => { const updateRemaining = () => { const diff = new Date(until).getTime() - Date.now(); if (diff <= 0) { setRemaining(""); return; } const secs = Math.floor(diff / 1000); if (secs < 60) { setRemaining(`${secs}s`); } else if (secs < 3600) { setRemaining(`${Math.floor(secs / 60)}m ${secs % 60}s`); } else { const hrs = Math.floor(secs / 3600); const mins = Math.floor((secs % 3600) / 60); setRemaining(`${hrs}h ${mins}m`); } }; updateRemaining(); const interval = setInterval(updateRemaining, 1000); return () => clearInterval(interval); }, [until]); if (!remaining) return null; return ( ⏱ {remaining} ); } CooldownTimer.propTypes = { until: PropTypes.string.isRequired, }; function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onEdit, onDelete }) { const proxyPoolMap = new Map((proxyPools || []).map((pool) => [pool.id, pool])); const boundProxyPoolId = connection.providerSpecificData?.proxyPoolId || null; const boundProxyPool = boundProxyPoolId ? proxyPoolMap.get(boundProxyPoolId) : null; const hasLegacyProxy = connection.providerSpecificData?.connectionProxyEnabled === true && !!connection.providerSpecificData?.connectionProxyUrl; const hasAnyProxy = !!boundProxyPoolId || hasLegacyProxy; const proxyDisplayText = boundProxyPool ? `Pool: ${boundProxyPool.name}` : boundProxyPoolId ? `Pool: ${boundProxyPoolId} (inactive/missing)` : hasLegacyProxy ? `Legacy: ${connection.providerSpecificData?.connectionProxyUrl}` : ""; let proxyBadgeVariant = "default"; if (boundProxyPool?.isActive === true) { proxyBadgeVariant = "success"; } else if (boundProxyPoolId || hasLegacyProxy) { proxyBadgeVariant = "error"; } let maskedProxyUrl = ""; if (boundProxyPool?.proxyUrl || connection.providerSpecificData?.connectionProxyUrl) { const rawProxyUrl = boundProxyPool?.proxyUrl || connection.providerSpecificData?.connectionProxyUrl; try { const parsed = new URL(rawProxyUrl); maskedProxyUrl = `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}`; } catch { maskedProxyUrl = rawProxyUrl; } } const noProxyText = boundProxyPool?.noProxy || connection.providerSpecificData?.connectionNoProxy || ""; const displayName = isOAuth ? connection.name || connection.email || connection.displayName || "OAuth Account" : connection.name; // Use useState + useEffect for impure Date.now() to avoid calling during render const [isCooldown, setIsCooldown] = useState(false); // Get earliest model lock timestamp (useEffect handles the Date.now() comparison) const modelLockUntil = Object.entries(connection) .filter(([k]) => k.startsWith("modelLock_")) .map(([, v]) => v) .filter(v => !!v) .sort()[0] || null; useEffect(() => { const checkCooldown = () => { const until = Object.entries(connection) .filter(([k]) => k.startsWith("modelLock_")) .map(([, v]) => v) .filter(v => v && new Date(v).getTime() > Date.now()) .sort()[0] || null; setIsCooldown(!!until); }; checkCooldown(); const interval = modelLockUntil ? setInterval(checkCooldown, 1000) : null; return () => { if (interval) clearInterval(interval); }; }, [modelLockUntil]); // Determine effective status (override unavailable if cooldown expired) const effectiveStatus = (connection.testStatus === "unavailable" && !isCooldown) ? "active" // Cooldown expired → treat as active : connection.testStatus; const getStatusVariant = () => { if (connection.isActive === false) return "default"; if (effectiveStatus === "active" || effectiveStatus === "success") return "success"; if (effectiveStatus === "error" || effectiveStatus === "expired" || effectiveStatus === "unavailable") return "error"; return "default"; }; return (
{/* Priority arrows */}
{isOAuth ? "lock" : "key"}

{displayName}

{connection.isActive === false ? "disabled" : (effectiveStatus || "Unknown")} {hasAnyProxy && ( Proxy )} {isCooldown && connection.isActive !== false && } {connection.lastError && connection.isActive !== false && ( {connection.lastError} )} #{connection.priority} {connection.globalPriority && ( Auto: {connection.globalPriority} )}
{hasAnyProxy && (
{proxyDisplayText} {maskedProxyUrl && ( {maskedProxyUrl} )} {noProxyText && ( no_proxy: {noProxyText} )}
)}
); } ConnectionRow.propTypes = { connection: PropTypes.shape({ id: PropTypes.string, name: PropTypes.string, email: PropTypes.string, displayName: PropTypes.string, modelLockUntil: PropTypes.string, testStatus: PropTypes.string, isActive: PropTypes.bool, lastError: PropTypes.string, priority: PropTypes.number, globalPriority: PropTypes.number, }).isRequired, proxyPools: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string, name: PropTypes.string, proxyUrl: PropTypes.string, noProxy: PropTypes.string, isActive: PropTypes.bool, })), isOAuth: PropTypes.bool.isRequired, isFirst: PropTypes.bool.isRequired, isLast: PropTypes.bool.isRequired, onMoveUp: PropTypes.func.isRequired, onMoveDown: PropTypes.func.isRequired, onToggleActive: PropTypes.func.isRequired, onEdit: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired, }; function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, proxyPools, onSave, onClose }) { const NONE_PROXY_POOL_VALUE = "__none__"; const [formData, setFormData] = useState({ name: "", apiKey: "", priority: 1, proxyPoolId: NONE_PROXY_POOL_VALUE, }); const [validating, setValidating] = useState(false); const [validationResult, setValidationResult] = useState(null); const [saving, setSaving] = useState(false); const handleValidate = async () => { setValidating(true); try { const res = await fetch("/api/providers/validate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider, apiKey: formData.apiKey }), }); const data = await res.json(); setValidationResult(data.valid ? "success" : "failed"); } catch { setValidationResult("failed"); } finally { setValidating(false); } }; const handleSubmit = async () => { if (!provider || !formData.apiKey) return; setSaving(true); try { let isValid = false; try { setValidating(true); setValidationResult(null); const res = await fetch("/api/providers/validate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider, apiKey: formData.apiKey }), }); const data = await res.json(); isValid = !!data.valid; setValidationResult(isValid ? "success" : "failed"); } catch { setValidationResult("failed"); } finally { setValidating(false); } await onSave({ name: formData.name, apiKey: formData.apiKey, priority: formData.priority, proxyPoolId: formData.proxyPoolId === NONE_PROXY_POOL_VALUE ? null : formData.proxyPoolId, testStatus: isValid ? "active" : "unknown", }); } finally { setSaving(false); } }; if (!provider) return null; return (
setFormData({ ...formData, name: e.target.value })} placeholder="Production Key" />
setFormData({ ...formData, apiKey: e.target.value })} className="flex-1" />
{validationResult && ( {validationResult === "success" ? "Valid" : "Invalid"} )} {isCompatible && (

{isAnthropic ? `Validation checks ${providerName || "Anthropic Compatible"} by verifying the API key.` : `Validation checks ${providerName || "OpenAI Compatible"} via /models on your base URL.` }

)} setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })} /> setFormData({ ...formData, name: e.target.value })} placeholder={isOAuth ? "Account name" : "Production Key"} /> {isOAuth && connection.email && (

Email

{connection.email}

)} setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })} /> setFormData({ ...formData, apiKey: e.target.value })} placeholder="Enter new API key" hint="Leave blank to keep the current API key." className="flex-1" />
{validationResult && ( {validationResult === "success" ? "Valid" : "Invalid"} )} )} {/* Test Connection */} {!isCompatible && (
{testResult && ( {testResult === "success" ? "Valid" : "Failed"} )}
)}
); } EditConnectionModal.propTypes = { isOpen: PropTypes.bool.isRequired, connection: PropTypes.shape({ id: PropTypes.string, name: PropTypes.string, email: PropTypes.string, priority: PropTypes.number, authType: PropTypes.string, provider: PropTypes.string, providerSpecificData: PropTypes.object, }), proxyPools: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string, name: PropTypes.string, })), onSave: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired, }; function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic }) { const [formData, setFormData] = useState({ name: "", prefix: "", apiType: "chat", baseUrl: "https://api.openai.com/v1", }); const [saving, setSaving] = useState(false); const [checkKey, setCheckKey] = useState(""); const [validating, setValidating] = useState(false); const [validationResult, setValidationResult] = useState(null); useEffect(() => { if (node) { setFormData({ name: node.name || "", prefix: node.prefix || "", apiType: node.apiType || "chat", baseUrl: node.baseUrl || (isAnthropic ? "https://api.anthropic.com/v1" : "https://api.openai.com/v1"), }); } }, [node, isAnthropic]); const apiTypeOptions = [ { value: "chat", label: "Chat Completions" }, { value: "responses", label: "Responses API" }, ]; const handleSubmit = async () => { if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return; setSaving(true); try { const payload = { name: formData.name, prefix: formData.prefix, baseUrl: formData.baseUrl, }; if (!isAnthropic) { payload.apiType = formData.apiType; } await onSave(payload); } finally { setSaving(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: isAnthropic ? "anthropic-compatible" : "openai-compatible" }), }); const data = await res.json(); setValidationResult(data.valid ? "success" : "failed"); } catch { setValidationResult("failed"); } finally { setValidating(false); } }; if (!node) return null; return (
setFormData({ ...formData, name: e.target.value })} placeholder={`${isAnthropic ? "Anthropic" : "OpenAI"} Compatible (Prod)`} hint="Required. A friendly label for this node." /> setFormData({ ...formData, prefix: e.target.value })} placeholder={isAnthropic ? "ac-prod" : "oc-prod"} hint="Required. Used as the provider prefix for model IDs." /> {!isAnthropic && ( setFormData({ ...formData, baseUrl: e.target.value })} placeholder={isAnthropic ? "https://api.anthropic.com/v1" : "https://api.openai.com/v1"} hint={`Use the base URL (ending in /v1) for your ${isAnthropic ? "Anthropic" : "OpenAI"}-compatible API.`} />
setCheckKey(e.target.value)} className="flex-1" />
{validationResult && ( {validationResult === "success" ? "Valid" : "Invalid"} )}
); } EditCompatibleNodeModal.propTypes = { isOpen: PropTypes.bool.isRequired, node: PropTypes.shape({ id: PropTypes.string, name: PropTypes.string, prefix: PropTypes.string, apiType: PropTypes.string, baseUrl: PropTypes.string, }), onSave: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired, isAnthropic: PropTypes.bool, }; function AddCustomModelModal({ isOpen, providerAlias, providerDisplayAlias, onSave, onClose }) { const [modelId, setModelId] = useState(""); const [testStatus, setTestStatus] = useState(null); // null | "testing" | "ok" | "error" const [testError, setTestError] = useState(""); const [saving, setSaving] = useState(false); // Reset state when modal opens useEffect(() => { if (isOpen) { setModelId(""); setTestStatus(null); setTestError(""); } }, [isOpen]); const handleTest = async () => { if (!modelId.trim()) return; setTestStatus("testing"); setTestError(""); try { const res = await fetch("/api/models/test", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: `${providerAlias}/${modelId.trim()}` }), }); const data = await res.json(); setTestStatus(data.ok ? "ok" : "error"); setTestError(data.error || ""); } catch (err) { setTestStatus("error"); setTestError(err.message); } }; const handleSave = async () => { if (!modelId.trim() || saving) return; setSaving(true); try { await onSave(modelId.trim()); } finally { setSaving(false); } }; const handleKeyDown = (e) => { if (e.key === "Enter") handleTest(); }; return (
{ setModelId(e.target.value); setTestStatus(null); setTestError(""); }} onKeyDown={handleKeyDown} placeholder="e.g. claude-opus-4-5" className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" autoFocus />

Sent to provider as: {modelId.trim() || "model-id"}

{/* Test result */} {testStatus === "ok" && (
check_circle Model is reachable
)} {testStatus === "error" && (
cancel {testError || "Model not reachable"}
)}
); } AddCustomModelModal.propTypes = { isOpen: PropTypes.bool.isRequired, providerAlias: PropTypes.string.isRequired, providerDisplayAlias: PropTypes.string.isRequired, onSave: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired, };