From 484e7025e85474e42a26f06158ce8c4bba8efc40 Mon Sep 17 00:00:00 2001 From: decolua Date: Wed, 25 Feb 2026 16:31:40 +0700 Subject: [PATCH] feat: add ProviderDetailPage component for managing provider connections and models - Introduced a new ProviderDetailPage component to handle provider-specific functionalities. - Implemented state management for connections, loading states, and model selections. - Added API calls for fetching provider connections and model aliases. - Included modals for OAuth and API key management. - Enhanced user experience with model selection and saving capabilities. --- .../dashboard/providers/[id]/page.new.js | 1716 +++++++++++++++++ 1 file changed, 1716 insertions(+) create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/page.new.js diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.new.js b/src/app/(dashboard)/dashboard/providers/[id]/page.new.js new file mode 100644 index 00000000..114e1a7e --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.new.js @@ -0,0 +1,1716 @@ +"use client"; + +import { useState, useEffect, useCallback, useMemo } 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, 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 [showOAuthModal, setShowOAuthModal] = useState(false); + const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false); + const [showEditModal, setShowEditModal] = useState(false); + const [showEditNodeModal, setShowEditNodeModal] = useState(false); + const [selectedConnection, setSelectedConnection] = useState(null); + const [modelAliases, setModelAliases] = useState({}); + const [remoteModels, setRemoteModels] = useState([]); + const [loadingRemoteModels, setLoadingRemoteModels] = useState(false); + const [selectedModelIds, setSelectedModelIds] = useState([]); + const [savingSelectedModels, setSavingSelectedModels] = useState(false); + const [modelSearchQuery, setModelSearchQuery] = useState(""); + const [showSelectedOnly, setShowSelectedOnly] = useState(false); + const [headerImgError, setHeaderImgError] = 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 = useMemo(() => getModelsByProviderId(providerId), [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; + const activeConnection = connections.find((conn) => conn.isActive !== false) || null; + const allProviderModels = models.length > 0 ? models : remoteModels; + const allProviderModelIds = useMemo( + () => allProviderModels.map((model) => model.id), + [allProviderModels] + ); + const savedEnabledModels = useMemo(() => { + const enabled = activeConnection?.providerSpecificData?.enabledModels; + return Array.isArray(enabled) + ? enabled.filter((modelId) => allProviderModelIds.includes(modelId)) + : []; + }, [activeConnection?.providerSpecificData?.enabledModels, allProviderModelIds]); + const savedEnabledModelsKey = useMemo( + () => savedEnabledModels.join("|"), + [savedEnabledModels] + ); + + // 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] = await Promise.all([ + fetch("/api/providers", { cache: "no-store" }), + fetch("/api/provider-nodes", { cache: "no-store" }), + ]); + const connectionsData = await connectionsRes.json(); + const nodesData = await nodesRes.json(); + if (connectionsRes.ok) { + const filtered = (connectionsData.connections || []).filter(c => c.provider === providerId); + setConnections(filtered); + } + 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 handleToggleModelSelected = (modelId) => { + setSelectedModelIds((prev) => ( + prev.includes(modelId) + ? prev.filter((id) => id !== modelId) + : [...prev, modelId] + )); + }; + + const handleSaveSelectedModels = async () => { + if (!activeConnection || savingSelectedModels) return; + setSavingSelectedModels(true); + try { + const res = await fetch(`/api/providers/${activeConnection.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providerSpecificData: { + enabledModels: selectedModelIds, + }, + }), + }); + + if (!res.ok) { + const data = await res.json(); + alert(data.error || "Failed to save selected models"); + return; + } + + await fetchConnections(); + } catch (error) { + console.log("Error saving selected models:", error); + alert("Failed to save selected models"); + } finally { + setSavingSelectedModels(false); + } + }; + + useEffect(() => { + fetchConnections(); + fetchAliases(); + }, [fetchConnections, fetchAliases]); + + useEffect(() => { + const nextSelectedModelIds = (isCompatible || providerInfo?.passthroughModels) + ? [] + : savedEnabledModels; + + setSelectedModelIds((prev) => { + if ( + prev.length === nextSelectedModelIds.length + && prev.every((modelId, index) => modelId === nextSelectedModelIds[index]) + ) { + return prev; + } + return nextSelectedModelIds; + }); + }, [ + isCompatible, + providerInfo?.passthroughModels, + activeConnection?.id, + savedEnabledModels + ]); + + const fetchRemoteModels = useCallback(async () => { + if (isCompatible || providerInfo?.passthroughModels || models.length > 0) { + setRemoteModels([]); + return; + } + + if (!activeConnection) { + setRemoteModels([]); + return; + } + + setLoadingRemoteModels(true); + try { + const res = await fetch(`/api/providers/${activeConnection.id}/models`); + const data = await res.json(); + if (!res.ok) { + setRemoteModels([]); + return; + } + + const parsed = (data.models || []) + .map((item) => { + if (typeof item === "string") return { id: item, name: item }; + const modelId = item?.id || item?.name || item?.model; + if (!modelId) return null; + return { id: modelId, name: item?.name || modelId }; + }) + .filter(Boolean); + + const deduped = Array.from( + new Map(parsed.map((item) => [item.id, item])).values() + ); + + setRemoteModels(deduped); + } catch (error) { + console.log("Error fetching remote models:", error); + setRemoteModels([]); + } finally { + setLoadingRemoteModels(false); + } + }, [activeConnection, isCompatible, models.length, providerInfo?.passthroughModels]); + + useEffect(() => { + fetchRemoteModels(); + }, [fetchRemoteModels]); + + 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 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 renderModelsSection = () => { + if (isCompatible) { + return ( + + ); + } + if (providerInfo.passthroughModels) { + return ( + + ); + } + + const availableModels = allProviderModels; + if (availableModels.length === 0) { + if (loadingRemoteModels) { + return

Loading models from provider...

; + } + return

No models configured

; + } + + const selectedSet = new Set(selectedModelIds); + const filteredBySelection = showSelectedOnly + ? availableModels.filter((model) => selectedSet.has(model.id)) + : availableModels; + const query = modelSearchQuery.trim().toLowerCase(); + const visibleModels = query + ? filteredBySelection.filter((model) => + model.id.toLowerCase().includes(query) || + (model.name || "").toLowerCase().includes(query) + ) + : filteredBySelection; + const hasSelectionChanges = + savedEnabledModels.length !== selectedModelIds.length || + savedEnabledModels.some((modelId) => !selectedSet.has(modelId)); + + return ( +
+
+
+ setModelSearchQuery(e.target.value)} + placeholder="Search model id" + className="pr-8" + /> + {modelSearchQuery && ( + + )} +
+ + + +
+ Selected only + +
+
+ +

