import { useCallback, useEffect, useState } from "react"; import { addCustomProvider, deleteCustomProvider, fetchCustomProviders, probeProviderModels, updateCustomProvider, type CustomProvider, } from "../api"; import { AlertCircle, Loader2, Pencil, Plus, Search, Trash2 } from "lucide-react"; import { OnboardingDisclosure } from "./OnboardingDisclosure"; import "./CustomProvidersSection.css"; type ProviderApiType = CustomProvider["apiType"]; const API_TYPES: ProviderApiType[] = ["openai-compatible", "openai-responses", "anthropic-compatible", "google-generative-ai"]; type LegacyProvider = { id: string; name?: string; baseUrl: string; api: "openai-completions" | "openai-responses" | "anthropic-messages" | "google-generative-ai"; apiKey?: string; models?: Array<{ id: string; name?: string }>; }; function normalizeProviders(result: Awaited>): CustomProvider[] { const providerRecords: Array = Array.isArray(result) ? (result as Array) : ((result as { providers?: Array }).providers ?? []); return providerRecords.map((provider) => { if ("apiType" in provider) { return provider; } return { id: provider.id, name: provider.name?.trim() || provider.id, apiType: provider.api === "anthropic-messages" ? "anthropic-compatible" : provider.api === "openai-responses" ? "openai-responses" : provider.api === "google-generative-ai" ? "google-generative-ai" : "openai-compatible", baseUrl: provider.baseUrl, ...(provider.apiKey ? { apiKey: provider.apiKey } : {}), models: (provider.models ?? []).map((model) => ({ id: model.id, name: model.name ?? model.id, })), } satisfies CustomProvider; }); } function parseModels(modelsInput: string): { id: string; name: string }[] { return modelsInput .split(",") .map((model) => model.trim()) .filter(Boolean) .map((model) => ({ id: model, name: model })); } interface CustomProvidersSectionProps { embedded?: boolean; onProviderChange?: () => void; } export function CustomProvidersSection({ embedded = false, onProviderChange }: CustomProvidersSectionProps) { const [providers, setProviders] = useState([]); const [loading, setLoading] = useState(false); const [loaded, setLoaded] = useState(false); const [error, setError] = useState(null); const [editingProvider, setEditingProvider] = useState(null); const [isFormOpen, setIsFormOpen] = useState(false); const [name, setName] = useState(""); const [apiType, setApiType] = useState("openai-compatible"); const [baseUrl, setBaseUrl] = useState(""); const [apiKey, setApiKey] = useState(""); const [models, setModels] = useState(""); const [saving, setSaving] = useState(false); const [formError, setFormError] = useState(null); const [detecting, setDetecting] = useState(false); const [detectError, setDetectError] = useState(null); const loadProviders = useCallback(async () => { setLoading(true); setError(null); try { const response = await fetchCustomProviders(); setProviders(normalizeProviders(response)); setLoaded(true); } catch (loadError) { setError(loadError instanceof Error ? loadError.message : "Failed to load custom providers."); } finally { setLoading(false); } }, []); const handleDisclosureToggle = useCallback( (isOpen: boolean) => { if (isOpen && !loaded && !loading) { void loadProviders(); } }, [loaded, loading, loadProviders], ); useEffect(() => { if (embedded && !loaded && !loading) { void loadProviders(); } }, [embedded, loaded, loading, loadProviders]); const resetForm = useCallback(() => { setEditingProvider(null); setName(""); setApiType("openai-compatible"); setBaseUrl(""); setApiKey(""); setModels(""); setFormError(null); setDetectError(null); setDetecting(false); setIsFormOpen(false); }, []); const openAddForm = useCallback(() => { setEditingProvider(null); setName(""); setApiType("openai-compatible"); setBaseUrl(""); setApiKey(""); setModels(""); setFormError(null); setDetectError(null); setDetecting(false); setIsFormOpen(true); }, []); const openEditForm = useCallback((provider: CustomProvider) => { setEditingProvider(provider); setName(provider.name); setApiType(provider.apiType); setBaseUrl(provider.baseUrl); setApiKey(provider.apiKey ?? ""); setModels((provider.models ?? []).map((model) => model.id).join(", ")); setFormError(null); setDetectError(null); setDetecting(false); setIsFormOpen(true); }, []); const validateForm = useCallback((): string | null => { if (!name.trim()) { return "Provider name is required."; } if (!baseUrl.trim()) { return "Base URL is required."; } let validProtocol = false; try { const parsed = new URL(baseUrl.trim()); validProtocol = parsed.protocol === "http:" || parsed.protocol === "https:"; } catch { validProtocol = false; } if (!validProtocol) { return "Base URL must be a valid http/https URL."; } if (!API_TYPES.includes(apiType)) { return "API type is invalid."; } return null; }, [apiType, baseUrl, name]); // Detect Models is available for all API types that expose a /models endpoint const handleDetectModels = useCallback(async () => { const trimmedBaseUrl = baseUrl.trim(); if (!trimmedBaseUrl) { setDetectError("Base URL is required to detect models."); return; } setDetecting(true); setDetectError(null); try { const result = await probeProviderModels({ baseUrl: trimmedBaseUrl, apiKey: apiKey.trim() || undefined, apiType, }); if (result.models.length > 0) { setModels((prev) => { const existingIds = new Set( prev.split(",").map((s) => s.trim()).filter(Boolean), ); const newIds = result.models .map((m) => m.id.trim()) .filter((id) => !existingIds.has(id)); if (newIds.length === 0) return prev; const existing = prev.trim(); return newIds.join(", ") + (existing ? ", " + existing : ""); }); } else { setDetectError("No models found. The provider may require an API key."); } } catch (err) { setDetectError( err instanceof Error ? err.message : "Failed to detect models", ); } finally { setDetecting(false); } }, [baseUrl, apiKey, apiType]); const handleSave = useCallback(async () => { const validationError = validateForm(); setFormError(validationError); if (validationError) return; const parsedModels = parseModels(models); const payload: Omit = { name: name.trim(), apiType, baseUrl: baseUrl.trim(), ...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}), ...(parsedModels.length > 0 ? { models: parsedModels } : {}), }; setSaving(true); setError(null); try { if (editingProvider) { await updateCustomProvider(editingProvider.id, payload); } else { await addCustomProvider(payload); } await loadProviders(); onProviderChange?.(); resetForm(); } catch (saveError) { setFormError(saveError instanceof Error ? saveError.message : "Failed to save provider."); } finally { setSaving(false); } }, [apiKey, apiType, baseUrl, editingProvider, loadProviders, models, name, resetForm, validateForm]); const handleDelete = useCallback( async (provider: CustomProvider) => { if (!window.confirm(`Delete custom provider "${provider.name}"?`)) return; setError(null); try { await deleteCustomProvider(provider.id); await loadProviders(); onProviderChange?.(); } catch (deleteError) { setError(deleteError instanceof Error ? deleteError.message : "Failed to delete provider."); } }, [loadProviders, onProviderChange], ); const sectionContent = ( <> {embedded ? null : loading ? (
) : null} {embedded ? null : !loading && error ? (
) : null} {!loading && providers.length > 0 ? (
{providers.map((provider) => { const isEditingThisProvider = isFormOpen && editingProvider?.id === provider.id; return (
{provider.name}
{provider.apiType} {provider.baseUrl}
{isEditingThisProvider ? (
setName(event.target.value)} disabled={saving} />
setBaseUrl(event.target.value)} disabled={saving} />
setApiKey(event.target.value)} disabled={saving} />
setModels(event.target.value)} disabled={saving} />
{detectError ?
{detectError}
: null} {formError ?
{formError}
: null}
) : null}
); })}
) : null} {!loading && providers.length === 0 && !error ? (
No custom providers configured.
) : null} {isFormOpen && !editingProvider ? (
setName(event.target.value)} disabled={saving} />
setBaseUrl(event.target.value)} disabled={saving} />
setApiKey(event.target.value)} disabled={saving} />
setModels(event.target.value)} disabled={saving} />
{detectError ?
{detectError}
: null} {formError ?
{formError}
: null}
) : null} ); return (
{embedded ? sectionContent : ( {sectionContent} )}
); }