/** * Providers Settings Component * Manage AI provider configurations and API keys */ import React, { useEffect, useMemo, useState } from 'react'; import { Plus, Trash2, Edit, Eye, EyeOff, Check, X, Loader2, Star, Key, ExternalLink, Copy, XCircle, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Separator } from '@/components/ui/separator'; import { useProviderStore, type ProviderAccount, type ProviderConfig, type ProviderVendorInfo, } from '@/stores/providers'; import { PROVIDER_TYPE_INFO, type ProviderType, getProviderIconUrl, resolveProviderApiKeyForSave, resolveProviderModelForSave, shouldShowProviderModelId, shouldInvertInDark, } from '@/lib/providers'; import { buildProviderAccountId, buildProviderListItems, type ProviderListItem, } from '@/lib/provider-accounts'; import { cn } from '@/lib/utils'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import { invokeIpc } from '@/lib/api-client'; import { useSettingsStore } from '@/stores/settings'; import { hostApiFetch } from '@/lib/host-api'; import { subscribeHostEvent } from '@/lib/host-events'; function normalizeFallbackProviderIds(ids?: string[]): string[] { return Array.from(new Set((ids ?? []).filter(Boolean))); } function fallbackProviderIdsEqual(a?: string[], b?: string[]): boolean { const left = normalizeFallbackProviderIds(a).sort(); const right = normalizeFallbackProviderIds(b).sort(); return left.length === right.length && left.every((id, index) => id === right[index]); } function normalizeFallbackModels(models?: string[]): string[] { return Array.from(new Set((models ?? []).map((model) => model.trim()).filter(Boolean))); } function fallbackModelsEqual(a?: string[], b?: string[]): boolean { const left = normalizeFallbackModels(a); const right = normalizeFallbackModels(b); return left.length === right.length && left.every((model, index) => model === right[index]); } function getAuthModeLabel( authMode: ProviderAccount['authMode'], t: (key: string) => string ): string { switch (authMode) { case 'api_key': return t('aiProviders.authModes.apiKey'); case 'oauth_device': return t('aiProviders.authModes.oauthDevice'); case 'oauth_browser': return t('aiProviders.authModes.oauthBrowser'); case 'local': return t('aiProviders.authModes.local'); default: return authMode; } } export function ProvidersSettings() { const { t } = useTranslation('settings'); const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked); const { statuses, accounts, vendors, defaultAccountId, loading, refreshProviderSnapshot, createAccount, removeAccount, updateAccount, setDefaultAccount, validateAccountApiKey, } = useProviderStore(); const [showAddDialog, setShowAddDialog] = useState(false); const [editingProvider, setEditingProvider] = useState(null); const vendorMap = new Map(vendors.map((vendor) => [vendor.id, vendor])); const existingVendorIds = new Set(accounts.map((account) => account.vendorId)); const displayProviders = useMemo( () => buildProviderListItems(accounts, statuses, vendors, defaultAccountId), [accounts, statuses, vendors, defaultAccountId], ); // Fetch providers on mount useEffect(() => { refreshProviderSnapshot(); }, [refreshProviderSnapshot]); const handleAddProvider = async ( type: ProviderType, name: string, apiKey: string, options?: { baseUrl?: string; model?: string; authMode?: ProviderAccount['authMode'] } ) => { const vendor = vendorMap.get(type); const id = buildProviderAccountId(type, null, vendors); const effectiveApiKey = resolveProviderApiKeyForSave(type, apiKey); try { await createAccount({ id, vendorId: type, label: name, authMode: options?.authMode || vendor?.defaultAuthMode || (type === 'ollama' ? 'local' : 'api_key'), baseUrl: options?.baseUrl, apiProtocol: type === 'custom' || type === 'ollama' ? 'openai-completions' : undefined, model: options?.model, enabled: true, isDefault: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }, effectiveApiKey); // Auto-set as default if no default is currently configured if (!defaultAccountId) { await setDefaultAccount(id); } setShowAddDialog(false); toast.success(t('aiProviders.toast.added')); } catch (error) { toast.error(`${t('aiProviders.toast.failedAdd')}: ${error}`); } }; const handleDeleteProvider = async (providerId: string) => { try { await removeAccount(providerId); toast.success(t('aiProviders.toast.deleted')); } catch (error) { toast.error(`${t('aiProviders.toast.failedDelete')}: ${error}`); } }; const handleSetDefault = async (providerId: string) => { try { await setDefaultAccount(providerId); toast.success(t('aiProviders.toast.defaultUpdated')); } catch (error) { toast.error(`${t('aiProviders.toast.failedDefault')}: ${error}`); } }; return (
{loading ? (
) : displayProviders.length === 0 ? (

{t('aiProviders.empty.title')}

{t('aiProviders.empty.desc')}

) : (
{displayProviders.map((item) => ( setEditingProvider(item.account.id)} onCancelEdit={() => setEditingProvider(null)} onDelete={() => handleDeleteProvider(item.account.id)} onSetDefault={() => handleSetDefault(item.account.id)} onSaveEdits={async (payload) => { const updates: Partial = {}; if (payload.updates) { if (payload.updates.baseUrl !== undefined) updates.baseUrl = payload.updates.baseUrl; if (payload.updates.model !== undefined) updates.model = payload.updates.model; if (payload.updates.fallbackModels !== undefined) updates.fallbackModels = payload.updates.fallbackModels; if (payload.updates.fallbackProviderIds !== undefined) { updates.fallbackAccountIds = payload.updates.fallbackProviderIds; } } await updateAccount( item.account.id, updates, payload.newApiKey ); setEditingProvider(null); }} onValidateKey={(key, options) => validateAccountApiKey(item.account.id, key, options)} devModeUnlocked={devModeUnlocked} /> ))}
)} {/* Add Provider Dialog */} {showAddDialog && ( setShowAddDialog(false)} onAdd={handleAddProvider} onValidateKey={(type, key, options) => validateAccountApiKey(type, key, options)} devModeUnlocked={devModeUnlocked} /> )}
); } interface ProviderCardProps { item: ProviderListItem; allProviders: ProviderListItem[]; isDefault: boolean; isEditing: boolean; onEdit: () => void; onCancelEdit: () => void; onDelete: () => void; onSetDefault: () => void; onSaveEdits: (payload: { newApiKey?: string; updates?: Partial }) => Promise; onValidateKey: ( key: string, options?: { baseUrl?: string } ) => Promise<{ valid: boolean; error?: string }>; devModeUnlocked: boolean; } function ProviderCard({ item, allProviders, isDefault, isEditing, onEdit, onCancelEdit, onDelete, onSetDefault, onSaveEdits, onValidateKey, devModeUnlocked, }: ProviderCardProps) { const { t } = useTranslation('settings'); const { account, vendor, status } = item; const [newKey, setNewKey] = useState(''); const [baseUrl, setBaseUrl] = useState(account.baseUrl || ''); const [modelId, setModelId] = useState(account.model || ''); const [fallbackModelsText, setFallbackModelsText] = useState( normalizeFallbackModels(account.fallbackModels).join('\n') ); const [fallbackProviderIds, setFallbackProviderIds] = useState( normalizeFallbackProviderIds(account.fallbackAccountIds) ); const [showKey, setShowKey] = useState(false); const [validating, setValidating] = useState(false); const [saving, setSaving] = useState(false); const typeInfo = PROVIDER_TYPE_INFO.find((t) => t.id === account.vendorId); const showModelIdField = shouldShowProviderModelId(typeInfo, devModeUnlocked); const canEditModelConfig = Boolean(typeInfo?.showBaseUrl || showModelIdField); useEffect(() => { if (isEditing) { setNewKey(''); setShowKey(false); setBaseUrl(account.baseUrl || ''); setModelId(account.model || ''); setFallbackModelsText(normalizeFallbackModels(account.fallbackModels).join('\n')); setFallbackProviderIds(normalizeFallbackProviderIds(account.fallbackAccountIds)); } }, [isEditing, account.baseUrl, account.fallbackModels, account.fallbackAccountIds, account.model]); const fallbackOptions = allProviders.filter((candidate) => candidate.account.id !== account.id); const toggleFallbackProvider = (providerId: string) => { setFallbackProviderIds((current) => ( current.includes(providerId) ? current.filter((id) => id !== providerId) : [...current, providerId] )); }; const handleSaveEdits = async () => { setSaving(true); try { const payload: { newApiKey?: string; updates?: Partial } = {}; const normalizedFallbackModels = normalizeFallbackModels(fallbackModelsText.split('\n')); if (newKey.trim()) { setValidating(true); const result = await onValidateKey(newKey, { baseUrl: baseUrl.trim() || undefined, }); setValidating(false); if (!result.valid) { toast.error(result.error || t('aiProviders.toast.invalidKey')); setSaving(false); return; } payload.newApiKey = newKey.trim(); } { if (showModelIdField && !modelId.trim()) { toast.error(t('aiProviders.toast.modelRequired')); setSaving(false); return; } const updates: Partial = {}; if (typeInfo?.showBaseUrl && (baseUrl.trim() || undefined) !== (account.baseUrl || undefined)) { updates.baseUrl = baseUrl.trim() || undefined; } if (showModelIdField && (modelId.trim() || undefined) !== (account.model || undefined)) { updates.model = modelId.trim() || undefined; } if (!fallbackModelsEqual(normalizedFallbackModels, account.fallbackModels)) { updates.fallbackModels = normalizedFallbackModels; } if (!fallbackProviderIdsEqual(fallbackProviderIds, account.fallbackAccountIds)) { updates.fallbackProviderIds = normalizeFallbackProviderIds(fallbackProviderIds); } if (Object.keys(updates).length > 0) { payload.updates = updates; } } // Keep Ollama key optional in UI, but persist a placeholder when // editing legacy configs that have no stored key. if (account.vendorId === 'ollama' && !status?.hasKey && !payload.newApiKey) { payload.newApiKey = resolveProviderApiKeyForSave(account.vendorId, '') as string; } if (!payload.newApiKey && !payload.updates) { onCancelEdit(); setSaving(false); return; } await onSaveEdits(payload); setNewKey(''); toast.success(t('aiProviders.toast.updated')); } catch (error) { toast.error(`${t('aiProviders.toast.failedUpdate')}: ${error}`); } finally { setSaving(false); setValidating(false); } }; return ( {/* Top row: icon + name */}
{getProviderIconUrl(account.vendorId) ? ( {typeInfo?.name ) : ( {vendor?.icon || typeInfo?.icon || '⚙️'} )}
{account.label} {vendor?.name || account.vendorId} {getAuthModeLabel(account.authMode, t)}

{account.vendorId}

{t('aiProviders.dialog.modelId')}: {account.model || t('aiProviders.card.none')}

{/* Key row */} {isEditing ? (
{canEditModelConfig && (

{t('aiProviders.sections.model')}

{typeInfo?.showBaseUrl && (
setBaseUrl(e.target.value)} placeholder="https://api.example.com/v1" className="h-9 text-sm" />
)} {showModelIdField && (
setModelId(e.target.value)} placeholder={typeInfo?.modelIdPlaceholder || 'provider/model-id'} className="h-9 text-sm" />
)}
)}

{t('aiProviders.sections.fallback')}