+ {selectedModelIds.length > 0 + ? `${selectedModelIds.length} selected` + : "All models enabled"} +

+ + {visibleModels.length === 0 ? ( +

No models match your filter.

+ ) : ( +
+ {visibleModels.map((model) => { + const fullModel = `${providerStorageAlias}/${model.id}`; + const oldFormatModel = `${providerId}/${model.id}`; + const existingAlias = Object.entries(modelAliases).find( + ([, m]) => m === fullModel || m === oldFormatModel + )?.[0]; + return ( + handleToggleModelSelected(model.id)} + onSetAlias={(alias) => handleSetAlias(model.id, alias, providerStorageAlias)} + onDeleteAlias={() => handleDeleteAlias(existingAlias)} + /> + ); + })} +
+ )} +
+ ); + }; + + if (loading) { + return ( +
+ + +
+ ); + } + + if (!providerInfo) { + return ( +
+

Provider not found

+ + Back to Providers + +
+ ); + } + + // Determine icon path: OpenAI Compatible providers use specialized icons + const getHeaderIconPath = () => { + if (isOpenAICompatible && providerInfo.apiType) { + return providerInfo.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png"; + } + if (isAnthropicCompatible) { + return "/providers/anthropic-m.png"; + } + return `/providers/${providerInfo.id}.png`; + }; + + return ( +
+ {/* Header */} +
+ + arrow_back + Back to Providers + +
+
+ {headerImgError ? ( + + {providerInfo.textIcon || providerInfo.id.slice(0, 2).toUpperCase()} + + ) : ( + {providerInfo.name} setHeaderImgError(true)} + /> + )} +
+
+

{providerInfo.name}

