ai_api / src /app /(dashboard) /dashboard /providers /components /AddCompatibleProviderModal.tsx
Yogesh
initial deploy
cd8bd0a
Raw
History Blame Contribute Delete
11.7 kB
"use client";
import { useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Badge, Button, Input, Modal, Select } from "@/shared/components";
type CompatibleMode = "openai" | "anthropic" | "cc";
type CompatibleProviderNode = { id: string } & Record<string, unknown>;
interface AddCompatibleProviderModalProps {
isOpen: boolean;
mode: CompatibleMode;
title?: string;
onClose: () => void;
onCreated: (node: CompatibleProviderNode) => void;
}
interface CompatibleFormState {
name: string;
prefix: string;
apiType: string;
baseUrl: string;
chatPath: string;
modelsPath: string;
}
const CC_DEFAULT_CHAT_PATH = "/v1/messages?beta=true";
const MODE_DEFAULTS: Record<
CompatibleMode,
{
baseUrl: string;
type: "openai-compatible" | "anthropic-compatible";
compatMode?: "cc";
chatPath: string;
hasApiType: boolean;
hasModelsPath: boolean;
hasWarning: boolean;
}
> = {
openai: {
baseUrl: "https://api.openai.com/v1",
type: "openai-compatible",
chatPath: "",
hasApiType: true,
hasModelsPath: true,
hasWarning: false,
},
anthropic: {
baseUrl: "https://api.anthropic.com/v1",
type: "anthropic-compatible",
chatPath: "",
hasApiType: false,
hasModelsPath: true,
hasWarning: false,
},
cc: {
baseUrl: "",
type: "anthropic-compatible",
compatMode: "cc",
chatPath: CC_DEFAULT_CHAT_PATH,
hasApiType: false,
hasModelsPath: false,
hasWarning: true,
},
};
function createInitialForm(mode: CompatibleMode): CompatibleFormState {
const defaults = MODE_DEFAULTS[mode];
return {
name: "",
prefix: "",
apiType: "chat",
baseUrl: defaults.baseUrl,
chatPath: defaults.chatPath,
modelsPath: "",
};
}
export default function AddCompatibleProviderModal({
isOpen,
mode,
title,
onClose,
onCreated,
}: AddCompatibleProviderModalProps) {
const t = useTranslations("providers");
const defaults = MODE_DEFAULTS[mode];
const [formData, setFormData] = useState<CompatibleFormState>(() => createInitialForm(mode));
const [submitting, setSubmitting] = useState(false);
const [checkKey, setCheckKey] = useState("");
const [checkModelId, setCheckModelId] = useState("");
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState<
null | { valid: boolean; error?: string | null; method?: string | null }
>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const apiTypeOptions = useMemo(
() => [
{ value: "chat", label: t("chatCompletions") },
{ value: "responses", label: t("responsesApi") },
{ value: "embeddings", label: t("embeddings") },
{ value: "audio-transcriptions", label: t("audioTranscriptions") },
{ value: "audio-speech", label: t("audioSpeech") },
{ value: "images-generations", label: t("imagesGenerations") },
],
[t]
);
useEffect(() => {
if (!isOpen) return;
setFormData(createInitialForm(mode));
setValidationResult(null);
setCheckKey("");
setShowAdvanced(false);
}, [isOpen, mode]);
const modalTitle =
title ||
(mode === "openai"
? t("addOpenAICompatible")
: mode === "anthropic"
? t("addAnthropicCompatible")
: t("addCcCompatible"));
const namePlaceholder =
mode === "cc"
? t("ccCompatibleNamePlaceholder")
: t("compatibleProdPlaceholder", {
type: mode === "openai" ? t("openai") : t("anthropic"),
});
const nameHint = mode === "cc" ? t("ccCompatibleNameHint") : t("nameHint");
const prefixPlaceholder =
mode === "openai"
? t("openaiPrefixPlaceholder")
: mode === "cc"
? t("ccCompatiblePrefixPlaceholder")
: t("anthropicPrefixPlaceholder");
const prefixHint = mode === "cc" ? t("ccCompatiblePrefixHint") : t("prefixHint");
const baseUrlPlaceholder =
mode === "openai"
? t("openaiBaseUrlPlaceholder")
: mode === "cc"
? t("ccCompatibleBaseUrlPlaceholder")
: t("anthropicBaseUrlPlaceholder");
const baseUrlHint =
mode === "cc"
? t("ccCompatibleBaseUrlHint")
: t("compatibleBaseUrlHint", {
type: mode === "openai" ? t("openai") : t("anthropic"),
});
const chatPathPlaceholder =
mode === "openai" ? "/v1/chat/completions" : mode === "cc" ? CC_DEFAULT_CHAT_PATH : "/messages";
const chatPathHint = mode === "cc" ? t("ccCompatibleChatPathHint") : t("chatPathHint");
const advancedId = `advanced-settings-${mode}`;
const hasRequiredFields = Boolean(
formData.name.trim() && formData.prefix.trim() && formData.baseUrl.trim()
);
const canValidate = Boolean(checkKey.trim() && formData.baseUrl.trim());
const resetAfterCreate = () => {
setFormData(createInitialForm(mode));
setCheckKey("");
setValidationResult(null);
setShowAdvanced(false);
};
const handleSubmit = async () => {
if (!hasRequiredFields) return;
setSubmitting(true);
try {
const body: Record<string, unknown> = {
name: formData.name,
prefix: formData.prefix,
baseUrl: formData.baseUrl,
type: defaults.type,
chatPath: formData.chatPath || (mode === "cc" ? CC_DEFAULT_CHAT_PATH : ""),
};
if (defaults.hasApiType) body.apiType = formData.apiType;
if (defaults.hasModelsPath) body.modelsPath = formData.modelsPath || "";
if (defaults.compatMode) body.compatMode = defaults.compatMode;
const res = await fetch("/api/provider-nodes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = (await res.json()) as { node: CompatibleProviderNode };
if (res.ok) {
onCreated(data.node);
resetAfterCreate();
}
} catch (error) {
console.log(`Error creating ${mode} compatible node:`, error);
} finally {
setSubmitting(false);
}
};
const handleValidate = async () => {
setValidating(true);
try {
const body: Record<string, unknown> = {
baseUrl: formData.baseUrl,
apiKey: checkKey,
type: defaults.type,
};
if (defaults.hasModelsPath) body.modelsPath = formData.modelsPath || "";
if (defaults.compatMode) {
body.compatMode = defaults.compatMode;
body.chatPath = formData.chatPath || CC_DEFAULT_CHAT_PATH;
}
const trimmedModelId = checkModelId.trim();
if (trimmedModelId) body.modelId = trimmedModelId;
const res = await fetch("/api/provider-nodes/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
setValidationResult({
valid: !!data.valid,
error: data.error ?? null,
method: data.method ?? null,
});
} catch {
setValidationResult({ valid: false, error: "Network error" });
} finally {
setValidating(false);
}
};
return (
<Modal isOpen={isOpen} title={modalTitle} onClose={onClose}>
<div className="flex flex-col gap-4">
{defaults.hasWarning && (
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-sm text-text-muted">
<div className="flex items-start gap-2">
<span className="material-symbols-outlined mt-0.5 text-[18px] text-amber-500">
warning
</span>
<p>{t("ccCompatibleValidationHint")}</p>
</div>
</div>
)}
<Input
label={t("nameLabel")}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={namePlaceholder}
hint={nameHint}
/>
<Input
label={t("prefixLabel")}
value={formData.prefix}
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
placeholder={prefixPlaceholder}
hint={prefixHint}
/>
{defaults.hasApiType && (
<Select
label={t("apiTypeLabel")}
options={apiTypeOptions}
value={formData.apiType}
onChange={(e) => setFormData({ ...formData, apiType: e.target.value })}
/>
)}
<Input
label={t("baseUrlLabel")}
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder={baseUrlPlaceholder}
hint={baseUrlHint}
/>
<button
type="button"
className="text-sm text-text-muted hover:text-text-primary flex items-center gap-1"
onClick={() => setShowAdvanced(!showAdvanced)}
aria-expanded={showAdvanced}
aria-controls={advancedId}
>
<span
className={`transition-transform ${showAdvanced ? "rotate-90" : ""}`}
aria-hidden="true"
>
{">"}
</span>
{t("advancedSettings")}
</button>
{showAdvanced && (
<div id={advancedId} className="flex flex-col gap-3 pl-2 border-l-2 border-border">
<Input
label={t("chatPathLabel")}
value={formData.chatPath}
onChange={(e) => setFormData({ ...formData, chatPath: e.target.value })}
placeholder={chatPathPlaceholder}
hint={chatPathHint}
/>
{defaults.hasModelsPath && (
<Input
label={t("modelsPathLabel")}
value={formData.modelsPath}
onChange={(e) => setFormData({ ...formData, modelsPath: e.target.value })}
placeholder={t("modelsPathPlaceholder")}
hint={t("modelsPathHint")}
/>
)}
</div>
)}
<div className="flex gap-2">
<Input
label={t("apiKeyForCheck")}
type="password"
value={checkKey}
onChange={(e) => setCheckKey(e.target.value)}
className="flex-1"
/>
<div className="pt-6">
<Button
onClick={handleValidate}
disabled={!canValidate || validating}
variant="secondary"
>
{validating ? t("checking") : t("check")}
</Button>
</div>
</div>
<Input
label={t("testModelIdLabel")}
value={checkModelId}
onChange={(e) => setCheckModelId(e.target.value)}
placeholder={t("testModelIdPlaceholder")}
hint={t("testModelIdHint")}
/>
{validationResult && (
<div className="flex flex-col gap-1">
<Badge variant={validationResult.valid ? "success" : "error"}>
{validationResult.valid ? t("valid") : t("invalid")}
</Badge>
{validationResult.error && (
<span
className={`text-sm ${validationResult.valid ? "text-text-muted" : "text-red-500"}`}
>
{validationResult.error}
</span>
)}
</div>
)}
<div className="flex gap-2">
<Button onClick={handleSubmit} fullWidth disabled={!hasRequiredFields || submitting}>
{submitting ? t("creating") : t("add")}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
{t("cancel")}
</Button>
</div>
</div>
</Modal>
);
}