import * as React from "react"; import { useTranslation } from "react-i18next"; import { KeyRound, Pencil, Plus, Search, Trash2, Unplug, Wifi, WifiOff, } from "lucide-react"; import { createModelProvider, deleteModelProvider, discoverModels, listModelProviders, testModelProviderConnection, updateModelProvider, } from "@/api"; import { ApiErrorState } from "@/components/shared/ApiErrorState"; import { EmptyState } from "@/components/shared/EmptyState"; import { LoadingSpinner } from "@/components/shared/LoadingSpinner"; import { MetricCard } from "@/components/shared/MetricCard"; import { PageHeader } from "@/components/shared/PageHeader"; import { StatusBadge } from "@/components/shared/StatusBadge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Dialog } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Select } from "@/components/ui/select"; import { toast } from "@/components/ui/toaster"; import { type DiscoveredModel, type ModelItem, type ModelProvider, type ModelProviderType, type ModelProviderUpdateRequest, type ModelType, } from "@/types"; const PROVIDER_TYPE_OPTIONS = [ { value: "openai", label: "OpenAI" }, { value: "anthropic", label: "Anthropic" }, { value: "deepseek", label: "DeepSeek" }, { value: "azure_openai", label: "Azure OpenAI" }, { value: "ollama", label: "Ollama" }, { value: "custom", label: "Custom" }, ]; const DEFAULT_URLS = { openai: "https://api.openai.com/v1", anthropic: "https://api.anthropic.com", deepseek: "https://api.deepseek.com/v1", azure_openai: "https://.openai.azure.com", ollama: "http://localhost:11434", custom: "", } as const; function ModelTypeBadge({ type, compact }: { type: ModelType; compact?: boolean }) { const labels: Record = { chat: "Chat", reasoning: "Reasoning", embedding: "Embedding", image: "Image", audio: "Audio", video: "Video", rerank: "Rerank", moderation: "Moderation", other: "Other", }; return ( {labels[type] || type} ); } export function ModelProvidersPage() { const { t } = useTranslation(); const [providers, setProviders] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(); const [search, setSearch] = React.useState(""); const [providerDialogOpen, setProviderDialogOpen] = React.useState(false); const [editingProvider, setEditingProvider] = React.useState(null); const load = React.useCallback(async () => { setLoading(true); setError(undefined); try { const provs = await listModelProviders(); setProviders(provs); } catch (err) { setError(err instanceof Error ? err.message : t("errors.failedToLoad")); } finally { setLoading(false); } }, [t]); React.useEffect(() => { void load(); }, [load]); async function removeProvider(id: string) { await deleteModelProvider(id); toast.success(t("modelProviders.providerDeleted")); void load(); } async function toggleProviderStatus(provider: ModelProvider) { const next = provider.status === "active" ? "inactive" : "active"; await updateModelProvider(provider.id, { status: next }); void load(); } async function testConnection(provider: ModelProvider) { try { const result = await testModelProviderConnection(provider.id); if (result.success) { toast.success(t("modelProviders.testSuccess", { latency: result.latency_ms })); } else { toast.error(`${t("modelProviders.testFailed")}: ${result.message}`); } } catch { toast.error(t("modelProviders.testFailed")); } } const filtered = providers.filter( (p) => p.name.toLowerCase().includes(search.toLowerCase()) || p.provider_type.toLowerCase().includes(search.toLowerCase()) ); if (loading) return ; if (error) return void load()} />; return (
{ setEditingProvider(null); setProviderDialogOpen(true); }} > {t("modelProviders.addProvider")} } />
p.status === "active").length} icon={Wifi} /> acc + p.models.filter((m) => m.enabled).length, 0)} icon={KeyRound} />
{t("modelProviders.providers")}
setSearch(e.target.value)} placeholder={t("modelProviders.searchProviders")} className="pl-9" />
{filtered.length ? (
{filtered.map((provider) => (
{provider.name} {t(`modelProviders.${provider.provider_type}`)}

{provider.base_url}

{provider.models.filter((m) => m.enabled).map((m) => ( {m.display_name} {provider.default_model === m.model_id ? " *" : ""} ))} {provider.models.filter((m) => m.enabled).length === 0 && ( {t("modelProviders.noModels")} )}
))}
) : ( )}
void load()} />
); } function ProviderDialog({ open, onOpenChange, editing, onSaved, }: { open: boolean; onOpenChange: (open: boolean) => void; editing: ModelProvider | null; onSaved: () => void; }) { const { t } = useTranslation(); const [name, setName] = React.useState(""); const [providerType, setProviderType] = React.useState("openai"); const [baseUrl, setBaseUrl] = React.useState(""); const [apiKey, setApiKey] = React.useState(""); const [models, setModels] = React.useState([]); const [defaultModel, setDefaultModel] = React.useState(""); const [submitting, setSubmitting] = React.useState(false); const [discovering, setDiscovering] = React.useState(false); const [discovered, setDiscovered] = React.useState([]); const [discoverOpen, setDiscoverOpen] = React.useState(false); const [selectedDiscovered, setSelectedDiscovered] = React.useState>(new Set()); const [typeFilter, setTypeFilter] = React.useState("all"); React.useEffect(() => { if (!open) { setDiscoverOpen(false); setDiscovered([]); setSelectedDiscovered(new Set()); return; } if (editing) { setName(editing.name); setProviderType(editing.provider_type); setBaseUrl(editing.base_url); setApiKey(""); setModels(editing.models.map((m) => ({ ...m }))); setDefaultModel(editing.default_model ?? ""); } else { setName(""); setProviderType("openai"); setBaseUrl(DEFAULT_URLS.openai); setApiKey(""); setModels([]); setDefaultModel(""); } }, [open, editing]); function handleTypeChange(newType: string) { const pt = newType as ModelProviderType; setProviderType(pt); setBaseUrl(DEFAULT_URLS[pt] ?? ""); setModels([]); setDefaultModel(""); setDiscovered([]); setDiscoverOpen(false); } function updateModel(index: number, patch: Partial) { setModels((prev) => prev.map((m, i) => (i === index ? { ...m, ...patch } : m))); } function addModelRow() { setModels((prev) => [...prev, { model_id: "", display_name: "", model_type: "chat", enabled: true }]); } function removeModelRow(index: number) { setModels((prev) => prev.filter((_, i) => i !== index)); } async function handleDiscover() { setDiscovering(true); setDiscovered([]); setSelectedDiscovered(new Set()); try { const result = await discoverModels( editing ? { providerId: editing.id } : { providerType, baseUrl, apiKey }, ); setDiscovered(result.models); setDiscoverOpen(true); toast.success(t("modelProviders.discovered", { count: result.models.length })); } catch { toast.error(t("modelProviders.discoverFailed")); } finally { setDiscovering(false); } } function toggleDiscovered(modelId: string) { setSelectedDiscovered((prev) => { const next = new Set(prev); if (next.has(modelId)) next.delete(modelId); else next.add(modelId); return next; }); } function toggleAllDiscovered() { const filteredDiscovered = typeFilter === "all" ? discovered : discovered.filter((m) => m.model_type === typeFilter); if (selectedDiscovered.size === filteredDiscovered.length) { setSelectedDiscovered(new Set()); } else { setSelectedDiscovered(new Set(filteredDiscovered.map((m) => m.model_id))); } } const filteredDiscovered = typeFilter === "all" ? discovered : discovered.filter((m) => m.model_type === typeFilter); function applyDiscovered() { const picked = discovered.filter((m) => selectedDiscovered.has(m.model_id)); const existingIds = new Set(models.map((m) => m.model_id)); const newItems: ModelItem[] = picked .filter((m) => !existingIds.has(m.model_id)) .map((m) => ({ model_id: m.model_id, display_name: m.display_name, model_type: m.model_type, enabled: true, })); setModels((prev) => [...prev, ...newItems]); if (!defaultModel && newItems.length > 0) { setDefaultModel(newItems[0]!.model_id); } setDiscoverOpen(false); setDiscovered([]); setSelectedDiscovered(new Set()); } async function submit(event: React.FormEvent) { event.preventDefault(); setSubmitting(true); try { const validModels = models.filter((m) => m.model_id.trim()); if (editing) { const patch: ModelProviderUpdateRequest = { name, base_url: baseUrl, models: validModels, default_model: defaultModel || null }; if (apiKey) patch.api_key = apiKey; await updateModelProvider(editing.id, patch); toast.success(t("modelProviders.providerUpdated")); } else { await createModelProvider({ name, provider_type: providerType, base_url: baseUrl, api_key: apiKey, models: validModels, default_model: defaultModel || null, }); toast.success(t("modelProviders.providerCreated")); } onOpenChange(false); onSaved(); } catch { toast.error(t("errors.failedToSave")); } finally { setSubmitting(false); } } const discoveredTypes = Array.from(new Set(discovered.map((m) => m.model_type))); return (
{t("modelProviders.availableModels")} ({models.length})
{models.length > 0 && (
{models.map((model, index) => (
updateModel(index, { model_id: e.target.value })} className="flex-1" /> updateModel(index, { display_name: e.target.value })} className="w-32" /> setTypeFilter(e.target.value)} options={[{ value: "all", label: t("modelProviders.allTypes") }, ...discoveredTypes.map((t) => ({ value: t, label: t }))]} className="w-32" />
{filteredDiscovered.map((m) => ( ))} {filteredDiscovered.length === 0 && (

{t("modelProviders.noModelsDiscovered")}

)}
)}
); }