+

+ {connections.length} connection{connections.length === 1 ? "" : "s"} +

+
+
+
+ + {isCompatible && providerNode && ( + +
+
+

{isAnthropicCompatible ? "Anthropic Compatible Details" : "OpenAI Compatible Details"}

+

+ {isAnthropicCompatible ? "Messages API" : (providerNode.apiType === "responses" ? "Responses API" : "Chat Completions")} · {(providerNode.baseUrl || "").replace(/\/$/, "")}/ + {isAnthropicCompatible ? "messages" : (providerNode.apiType === "responses" ? "responses" : "chat/completions")} +

+
+
+ + + +
+
+ {connections.length > 0 && ( +

+ Only one connection is allowed per compatible node. Add another node if you need more connections. +

+ )} +
+ )} + + {/* Connections */} + +
+

Connections

+ {!isCompatible && ( + + )} +
+ + {connections.length === 0 ? ( +
+
+ {isOAuth ? "lock" : "key"} +
+

No connections yet

+

Add your first connection to get started

+ {!isCompatible && ( + + )} +
+ ) : ( +
+ {connections + .sort((a, b) => (a.priority || 0) - (b.priority || 0)) + .map((conn, index) => ( + 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)} + /> + ))} +
+ )} +
+ + {/* Models */} + +

+ {providerInfo.passthroughModels ? "Model Aliases" : "Available Models"} +

