Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 124 additions & 20 deletions src/app/(dashboard)/dashboard/providers/[id]/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ 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 { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, Toggle, Select, DuplicateWarningModal } 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";
Expand All @@ -24,6 +24,9 @@ export default function ProviderDetailPage() {
const [selectedConnection, setSelectedConnection] = useState(null);
const [modelAliases, setModelAliases] = useState({});
const [headerImgError, setHeaderImgError] = useState(false);
const [duplicateInfo, setDuplicateInfo] = useState(null);
const [showDuplicateModal, setShowDuplicateModal] = useState(false);
const [pendingConnection, setPendingConnection] = useState(null);
const { copied, copy } = useCopyToClipboard();

const providerInfo = providerNode
Expand Down Expand Up @@ -179,15 +182,93 @@ export default function ProviderDetailPage() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: providerId, ...formData }),
});

if (res.status === 409) {
// Duplicate detected
const data = await res.json();
setDuplicateInfo({
duplicate: data.duplicate,
reason: data.reason,
});
setPendingConnection(formData);
setShowDuplicateModal(true);
return;
}

if (res.ok) {
await fetchConnections();
setShowAddApiKeyModal(false);
setPendingConnection(null);
setDuplicateInfo(null);
}
} catch (error) {
console.log("Error saving connection:", error);
}
};

const handleDuplicateReplace = async () => {
if (!pendingConnection || !duplicateInfo) return;

try {
const res = await fetch("/api/providers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: providerId,
...pendingConnection,
replaceDuplicateId: duplicateInfo.duplicate.id,
}),
});

if (res.ok) {
await fetchConnections();
setShowAddApiKeyModal(false);
setShowDuplicateModal(false);
setPendingConnection(null);
setDuplicateInfo(null);
}
} catch (error) {
console.log("Error replacing connection:", error);
}
};

const handleDuplicateKeepBoth = async () => {
if (!pendingConnection) return;

try {
// Get max priority for this provider
const maxPriority = connections.reduce((max, c) => Math.max(max, c.priority || 0), 0);

const res = await fetch("/api/providers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: providerId,
...pendingConnection,
priority: maxPriority + 1,
name: `${pendingConnection.name} (Fallback)`,
replaceDuplicateId: null, // Force skip duplicate check
}),
});

if (res.ok) {
await fetchConnections();
setShowAddApiKeyModal(false);
setShowDuplicateModal(false);
setPendingConnection(null);
setDuplicateInfo(null);
}
} catch (error) {
console.log("Error keeping both connections:", error);
}
};

const handleDuplicateCancel = () => {
setShowDuplicateModal(false);
setPendingConnection(null);
setDuplicateInfo(null);
};

const handleUpdateConnection = async (formData) => {
try {
const res = await fetch(`/api/providers/${selectedConnection.id}`, {
Expand Down Expand Up @@ -471,23 +552,31 @@ export default function ProviderDetailPage() {
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
{connections
.sort((a, b) => (a.priority || 0) - (b.priority || 0))
.map((conn, index) => (
<ConnectionRow
key={conn.id}
connection={conn}
isOAuth={isOAuth}
isFirst={index === 0}
isLast={index === connections.length - 1}
onMoveUp={() => 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)}
/>
))}
.map((conn, index) => {
// Check if this connection has duplicates
const { checkDuplicate: checkDup } = require("@/shared/utils/duplicateDetection");
const otherConnections = connections.filter(c => c.id !== conn.id);
const dupCheck = checkDup(conn, otherConnections);

return (
<ConnectionRow
key={conn.id}
connection={conn}
isOAuth={isOAuth}
isFirst={index === 0}
isLast={index === connections.length - 1}
hasDuplicates={dupCheck.isDuplicate}
onMoveUp={() => 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)}
/>
);
})}
</div>
)}
</Card>
Expand Down Expand Up @@ -548,6 +637,14 @@ export default function ProviderDetailPage() {
isAnthropic={isAnthropicCompatible}
/>
)}
<DuplicateWarningModal
isOpen={showDuplicateModal}
duplicate={duplicateInfo?.duplicate}
reason={duplicateInfo?.reason}
onReplace={handleDuplicateReplace}
onKeepBoth={handleDuplicateKeepBoth}
onCancel={handleDuplicateCancel}
/>
</div>
);
}
Expand Down Expand Up @@ -911,7 +1008,7 @@ CooldownTimer.propTypes = {
until: PropTypes.string.isRequired,
};

