/** * 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, Key, ExternalLink, Copy, XCircle, ChevronDown, } 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 { 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, hasConfiguredCredentials, 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'; const inputClasses = 'h-[44px] rounded-xl font-mono text-[13px] bg-[#eeece3] dark:bg-[#151514] border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40'; const labelClasses = 'text-[14px] text-foreground/80 font-bold'; 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']; apiProtocol?: ProviderAccount['apiProtocol'] } ) => { 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: options?.apiProtocol, 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 (

{t('aiProviders.title', 'AI Providers')}

{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.apiProtocol !== undefined) updates.apiProtocol = payload.updates.apiProtocol; 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; apiProtocol?: 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 [apiProtocol, setApiProtocol] = useState(account.apiProtocol || 'openai-completions'); 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 [showFallback, setShowFallback] = 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 || ''); setApiProtocol(account.apiProtocol || 'openai-completions'); setModelId(account.model || ''); setFallbackModelsText(normalizeFallbackModels(account.fallbackModels).join('\n')); setFallbackProviderIds(normalizeFallbackProviderIds(account.fallbackAccountIds)); } }, [isEditing, account.baseUrl, account.fallbackModels, account.fallbackAccountIds, account.model, account.apiProtocol]); 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, apiProtocol: (account.vendorId === 'custom' || account.vendorId === 'ollama') ? apiProtocol : 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 ((account.vendorId === 'custom' || account.vendorId === 'ollama') && apiProtocol !== account.apiProtocol) { updates.apiProtocol = apiProtocol; } 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); } }; const currentInputClasses = isDefault ? "h-[40px] rounded-xl font-mono text-[13px] bg-white dark:bg-[#1a1a19] border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 shadow-sm" : inputClasses; const currentLabelClasses = isDefault ? "text-[13px] text-muted-foreground" : labelClasses; const currentSectionLabelClasses = isDefault ? "text-[14px] font-bold text-foreground/80" : labelClasses; return (
{getProviderIconUrl(account.vendorId) ? ( {typeInfo?.name ) : ( {vendor?.icon || typeInfo?.icon || '⚙️'} )}
{account.label} {isDefault && ( Default )}
{vendor?.name || account.vendorId} {getAuthModeLabel(account.authMode, t)} {account.model && ( <> {account.model} )} {hasConfiguredCredentials(account, status) ? ( <>
{t('aiProviders.card.configured')} ) : ( <>
{t('aiProviders.dialog.apiKeyMissing')} )} {((account.fallbackModels?.length ?? 0) > 0 || (account.fallbackAccountIds?.length ?? 0) > 0) && ( <> {t('aiProviders.sections.fallback')}: {[ ...normalizeFallbackModels(account.fallbackModels), ...normalizeFallbackProviderIds(account.fallbackAccountIds) .map((fallbackId) => allProviders.find((candidate) => candidate.account.id === fallbackId)?.account.label) .filter(Boolean), ].join(', ')} )}
{!isEditing && (
{!isDefault && ( )}
)}
{isEditing && (
{canEditModelConfig && (

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

{typeInfo?.showBaseUrl && (
setBaseUrl(e.target.value)} placeholder={apiProtocol === 'anthropic-messages' ? "https://api.example.com/anthropic" : "https://api.example.com/v1"} className={currentInputClasses} />
)} {showModelIdField && (
setModelId(e.target.value)} placeholder={typeInfo?.modelIdPlaceholder || 'provider/model-id'} className={currentInputClasses} />
)} {account.vendorId === 'custom' && (
)}
)}
{showFallback && (