diff --git a/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js b/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js
new file mode 100644
index 00000000..ef461db0
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js
@@ -0,0 +1,164 @@
+"use client";
+
+import { useState } from "react";
+import PropTypes from "prop-types";
+import { Button, Badge, Input, Modal, Select } from "@/shared/components";
+
+export default 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",
+ providerSpecificData: undefined
+ });
+ } 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,
+ proxyPools: PropTypes.arrayOf(PropTypes.shape({
+ id: PropTypes.string,
+ name: PropTypes.string,
+ })),
+ onSave: PropTypes.func.isRequired,
+ onClose: PropTypes.func.isRequired,
+};
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/AddCustomModelModal.js b/src/app/(dashboard)/dashboard/providers/[id]/AddCustomModelModal.js
new file mode 100644
index 00000000..6e8ca2f4
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/providers/[id]/AddCustomModelModal.js
@@ -0,0 +1,117 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import PropTypes from "prop-types";
+import { Button, Modal } from "@/shared/components";
+
+export default 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,
+};
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js b/src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js
new file mode 100644
index 00000000..33f1f05a
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js
@@ -0,0 +1,250 @@
+"use client";
+
+import { useState } from "react";
+import PropTypes from "prop-types";
+import { Button } from "@/shared/components";
+function CompatibleModelRow({ 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}
+
+
+
+ {copied === `model-${modelId}` ? "Copied!" : "Copy"}
+
+
+ {onTest && (
+
+
+
+ {isTesting ? "Testing..." : "Test"}
+
+
+ )}
+
+
+
+
+ );
+}
+
+export default 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 fullModel = `${providerStorageAlias}/${modelId}`;
+ // Skip if this exact model already has an alias
+ if (Object.values(modelAliases).includes(fullModel)) return null;
+ 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,
+};
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js
new file mode 100644
index 00000000..257270d1
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js
@@ -0,0 +1,261 @@
+"use client";
+
+import { useState, useEffect, useRef } from "react";
+import PropTypes from "prop-types";
+import { Badge, Toggle } from "@/shared/components";
+import CooldownTimer from "./CooldownTimer";
+
+export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete }) {
+ const [showProxyDropdown, setShowProxyDropdown] = useState(false);
+ const [updatingProxy, setUpdatingProxy] = useState(false);
+ const proxyDropdownRef = useRef(null);
+
+ 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 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 || "";
+
+ let proxyBadgeVariant = "default";
+ if (boundProxyPool?.isActive === true) {
+ proxyBadgeVariant = "success";
+ } else if (boundProxyPoolId || hasLegacyProxy) {
+ proxyBadgeVariant = "error";
+ }
+
+ // Close dropdown when clicking outside
+ useEffect(() => {
+ if (!showProxyDropdown) return;
+ const handler = (e) => {
+ if (proxyDropdownRef.current && !proxyDropdownRef.current.contains(e.target)) {
+ setShowProxyDropdown(false);
+ }
+ };
+ document.addEventListener("mousedown", handler);
+ return () => document.removeEventListener("mousedown", handler);
+ }, [showProxyDropdown]);
+
+ const handleSelectProxy = async (poolId) => {
+ setUpdatingProxy(true);
+ try {
+ await onUpdateProxy(poolId === "__none__" ? null : poolId);
+ } finally {
+ setUpdatingProxy(false);
+ setShowProxyDropdown(false);
+ }
+ };
+
+ 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 u2192 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}
+
+ )}
+
+ )}
+
+
+
+
+ {/* Proxy button with inline dropdown */}
+ {(proxyPools || []).length > 0 && (
+
+
+ {showProxyDropdown && (
+
+
+ {(proxyPools || []).map((pool) => (
+
+ ))}
+
+ )}
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+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,
+ onUpdateProxy: PropTypes.func,
+ onEdit: PropTypes.func.isRequired,
+ onDelete: PropTypes.func.isRequired,
+};
+
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/CooldownTimer.js b/src/app/(dashboard)/dashboard/providers/[id]/CooldownTimer.js
new file mode 100644
index 00000000..50e99dad
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/providers/[id]/CooldownTimer.js
@@ -0,0 +1,42 @@
+import { useState, useEffect } from "react";
+import PropTypes from "prop-types";
+
+export default 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,
+};
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/EditCompatibleNodeModal.js b/src/app/(dashboard)/dashboard/providers/[id]/EditCompatibleNodeModal.js
new file mode 100644
index 00000000..d6d33de1
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/providers/[id]/EditCompatibleNodeModal.js
@@ -0,0 +1,161 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import PropTypes from "prop-types";
+import { Button, Badge, Input, Modal, Select } from "@/shared/components";
+
+export default 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 [checkModelId, setCheckModelId] = 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",
+ modelId: checkModelId.trim() || undefined
+ }),
+ });
+ const data = await res.json();
+ setValidationResult(data.valid ? "success" : "failed");
+ } catch {
+ setValidationResult("failed");
+ } finally {
+ setValidating(false);
+ }
+ };
+
+ if (!node) return null;
+
+ return (
+
+
+
+ );
+}
+
+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,
+};
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js b/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js
new file mode 100644
index 00000000..44d78c52
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js
@@ -0,0 +1,86 @@
+import PropTypes from "prop-types";
+
+export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onTest, 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"}
+
+
+ {fullModel}
+ {model.name && {model.name}}
+
+ {onTest && (
+
+
+
+ {isTesting ? "Testing..." : "Test"}
+
+
+ )}
+
+
+
+ {copied === `model-${model.id}` ? "Copied!" : "Copy"}
+
+
+ {isCustom && (
+
+ )}
+
+
+ );
+}
+
+ModelRow.propTypes = {
+ model: PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ }).isRequired,
+ fullModel: PropTypes.string.isRequired,
+ alias: PropTypes.string,
+ copied: PropTypes.string,
+ onCopy: PropTypes.func.isRequired,
+ testStatus: PropTypes.oneOf(["ok", "error"]),
+ isCustom: PropTypes.bool,
+ isFree: PropTypes.bool,
+ onDeleteAlias: PropTypes.func,
+ onTest: PropTypes.func,
+ isTesting: PropTypes.bool,
+};
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/PassthroughModelsSection.js b/src/app/(dashboard)/dashboard/providers/[id]/PassthroughModelsSection.js
new file mode 100644
index 00000000..a58d34f8
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/providers/[id]/PassthroughModelsSection.js
@@ -0,0 +1,183 @@
+"use client";
+
+import { useState } from "react";
+import PropTypes from "prop-types";
+import { Button } from "@/shared/components";
+
+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}
+
+
+
+ {copied === `model-${modelId}` ? "Copied!" : "Copy"}
+
+
+ {onTest && (
+
+
+
+ {isTesting ? "Testing..." : "Test"}
+
+
+ )}
+
+
+
+ {/* 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,
+};
+
+export default 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,
+};
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js
index b9077e48..19b970e4 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/page.js
+++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js
@@ -1,7 +1,6 @@
"use client";
-import { useState, useEffect, useCallback, useRef } from "react";
-import PropTypes from "prop-types";
+import { useState, useEffect, useCallback } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
@@ -10,6 +9,13 @@ import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_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();
@@ -1044,1167 +1050,3 @@ export default function ProviderDetailPage() {
);
}
-
-function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onTest, 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 (
-