function ConnectionRow({ connection, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onEdit, onDelete }) {
function ConnectionRow({ connection, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onEdit, onDelete, hasDuplicates }) {
const displayName = isOAuth
? connection.name || connection.email || connection.displayName || "OAuth Account"
: connection.name;
Expand Down Expand Up @@ -971,10 +1068,16 @@ function ConnectionRow({ connection, isOAuth, isFirst, isLast, onMoveUp, onMoveD
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{displayName}</p>
<div className="flex items-center gap-2 mt-1">
<div className="flex items-center gap-2 mt-1 flex-wrap">
<Badge variant={getStatusVariant()} size="sm" dot>
{connection.isActive === false ? "disabled" : (effectiveStatus || "Unknown")}
</Badge>
{hasDuplicates && (
<Badge variant="warning" size="sm">
<span className="material-symbols-outlined text-[10px] mr-1">warning</span>
Duplicate
</Badge>
)}
{isCooldown && connection.isActive !== false && <CooldownTimer until={connection.rateLimitedUntil} />}
{connection.lastError && connection.isActive !== false && (
<span className="text-xs text-red-500 truncate max-w-[300px]" title={connection.lastError}>
Expand Down Expand Up @@ -1029,6 +1132,7 @@ ConnectionRow.propTypes = {
onToggleActive: PropTypes.func.isRequired,
onEdit: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
hasDuplicates: PropTypes.bool,
};

function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, onSave, onClose }) {
Expand Down
60 changes: 60 additions & 0 deletions src/app/api/providers/check-duplicate/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { NextResponse } from "next/server";
import { getProviderConnections } from "@/models";
import { checkDuplicate } from "@/shared/utils/duplicateDetection";

/**
* POST /api/providers/check-duplicate
* Check if a connection would be a duplicate
*/
export async function POST(request) {
try {
const body = await request.json();
const { provider, authType, email, apiKey, projectId, providerSpecificData } = body;

if (!provider || !authType) {
return NextResponse.json(
{ error: "Provider and authType are required" },
{ status: 400 }
);
}

// Get existing connections for this provider
const existingConnections = await getProviderConnections({ provider });

// Create a temporary connection object for comparison
const tempConnection = {
provider,
authType,
email,
apiKey,
projectId,
providerSpecificData,
};

// Check for duplicates
const result = checkDuplicate(tempConnection, existingConnections);

if (result.isDuplicate) {
return NextResponse.json({
isDuplicate: true,
duplicate: {
id: result.duplicate.id,
name: result.duplicate.name,
email: result.duplicate.email,
priority: result.duplicate.priority,
isActive: result.duplicate.isActive,
createdAt: result.duplicate.createdAt,
},
reason: result.reason,
});
}

return NextResponse.json({ isDuplicate: false });
} catch (error) {
console.error("Error checking duplicate:", error);
return NextResponse.json(
{ error: "Failed to check duplicate" },
{ status: 500 }
);
}
}
40 changes: 39 additions & 1 deletion src/app/api/providers/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { APIKEY_PROVIDERS } from "@/shared/constants/config";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/app/api/sync/cloud/route";
import { checkDuplicate } from "@/shared/utils/duplicateDetection";

// GET /api/providers - List all connections
export async function GET() {
Expand All @@ -30,7 +31,7 @@ export async function GET() {
export async function POST(request) {
try {
const body = await request.json();
const { provider, apiKey, name, priority, globalPriority, defaultModel, testStatus } = body;
const { provider, apiKey, name, priority, globalPriority, defaultModel, testStatus, replaceDuplicateId } = body;

// Validation
const isValidProvider = APIKEY_PROVIDERS[provider] ||
Expand All @@ -47,6 +48,33 @@ export async function POST(request) {
return NextResponse.json({ error: "Name is required" }, { status: 400 });
}

// Get existing connections for duplicate check
const existingConnections = await getProviderConnections({ provider });

// Check for duplicates
const tempConnection = {
provider,
authType: "apikey",
apiKey,
name,
};
const duplicateCheck = checkDuplicate(tempConnection, existingConnections);

if (duplicateCheck.isDuplicate && !replaceDuplicateId) {
return NextResponse.json(
{
error: "duplicate",
duplicate: {
id: duplicateCheck.duplicate.id,
name: duplicateCheck.duplicate.name,
priority: duplicateCheck.duplicate.priority,
},
reason: duplicateCheck.reason,
},
{ status: 409 }
);
}

let providerSpecificData = null;

if (isOpenAICompatibleProvider(provider)) {
Expand All @@ -66,6 +94,8 @@ export async function POST(request) {
baseUrl: node.baseUrl,
nodeName: node.name,
};

// For OpenAI Compatible, don't check duplicates since only 1 connection allowed
} else if (isAnthropicCompatibleProvider(provider)) {
const node = await getProviderNodeById(provider);
if (!node) {
Expand All @@ -82,6 +112,14 @@ export async function POST(request) {
baseUrl: node.baseUrl,
nodeName: node.name,
};

// For Anthropic Compatible, don't check duplicates since only 1 connection allowed
}

// If replacing duplicate, delete the old one first
if (replaceDuplicateId && duplicateCheck.isDuplicate) {
const { deleteProviderConnection } = await import("@/models");
await deleteProviderConnection(replaceDuplicateId);
}

const newConnection = await createProviderConnection({
Expand Down
Loading