"use client"; import { useCallback, useEffect, useMemo, useState, type ChangeEvent, type ReactElement, type SyntheticEvent, } from "react"; import { Settings, Trash2 } from "lucide-react"; import { FleetSettingsNav } from "@/components/settings/FleetSettingsNav"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { createFleetProvider, deleteFleetProvider, fetchFleetProviders, updateFleetProvider, type CreateFleetProviderRequest, type FleetProvider, type FleetProviderModel, type UpdateFleetProviderRequest, } from "@/lib/api/fleet-settings"; interface ProviderTypeOption { value: string; label: string; } interface ProviderFormState { type: string; displayName: string; apiKey: string; baseUrl: string; modelsText: string; isActive: boolean; } const PROVIDER_TYPE_OPTIONS: ProviderTypeOption[] = [ { value: "openai", label: "OpenAI Compatible" }, { value: "claude", label: "Claude / Anthropic" }, { value: "ollama", label: "Ollama" }, { value: "zai", label: "Z.ai" }, { value: "custom", label: "Custom" }, ]; const INITIAL_FORM: ProviderFormState = { type: "openai", displayName: "", apiKey: "", baseUrl: "", modelsText: "", isActive: true, }; function buildProviderName(displayName: string, type: string): string { const slug = displayName .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+/, "") .replace(/-+$/, ""); const candidate = `${type}-${slug.length > 0 ? slug : "provider"}`; return candidate.slice(0, 100); } function getErrorMessage(error: unknown, fallback: string): string { if (error instanceof Error && error.message.trim().length > 0) { return error.message; } return fallback; } function normalizeProviderModels(models: unknown): FleetProviderModel[] { if (!Array.isArray(models)) { return []; } const normalized: FleetProviderModel[] = []; models.forEach((entry) => { if (typeof entry === "string" && entry.trim().length > 0) { normalized.push({ id: entry.trim(), name: entry.trim() }); return; } if (entry && typeof entry === "object") { const record = entry as Record; const id = typeof record.id === "string" ? record.id.trim() : typeof record.name === "string" ? record.name.trim() : ""; if (id.length > 0) { const name = typeof record.name === "string" && record.name.trim().length > 0 ? record.name.trim() : id; normalized.push({ id, name }); } } }); const seen = new Set(); return normalized.filter((model) => { if (seen.has(model.id)) { return false; } seen.add(model.id); return true; }); } function modelsToEditorText(models: unknown): string { return normalizeProviderModels(models) .map((model) => model.id) .join("\n"); } function parseModelsText(value: string): string[] { const seen = new Set(); return value .split(/\r?\n/g) .map((segment) => segment.trim()) .filter((segment) => segment.length > 0) .filter((segment) => { if (seen.has(segment)) { return false; } seen.add(segment); return true; }); } function maskApiKey(value: string): string { if (value.length === 0) { return "Not set"; } if (value.length <= 7) { return "*".repeat(Math.max(4, value.length)); } return `${value.slice(0, 3)}****...${value.slice(-4)}`; } export default function ProvidersSettingsPage(): ReactElement { const [providers, setProviders] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isRefreshing, setIsRefreshing] = useState(false); const [error, setError] = useState(null); const [successMessage, setSuccessMessage] = useState(null); const [isDialogOpen, setIsDialogOpen] = useState(false); const [editingProvider, setEditingProvider] = useState(null); const [form, setForm] = useState(INITIAL_FORM); const [formError, setFormError] = useState(null); const [isSaving, setIsSaving] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [isDeleting, setIsDeleting] = useState(false); const loadProviders = useCallback(async (showLoadingState: boolean): Promise => { if (showLoadingState) { setIsLoading(true); } else { setIsRefreshing(true); } try { const data = await fetchFleetProviders(); setProviders(data); setError(null); } catch (loadError: unknown) { setError(getErrorMessage(loadError, "Failed to load providers.")); } finally { setIsLoading(false); setIsRefreshing(false); } }, []); useEffect(() => { void loadProviders(true); }, [loadProviders]); const apiKeyHint = useMemo(() => { const enteredKey = form.apiKey.trim(); if (enteredKey.length > 0) { return `Masked preview: ${maskApiKey(enteredKey)}`; } if (editingProvider) { return "Stored API key remains encrypted and hidden. Enter a new key only when rotating."; } return "API keys are never shown decrypted. Only masked previews are displayed while typing."; }, [editingProvider, form.apiKey]); function openCreateDialog(): void { setEditingProvider(null); setForm(INITIAL_FORM); setFormError(null); setIsDialogOpen(true); } function openEditDialog(provider: FleetProvider): void { setEditingProvider(provider); setForm({ type: provider.type, displayName: provider.displayName, apiKey: "", baseUrl: provider.baseUrl ?? "", modelsText: modelsToEditorText(provider.models), isActive: provider.isActive, }); setFormError(null); setIsDialogOpen(true); } function closeDialog(): void { if (isSaving) { return; } setIsDialogOpen(false); setEditingProvider(null); setForm(INITIAL_FORM); setFormError(null); } async function handleSubmit(event: SyntheticEvent): Promise { event.preventDefault(); setFormError(null); setSuccessMessage(null); const displayName = form.displayName.trim(); if (displayName.length === 0) { setFormError("Display name is required."); return; } const models = parseModelsText(form.modelsText); const providerModels = models.map((id) => ({ id, name: id })); const baseUrl = form.baseUrl.trim(); const apiKey = form.apiKey.trim(); try { setIsSaving(true); if (editingProvider) { const updatePayload: UpdateFleetProviderRequest = { displayName, isActive: form.isActive, models: providerModels, }; if (baseUrl.length > 0) { updatePayload.baseUrl = baseUrl; } if (apiKey.length > 0) { updatePayload.apiKey = apiKey; } await updateFleetProvider(editingProvider.id, updatePayload); setSuccessMessage(`Updated provider "${displayName}".`); } else { const createPayload: CreateFleetProviderRequest = { name: buildProviderName(displayName, form.type), displayName, type: form.type, }; if (baseUrl.length > 0) { createPayload.baseUrl = baseUrl; } if (apiKey.length > 0) { createPayload.apiKey = apiKey; } if (providerModels.length > 0) { createPayload.models = providerModels; } await createFleetProvider(createPayload); setSuccessMessage(`Added provider "${displayName}".`); } setIsDialogOpen(false); setEditingProvider(null); setForm(INITIAL_FORM); await loadProviders(false); } catch (saveError: unknown) { setFormError(getErrorMessage(saveError, "Unable to save provider.")); } finally { setIsSaving(false); } } async function handleDeleteProvider(): Promise { if (!deleteTarget) { return; } try { setIsDeleting(true); await deleteFleetProvider(deleteTarget.id); setSuccessMessage(`Deleted provider "${deleteTarget.displayName}".`); setDeleteTarget(null); await loadProviders(false); } catch (deleteError: unknown) { setError(getErrorMessage(deleteError, "Failed to delete provider.")); } finally { setIsDeleting(false); } } return (

LLM Providers

Manage provider endpoints, model inventories, and encrypted API credentials.

Provider Directory API keys are always encrypted in storage and never displayed in plaintext.
{error ? (

{error}

) : null} {successMessage ?

{successMessage}

: null} {isLoading ? (

Loading providers...

) : providers.length === 0 ? (

No providers configured yet. Add one to make models available for agent assignment.

) : ( providers.map((provider) => { const providerModels = normalizeProviderModels(provider.models); return (

{provider.displayName}

{provider.isActive ? "Active" : "Inactive"} {provider.type}

Name: {provider.name}

Base URL: {provider.baseUrl ?? "Provider default"}

API Key: encrypted and hidden (never returned decrypted)

{providerModels.length === 0 ? ( No models configured ) : ( providerModels.map((model) => ( {model.id} )) )}
); }) )}
{ if (!nextOpen) { closeDialog(); return; } setIsDialogOpen(true); }} > {editingProvider ? "Edit Provider" : "Add Provider"} Configure connection details and model IDs. API keys are masked in the UI.
void handleSubmit(event)} className="space-y-4">
) => { setForm((previous) => ({ ...previous, displayName: event.target.value })); }} placeholder="OpenAI Primary" maxLength={255} disabled={isSaving} required />
) => { setForm((previous) => ({ ...previous, apiKey: event.target.value })); }} placeholder={editingProvider ? "Enter new key to rotate" : "sk-..."} autoComplete="new-password" disabled={isSaving} />

{apiKeyHint}

) => { setForm((previous) => ({ ...previous, baseUrl: event.target.value })); }} placeholder="https://api.provider.com/v1" disabled={isSaving} />