+ {renderModelsSection()} + +
+ + {/* Modals */} + {providerId === "kiro" ? ( + setShowOAuthModal(false)} + /> + ) : providerId === "cursor" ? ( + setShowOAuthModal(false)} + /> + ) : ( + setShowOAuthModal(false)} + /> + )} + setShowAddApiKeyModal(false)} + /> + setShowEditModal(false)} + /> + {isCompatible && ( + setShowEditNodeModal(false)} + isAnthropic={isAnthropicCompatible} + /> + )} +
+ ); +} + +function ModelRow({ model, fullModel, alias, selected, onToggleSelect, copied, onCopy }) { + return ( +
+ + smart_toy + {fullModel} + +
+ ); +} + +ModelRow.propTypes = { + model: PropTypes.shape({ + id: PropTypes.string.isRequired, + }).isRequired, + fullModel: PropTypes.string.isRequired, + alias: PropTypes.string, + selected: PropTypes.bool, + onToggleSelect: PropTypes.func, + copied: PropTypes.string, + onCopy: PropTypes.func.isRequired, +}; + +function PassthroughModelsSection({ providerAlias, modelAliases, copied, onCopy, onSetAlias, onDeleteAlias }) { + const [newModel, setNewModel] = useState(""); + const [adding, setAdding] = useState(false); + + // Filter aliases for this provider - models are persisted via alias + const providerAliases = Object.entries(modelAliases).filter( + ([, model]) => model.startsWith(`${providerAlias}/`) + ); + + const allModels = providerAliases.map(([alias, fullModel]) => ({ + modelId: fullModel.replace(`${providerAlias}/`, ""), + fullModel, + alias, + })); + + // Generate default alias from modelId (last part after /) + const generateDefaultAlias = (modelId) => { + const parts = modelId.split("/"); + return parts[parts.length - 1]; + }; + + const handleAdd = async () => { + if (!newModel.trim() || adding) return; + const modelId = newModel.trim(); + const defaultAlias = generateDefaultAlias(modelId); + + // Check if alias already exists + if (modelAliases[defaultAlias]) { + alert(`Alias "${defaultAlias}" already exists. Please use a different model or edit existing alias.`); + return; + } + + setAdding(true); + try { + await onSetAlias(modelId, defaultAlias); + setNewModel(""); + } catch (error) { + console.log("Error adding model:", error); + } finally { + setAdding(false); + } + }; + + return ( +
+

+ OpenRouter supports any model. Add models and create aliases for quick access. +

+ + {/* Add new model */} +
+
+ + 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 }) { + return ( +
+ smart_toy + +
+

{modelId}

+ +
+ {fullModel} + +
+
+ + {/* Delete button */} + +
+ ); +} + +PassthroughModelRow.propTypes = { + modelId: PropTypes.string.isRequired, + fullModel: PropTypes.string.isRequired, + copied: PropTypes.string, + onCopy: PropTypes.func.isRequired, + onDeleteAlias: PropTypes.func.isRequired, +}; + +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 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)} + /> + ))} +
+ )} +
+ ); +} + +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, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onEdit, onDelete }) { + 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); + + useEffect(() => { + const checkCooldown = () => { + const cooldown = connection.rateLimitedUntil && + new Date(connection.rateLimitedUntil).getTime() > Date.now(); + setIsCooldown(cooldown); + }; + + checkCooldown(); + // Update every second while in cooldown + const interval = connection.rateLimitedUntil ? setInterval(checkCooldown, 1000) : null; + return () => { + if (interval) clearInterval(interval); + }; + }, [connection.rateLimitedUntil]); + + // 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")} + + {isCooldown && connection.isActive !== false && } + {connection.lastError && connection.isActive !== false && ( + + {connection.lastError} + + )} + #{connection.priority} + {connection.globalPriority && ( + Auto: {connection.globalPriority} + )} +
+
+
+
+ +
+ + +
+
+
+ ); +} + +ConnectionRow.propTypes = { + connection: PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + email: PropTypes.string, + displayName: PropTypes.string, + rateLimitedUntil: PropTypes.string, + testStatus: PropTypes.string, + isActive: PropTypes.bool, + lastError: PropTypes.string, + priority: PropTypes.number, + globalPriority: PropTypes.number, + }).isRequired, + 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, onSave, onClose }) { + const [formData, setFormData] = useState({ + name: "", + apiKey: "", + priority: 1, + }); + 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, + 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 })} + /> +
+ + +
+
+
+ ); +} + +AddApiKeyModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + provider: PropTypes.string, + providerName: PropTypes.string, + isCompatible: PropTypes.bool, + isAnthropic: PropTypes.bool, + onSave: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, +}; + +function EditConnectionModal({ isOpen, connection, onSave, onClose }) { + const [formData, setFormData] = useState({ + name: "", + priority: 1, + apiKey: "", + }); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState(null); + const [validating, setValidating] = useState(false); + const [validationResult, setValidationResult] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (connection) { + setFormData({ + name: connection.name || "", + priority: connection.priority || 1, + apiKey: "", + }); + setTestResult(null); + setValidationResult(null); + } + }, [connection]); + + const handleTest = async () => { + if (!connection?.provider) return; + setTesting(true); + setTestResult(null); + try { + const res = await fetch(`/api/providers/${connection.id}/test`, { method: "POST" }); + const data = await res.json(); + setTestResult(data.valid ? "success" : "failed"); + } catch { + setTestResult("failed"); + } finally { + setTesting(false); + } + }; + + const handleValidate = async () => { + if (!connection?.provider || !formData.apiKey) return; + setValidating(true); + setValidationResult(null); + try { + const res = await fetch("/api/providers/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: connection.provider, apiKey: formData.apiKey }), + }); + const data = await res.json(); + setValidationResult(data.valid ? "success" : "failed"); + } catch { + setValidationResult("failed"); + } finally { + setValidating(false); + } + }; + + const handleSubmit = async () => { + setSaving(true); + try { + const updates = { name: formData.name, priority: formData.priority }; + if (!isOAuth && formData.apiKey) { + updates.apiKey = formData.apiKey; + let isValid = validationResult === "success"; + if (!isValid) { + try { + setValidating(true); + setValidationResult(null); + const res = await fetch("/api/providers/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: connection.provider, apiKey: formData.apiKey }), + }); + const data = await res.json(); + isValid = !!data.valid; + setValidationResult(isValid ? "success" : "failed"); + } catch { + setValidationResult("failed"); + } finally { + setValidating(false); + } + } + if (isValid) { + updates.testStatus = "active"; + updates.lastError = null; + updates.lastErrorAt = null; + } + } + await onSave(updates); + } finally { + setSaving(false); + } + }; + + if (!connection) return null; + + const isOAuth = connection.authType === "oauth"; + const isCompatible = isOpenAICompatibleProvider(connection.provider) || isAnthropicCompatibleProvider(connection.provider); + + return ( + +
+ 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 })} + /> + {!isOAuth && ( + <> +
+ 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, + }), + 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, +};