'use client'; import { useState, useCallback, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Loader2, CheckCircle2, XCircle, Eye, EyeOff, RotateCcw, Plus, Zap, Settings2, Trash2, Sparkles, Wrench, FileText, Send, Download, } from 'lucide-react'; import { useI18n } from '@/lib/hooks/use-i18n'; import type { ProviderConfig } from '@/lib/ai/providers'; import type { ProvidersConfig } from '@/lib/types/settings'; import { createVerifyModelRequest, formatContextWindow } from './utils'; import { cn } from '@/lib/utils'; interface ProviderConfigPanelProps { provider: ProviderConfig; initialApiKey: string; initialBaseUrl: string; initialRequiresApiKey: boolean; providersConfig: ProvidersConfig; onConfigChange: (apiKey: string, baseUrl: string, requiresApiKey: boolean) => void; onSave: () => void; // Auto-save on blur onEditModel: (index: number) => void; onDeleteModel: (index: number) => void; onAddModel: () => void; /** Merge probed model ids into the provider's list; returns the count added. */ onModelsFetched?: (ids: string[]) => number; /** Optional explicit /models URL override (from a preset). */ modelsUrl?: string; onResetToDefault?: () => void; // Reset provider to default configuration isBuiltIn: boolean; // To determine if reset button should be shown } export function ProviderConfigPanel({ provider, initialApiKey, initialBaseUrl, initialRequiresApiKey, providersConfig, onConfigChange, onSave, onEditModel, onDeleteModel, onAddModel, onModelsFetched, modelsUrl, onResetToDefault, isBuiltIn, }: ProviderConfigPanelProps) { const { t } = useI18n(); // Local state for this provider const [apiKey, setApiKey] = useState(initialApiKey); const [baseUrl, setBaseUrl] = useState(initialBaseUrl); const [requiresApiKey, setRequiresApiKey] = useState(initialRequiresApiKey); const [showApiKey, setShowApiKey] = useState(false); const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle'); const [testMessage, setTestMessage] = useState(''); const [showResetDialog, setShowResetDialog] = useState(false); const [fetchStatus, setFetchStatus] = useState<'idle' | 'fetching' | 'success' | 'error'>('idle'); const [fetchMessage, setFetchMessage] = useState(''); // Update local state when provider changes or initial values change useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- Sync local state from props on provider change setApiKey(initialApiKey); setBaseUrl(initialBaseUrl); setRequiresApiKey(initialRequiresApiKey); setTestStatus('idle'); setTestMessage(''); setFetchStatus('idle'); setFetchMessage(''); }, [provider.id, initialApiKey, initialBaseUrl, initialRequiresApiKey]); // Notify parent of changes const handleApiKeyChange = (key: string) => { setApiKey(key); onConfigChange(key, baseUrl, requiresApiKey); }; const handleBaseUrlChange = (url: string) => { setBaseUrl(url); onConfigChange(apiKey, url, requiresApiKey); }; const handleRequiresApiKeyChange = (requires: boolean) => { setRequiresApiKey(requires); onConfigChange(apiKey, baseUrl, requires); }; const handleTestApi = useCallback(async () => { setTestStatus('testing'); setTestMessage(''); const availableModels = providersConfig[provider.id]?.models || []; if (availableModels.length === 0) { setTestStatus('error'); setTestMessage(t('settings.noModelsAvailable') || 'No models available for testing'); return; } const testModelId = availableModels[0].id; try { const response = await fetch('/api/verify-model', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify( createVerifyModelRequest({ providerId: provider.id, modelId: testModelId, apiKey, baseUrl, providerType: provider.type, requiresApiKey, }), ), }); const data = await response.json(); if (data.success) { setTestStatus('success'); setTestMessage(t('settings.connectionSuccess')); } else { setTestStatus('error'); setTestMessage(data.error || t('settings.connectionFailed')); } } catch (_error) { setTestStatus('error'); setTestMessage(t('settings.connectionFailed')); } }, [apiKey, baseUrl, provider.id, provider.type, requiresApiKey, providersConfig, t]); const effectiveBaseUrl = baseUrl || provider.defaultBaseUrl || ''; // Probe the provider's /models endpoint and merge results into the model list. const handleFetchModels = useCallback(async () => { setFetchStatus('fetching'); setFetchMessage(''); try { const response = await fetch('/api/provider/probe-models', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ baseUrl: effectiveBaseUrl, apiKey, modelsUrl }), }); const data = await response.json(); if (response.ok && data.success) { const ids: string[] = (data.models || []).map((m: { id: string }) => m.id); const added = onModelsFetched?.(ids) ?? 0; setFetchStatus('success'); setFetchMessage( t('settings.fetchModelsResult') .replace('{added}', String(added)) .replace('{total}', String(ids.length)), ); } else if (response.status === 404) { setFetchStatus('error'); setFetchMessage(t('settings.fetchModelsNoEndpoint')); } else if (response.status === 401) { setFetchStatus('error'); setFetchMessage(t('settings.fetchModelsAuthError')); } else { setFetchStatus('error'); setFetchMessage(data.error || t('settings.fetchModelsFailed')); } } catch { setFetchStatus('error'); setFetchMessage(t('settings.fetchModelsFailed')); } }, [apiKey, effectiveBaseUrl, modelsUrl, onModelsFetched, t]); const models = providersConfig[provider.id]?.models || []; const isServerConfigured = providersConfig[provider.id]?.isServerConfigured; // When the operator pins an allowed model list (MODELS env/yaml), the model // catalog is admin-managed too — view-only, no add/edit/delete. Without a // pinned list the server manages only credentials and the user curates models. const modelsLocked = !!providersConfig[provider.id]?.serverModels?.length; return (
{/* Server-configured notice */} {isServerConfigured && (
{t('settings.serverConfiguredNotice')}
)} {/* Managed providers are admin-owned: the operator's key and base URL are authoritative and not overridable here, so the editing inputs are hidden. */} {!isServerConfigured && ( <> {/* API Key */}
handleApiKeyChange(e.target.value)} onBlur={onSave} disabled={!requiresApiKey} className="h-8 pr-8" />
{testMessage && (
{testStatus === 'success' && } {testStatus === 'error' && }

{testMessage}

)}
{ handleRequiresApiKeyChange(checked as boolean); onSave(); }} />
{/* API Host */}
handleBaseUrlChange(e.target.value)} onBlur={onSave} className="h-8" /> {provider.alternateBaseUrls && provider.alternateBaseUrls.length > 0 && (
{provider.alternateBaseUrls.map((alt) => { const active = (baseUrl || provider.defaultBaseUrl) === alt.url; return ( ); })}
)} {(() => { const effectiveBaseUrl = baseUrl || provider.defaultBaseUrl || ''; if (!effectiveBaseUrl) return null; // Generate endpoint path based on provider type let endpointPath = ''; switch (provider.type) { case 'openai': endpointPath = '/chat/completions'; break; case 'azure': endpointPath = '/v1/responses?api-version=v1'; break; case 'anthropic': endpointPath = '/messages'; break; case 'google': endpointPath = '/models/[model]'; break; default: endpointPath = ''; } const fullUrl = effectiveBaseUrl + endpointPath; return (

{t('settings.requestUrl')}: {fullUrl}

); })()}
)} {/* Models - No selection state, just list for management */}
{provider.id === 'azure' && (
{t('settings.azureDeploymentHint')}
)}
{modelsLocked && ( {t('settings.serverConfigured')} )}
{!modelsLocked && (
{isBuiltIn && onResetToDefault && ( )} {provider.supportsModelDiscovery !== false && ( )}
)}
{/* Fetch-models result message */} {fetchMessage && (
{fetchMessage}
)}
{models.map((model, index) => { return (
{model.name}
{/* Capabilities */}
{model.capabilities?.vision && (
)} {model.capabilities?.tools && (
)} {model.capabilities?.streaming && (
)}
{/* Context Window */} {model.contextWindow && ( {formatContextWindow(model.contextWindow)} )} {/* Output Window */} {model.outputWindow && ( {formatContextWindow(model.outputWindow)} )}
{/* Edit/Delete Buttons — hidden when the model catalog is server-managed */} {!modelsLocked && (
)}
); })}
{/* Reset Confirmation Dialog */} {t('settings.resetToDefault')} {t('settings.resetConfirmDescription')} {t('settings.cancelEdit')} { setShowResetDialog(false); onResetToDefault?.(); }} > {t('settings.confirmReset')}
); }