import React from 'react'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { toast } from '@/components/ui'; import { RiStackLine, RiToolsLine, RiBrainAi3Line, RiFileImageLine, RiArrowDownSLine, RiCheckLine, RiSearchLine, RiInformationLine, RiEyeLine, RiEyeOffLine } from '@remixicon/react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; import { cn } from '@/lib/utils'; import { copyTextToClipboard } from '@/lib/clipboard'; import { openExternalUrl } from '@/lib/url'; import type { ModelMetadata } from '@/types'; import { useI18n } from '@/lib/i18n'; const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', { notation: 'compact', compactDisplay: 'short', maximumFractionDigits: 1, minimumFractionDigits: 0, }); const formatTokens = (value?: number | null) => { if (typeof value !== 'number' || Number.isNaN(value)) { return null; } if (value === 0) { return '0'; } const formatted = COMPACT_NUMBER_FORMATTER.format(value); return formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted; }; const ADD_PROVIDER_ID = '__add_provider__'; interface AuthMethod { type?: string; name?: string; label?: string; description?: string; help?: string; method?: number; [key: string]: unknown; } interface ProviderOption { id: string; name?: string; } interface ProviderSourceInfo { exists: boolean; path?: string | null; } interface ProviderSources { auth: ProviderSourceInfo; user: ProviderSourceInfo; project: ProviderSourceInfo; custom?: ProviderSourceInfo; } const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; const normalizeAuthType = (method: AuthMethod) => { const raw = typeof method.type === 'string' ? method.type : ''; const label = `${method.name ?? ''} ${method.label ?? ''}`.toLowerCase(); const merged = `${raw} ${label}`.toLowerCase(); if (merged.includes('oauth')) return 'oauth'; if (merged.includes('api')) return 'api'; return raw.toLowerCase(); }; const parseAuthPayload = (payload: unknown): Record => { if (!isRecord(payload)) { return {}; } const result: Record = {}; for (const [providerId, value] of Object.entries(payload)) { if (Array.isArray(value)) { result[providerId] = value.filter((entry) => isRecord(entry)) as AuthMethod[]; } } return result; }; const normalizeProviderEntry = (entry: unknown): ProviderOption | null => { if (typeof entry === 'string') { return { id: entry }; } if (!isRecord(entry)) { return null; } const idCandidate = (typeof entry.id === 'string' && entry.id) || (typeof entry.providerID === 'string' && entry.providerID) || (typeof entry.slug === 'string' && entry.slug) || (typeof entry.name === 'string' && entry.name); if (!idCandidate) { return null; } const nameCandidate = typeof entry.name === 'string' ? entry.name : undefined; return { id: idCandidate, name: nameCandidate }; }; const parseProvidersPayload = (payload: unknown): ProviderOption[] => { let entries: unknown[] = []; if (Array.isArray(payload)) { entries = payload; } else if (isRecord(payload)) { if (Array.isArray(payload.all)) { entries = payload.all; } else if (Array.isArray(payload.providers)) { entries = payload.providers; } } const mapped = entries .map((entry) => normalizeProviderEntry(entry)) .filter((entry): entry is ProviderOption => Boolean(entry)); const seen = new Set(); return mapped.filter((entry) => { if (seen.has(entry.id)) { return false; } seen.add(entry.id); return true; }); }; export const ProvidersPage: React.FC = () => { const { t } = useI18n(); const providers = useConfigStore((state) => state.providers); const selectedProviderId = useConfigStore((state) => state.selectedProviderId); const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider); const getModelMetadata = useConfigStore((state) => state.getModelMetadata); const hiddenModels = useUIStore((state) => state.hiddenModels); const toggleHiddenModel = useUIStore((state) => state.toggleHiddenModel); const hideAllModels = useUIStore((state) => state.hideAllModels); const showAllModels = useUIStore((state) => state.showAllModels); const [authMethodsByProvider, setAuthMethodsByProvider] = React.useState>({}); const [authLoading, setAuthLoading] = React.useState(false); const [apiKeyInputs, setApiKeyInputs] = React.useState>({}); const [authBusyKey, setAuthBusyKey] = React.useState(null); const [modelQuery, setModelQuery] = React.useState(''); const [pendingOAuth, setPendingOAuth] = React.useState<{ providerId: string; methodIndex: number } | null>(null); const [oauthCodes, setOauthCodes] = React.useState>({}); const [oauthDetails, setOauthDetails] = React.useState>({}); const [availableProviders, setAvailableProviders] = React.useState([]); const [availableLoading, setAvailableLoading] = React.useState(false); const [availableError, setAvailableError] = React.useState(null); const [candidateProviderId, setCandidateProviderId] = React.useState(''); const [providerSearchQuery, setProviderSearchQuery] = React.useState(''); const [providerDropdownOpen, setProviderDropdownOpen] = React.useState(false); const [providerSources, setProviderSources] = React.useState>({}); const [showAuthPanel, setShowAuthPanel] = React.useState(false); React.useEffect(() => { if (!selectedProviderId && providers.length > 0) { setSelectedProvider(providers[0].id); } }, [providers, selectedProviderId, setSelectedProvider]); React.useEffect(() => { let isMounted = true; const loadAuthMethods = async () => { setAuthLoading(true); try { const response = await fetch('/api/provider/auth', { method: 'GET', headers: { Accept: 'application/json' }, }); if (!response.ok) { throw new Error(`Auth methods request failed (${response.status})`); } const payload = await response.json().catch(() => ({})); if (!isMounted) return; setAuthMethodsByProvider(parseAuthPayload(payload)); } catch (error) { if (!isMounted) return; console.error('Failed to load provider auth methods:', error); toast.error(t('settings.providers.page.toast.authMethodsLoadFailed')); } finally { if (isMounted) { setAuthLoading(false); } } }; loadAuthMethods(); return () => { isMounted = false; }; }, [t]); React.useEffect(() => { let isMounted = true; const loadAvailableProviders = async () => { setAvailableLoading(true); setAvailableError(null); try { const response = await fetch('/api/provider', { method: 'GET', headers: { Accept: 'application/json' }, }); if (!response.ok) { throw new Error(`Provider list request failed (${response.status})`); } const payload = await response.json().catch(() => ({})); if (!isMounted) return; setAvailableProviders(parseProvidersPayload(payload)); } catch (error) { if (!isMounted) return; console.error('Failed to load available providers:', error); setAvailableError(t('settings.providers.page.state.unableToLoadProviderList')); } finally { if (isMounted) { setAvailableLoading(false); } } }; loadAvailableProviders(); return () => { isMounted = false; }; }, [t]); const connectedProviderIds = React.useMemo( () => new Set(providers.map((provider) => provider.id)), [providers] ); const unconnectedProviders = React.useMemo( () => availableProviders .filter((provider) => !connectedProviderIds.has(provider.id)) .sort((a, b) => { const labelA = (a.name || a.id).toLowerCase(); const labelB = (b.name || b.id).toLowerCase(); return labelA.localeCompare(labelB); }), [availableProviders, connectedProviderIds] ); React.useEffect(() => { if (selectedProviderId !== ADD_PROVIDER_ID) { return; } if (candidateProviderId && !unconnectedProviders.some((provider) => provider.id === candidateProviderId)) { setCandidateProviderId(''); } }, [selectedProviderId, candidateProviderId, unconnectedProviders]); React.useEffect(() => { if (selectedProviderId === ADD_PROVIDER_ID) { setShowAuthPanel(true); return; } setShowAuthPanel(false); }, [selectedProviderId, t]); React.useEffect(() => { if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) { return; } let cancelled = false; const loadSources = async () => { try { const response = await fetch(`/api/provider/${encodeURIComponent(selectedProviderId)}/source`, { method: 'GET', headers: { Accept: 'application/json' }, }); const payload = await response.json().catch(() => null); if (!response.ok) { throw new Error(payload?.error || t('settings.providers.page.toast.providerSourcesLoadFailed')); } const sources = (payload?.sources ?? payload?.data?.sources) as ProviderSources | undefined; if (!cancelled && sources) { setProviderSources((prev) => ({ ...prev, [selectedProviderId]: sources, })); } } catch (error) { if (!cancelled) { console.error('Failed to load provider sources:', error); } } }; loadSources(); return () => { cancelled = true; }; }, [selectedProviderId, t]); const selectedProvider = providers.find((provider) => provider.id === selectedProviderId); const selectedSources = selectedProviderId ? providerSources[selectedProviderId] : undefined; const handleSaveApiKey = async (providerId: string) => { const apiKey = apiKeyInputs[providerId]?.trim() ?? ''; if (!apiKey) { toast.error(t('settings.providers.page.toast.apiKeyRequired')); return; } const busyKey = `api:${providerId}`; setAuthBusyKey(busyKey); try { const response = await fetch(`/api/auth/${encodeURIComponent(providerId)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'api', key: apiKey }), }); const payload = await response.json().catch(() => null); if (!response.ok) { const message = payload?.error || t('settings.providers.page.toast.apiKeySaveFailed'); throw new Error(message); } toast.success(t('settings.providers.page.toast.apiKeySaved')); setApiKeyInputs((prev) => ({ ...prev, [providerId]: '' })); await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" }); setSelectedProvider(providerId); } catch (error) { console.error('Failed to save API key:', error); toast.error(t('settings.providers.page.toast.apiKeySaveFailed')); } finally { setAuthBusyKey(null); } }; const handleOAuthStart = async (providerId: string, methodIndex: number) => { const busyKey = `oauth:${providerId}:${methodIndex}`; setAuthBusyKey(busyKey); try { const response = await fetch(`/api/provider/${encodeURIComponent(providerId)}/oauth/authorize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ method: methodIndex }), }); const payload = await response.json().catch(() => null); if (!response.ok) { const message = payload?.error || t('settings.providers.page.toast.oauthStartFailed'); throw new Error(message); } const payloadRecord = isRecord(payload) ? payload : {}; const dataRecord = isRecord(payloadRecord.data) ? payloadRecord.data : payloadRecord; const urlCandidate = (typeof dataRecord.url === 'string' && dataRecord.url) || (typeof dataRecord.verification_uri_complete === 'string' && dataRecord.verification_uri_complete) || (typeof dataRecord.verification_uri === 'string' && dataRecord.verification_uri) || undefined; const instructions = (typeof dataRecord.instructions === 'string' && dataRecord.instructions) || (typeof dataRecord.message === 'string' && dataRecord.message) || undefined; const userCode = (typeof dataRecord.user_code === 'string' && dataRecord.user_code) || (typeof dataRecord.code === 'string' && dataRecord.code) || (typeof dataRecord.userCode === 'string' && dataRecord.userCode) || undefined; if (!urlCandidate && !instructions && !userCode) { throw new Error(t('settings.providers.page.toast.oauthDetailsMissing')); } const detailsKey = `${providerId}:${methodIndex}`; setOauthDetails((prev) => ({ ...prev, [detailsKey]: { url: urlCandidate, instructions, userCode, }, })); if (urlCandidate) { void openExternalUrl(urlCandidate); } setPendingOAuth({ providerId, methodIndex }); toast.message(t('settings.providers.page.toast.completeOAuthInBrowser')); } catch (error) { console.error('Failed to start OAuth flow:', error); toast.error(t('settings.providers.page.toast.oauthStartFailed')); } finally { setAuthBusyKey(null); } }; const handleOAuthComplete = async (providerId: string, methodIndex: number) => { const codeKey = `${providerId}:${methodIndex}`; const code = oauthCodes[codeKey]?.trim(); const busyKey = `oauth-complete:${providerId}:${methodIndex}`; setAuthBusyKey(busyKey); try { const requestBody: { method: number; code?: string } = { method: methodIndex }; if (code) { requestBody.code = code; } const response = await fetch(`/api/provider/${encodeURIComponent(providerId)}/oauth/callback`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody), }); const responsePayload = await response.json().catch(() => null); if (!response.ok) { const message = responsePayload?.error || t('settings.providers.page.toast.oauthCompleteFailed'); throw new Error(message); } toast.success(t('settings.providers.page.toast.oauthCompleted')); setOauthCodes((prev) => ({ ...prev, [codeKey]: '' })); setPendingOAuth(null); await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" }); setSelectedProvider(providerId); } catch (error) { console.error('Failed to complete OAuth flow:', error); toast.error(t('settings.providers.page.toast.oauthCompleteFailed')); } finally { setAuthBusyKey(null); } }; const handleCopyOAuthLink = async (url: string) => { const result = await copyTextToClipboard(url); if (result.ok) { toast.success(t('settings.providers.page.toast.oauthLinkCopied')); return; } console.error('Failed to copy OAuth link:', result.error); toast.error(t('settings.providers.page.toast.oauthLinkCopyFailed')); }; const handleCopyOAuthCode = async (code: string) => { const result = await copyTextToClipboard(code); if (result.ok) { toast.success(t('settings.providers.page.toast.deviceCodeCopied')); return; } console.error('Failed to copy device code:', result.error); toast.error(t('settings.providers.page.toast.deviceCodeCopyFailed')); }; const handleDisconnectProvider = async (providerId: string) => { const busyKey = `disconnect:${providerId}`; setAuthBusyKey(busyKey); try { const response = await fetch(`/api/provider/${encodeURIComponent(providerId)}/auth?scope=all`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, }); const payload = await response.json().catch(() => null); if (!response.ok) { const message = payload?.error || t('settings.providers.page.toast.providerDisconnectFailed'); throw new Error(message); } toast.success(t('settings.providers.page.toast.providerDisconnected')); await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" }); } catch (error) { console.error('Failed to disconnect provider:', error); toast.error(t('settings.providers.page.toast.providerDisconnectFailed')); } finally { setAuthBusyKey(null); } }; const isAddMode = selectedProviderId === ADD_PROVIDER_ID; if (!isAddMode && providers.length === 0) { return (

{t('settings.providers.page.empty.noProvidersDetected')}

{t('settings.providers.page.empty.checkOpenCodeConfiguration')}

); } if (isAddMode) { return (

{t('settings.providers.page.connect.title')}

{t('settings.providers.page.connect.selectProviderTitle')}

{t('settings.providers.page.connect.providerField')} {availableLoading ? (

{t('settings.providers.page.state.loading')}

) : availableError ? (

{availableError}

) : unconnectedProviders.length === 0 ? (

{t('settings.providers.page.connect.allProvidersConnected')}

) : ( { setProviderDropdownOpen(open); if (!open) setProviderSearchQuery(''); }}> e.preventDefault()} >
e.stopPropagation()} > setProviderSearchQuery(e.target.value)} onKeyDown={(e) => e.stopPropagation()} placeholder={t('settings.providers.page.connect.searchProvidersPlaceholder')} className="flex-1 bg-transparent typography-meta outline-none placeholder:text-muted-foreground" autoFocus />
{(() => { const filtered = unconnectedProviders.filter(p => { const query = providerSearchQuery.toLowerCase(); return (p.name || p.id).toLowerCase().includes(query) || p.id.toLowerCase().includes(query); }); if (filtered.length === 0) { return

{t('settings.providers.page.connect.noProvidersFound')}

; } return filtered.map((provider) => ( { setCandidateProviderId(provider.id); setProviderDropdownOpen(false); setProviderSearchQuery(''); }} className="flex items-center justify-between" > {provider.name || provider.id} {candidateProviderId === provider.id && ( )} )); })()}
)}
{candidateProviderId && (

{t('settings.providers.page.auth.title')}

{authLoading ? (

{t('settings.providers.page.auth.loadingMethods')}

) : (
setApiKeyInputs((prev) => ({ ...prev, [candidateProviderId]: event.target.value, })) } placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')} className="flex-1 font-mono text-xs" />
{(() => { const candidateAuthMethods = authMethodsByProvider[candidateProviderId] ?? []; const candidateOAuthMethods = candidateAuthMethods.filter( (method) => normalizeAuthType(method) === 'oauth' ); if (candidateOAuthMethods.length === 0) { return null; } return (
{candidateOAuthMethods.map((method, index) => { const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(index + 1) }); const codeKey = `${candidateProviderId}:${index}`; const isPending = pendingOAuth?.providerId === candidateProviderId && pendingOAuth?.methodIndex === index; return (
{methodLabel}
{(method.description || method.help) && (
{String(method.description || method.help)}
)}
{oauthDetails[codeKey]?.instructions && (

{oauthDetails[codeKey]?.instructions}

)} {oauthDetails[codeKey]?.userCode && (
)} {oauthDetails[codeKey]?.url && (
)} {isPending && (
setOauthCodes((prev) => ({ ...prev, [codeKey]: event.target.value, })) } placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')} className="font-mono text-xs" />
)}
); })}
); })()}
)}
)}
); } if (!selectedProvider) { return (

{t('settings.providers.page.empty.selectProviderFromSidebar')}

{t('settings.providers.page.empty.reviewDetailsAndConfigureAuth')}

); } const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : []; const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? []; const oauthAuthMethods = providerAuthMethods.filter((method) => normalizeAuthType(method) === 'oauth'); const filteredModels = providerModels.filter((model) => { const name = typeof model?.name === 'string' ? model.name : ''; const id = typeof model?.id === 'string' ? model.id : ''; const query = modelQuery.trim().toLowerCase(); if (!query) return true; return name.toLowerCase().includes(query) || id.toLowerCase().includes(query); }); return (
{/* Header */}

{selectedProvider.name || selectedProvider.id}

{selectedProvider.id}

{/* Authentication */}

{t('settings.providers.page.auth.title')}

{!showAuthPanel ? (
{t('settings.providers.page.auth.connected')} {t('settings.providers.page.auth.useReconnectHint')}
) : authLoading ? (
{t('settings.providers.page.auth.loadingMethods')}
) : (
setApiKeyInputs((prev) => ({ ...prev, [selectedProvider.id]: event.target.value, })) } placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')} className="flex-1 font-mono text-xs" />
{oauthAuthMethods.length > 0 && (
{oauthAuthMethods.map((method, index) => { const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(index + 1) }); const codeKey = `${selectedProvider.id}:${index}`; const isPending = pendingOAuth?.providerId === selectedProvider.id && pendingOAuth?.methodIndex === index; return (
{methodLabel}
{(method.description || method.help) && (
{String(method.description || method.help)}
)}
{oauthDetails[codeKey]?.instructions && (

{oauthDetails[codeKey]?.instructions}

)} {oauthDetails[codeKey]?.userCode && (
)} {oauthDetails[codeKey]?.url && (
)} {isPending && (
setOauthCodes((prev) => ({ ...prev, [codeKey]: event.target.value, })) } placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')} className="font-mono text-xs" />
)}
); })}
)}
)}
{/* Connection Details */}

{t('settings.providers.page.connectionDetails.title')}

{selectedSources && (selectedSources.auth.exists || selectedSources.user.exists || selectedSources.project.exists || selectedSources.custom?.exists) ? ( {t('settings.providers.page.connectionDetails.configuredIn')}{' '} {[ selectedSources.auth.exists ? t('settings.providers.page.connectionDetails.source.authCredentials') : null, selectedSources.user.exists ? t('settings.providers.page.connectionDetails.source.userConfig') : null, selectedSources.project.exists ? t('settings.providers.page.connectionDetails.source.projectConfig') : null, selectedSources.custom?.exists ? t('settings.providers.page.connectionDetails.source.customConfig') : null, ].filter(Boolean).join(', ')} ) : ( {t('settings.providers.page.connectionDetails.noActiveSource')} )}
{/* Models */}

{t('settings.providers.page.models.title')} {providerModels.length > 0 && ( ({providerModels.length}) )}

setModelQuery(event.target.value)} placeholder={t('settings.providers.page.models.filterPlaceholder')} className="h-7 pl-8 w-full" />
{filteredModels.length === 0 ? (

{t('settings.providers.page.models.noModelsMatchFilter')}

) : (
{filteredModels.map((model) => { const modelId = typeof model?.id === 'string' ? model.id : ''; const modelName = typeof model?.name === 'string' ? model.name : modelId; const metadata = modelId ? getModelMetadata(selectedProvider.id, modelId) as ModelMetadata | undefined : undefined; const isHidden = hiddenModels.some( (item) => item.providerID === selectedProvider.id && item.modelID === modelId ); const contextTokens = formatTokens(metadata?.limit?.context); const outputTokens = formatTokens(metadata?.limit?.output); const capabilityIcons: Array<{ key: string; icon: typeof RiToolsLine; label: string }> = []; if (metadata?.tool_call) capabilityIcons.push({ key: 'tools', icon: RiToolsLine, label: t('settings.providers.page.models.capability.toolCalling') }); if (metadata?.reasoning) capabilityIcons.push({ key: 'reasoning', icon: RiBrainAi3Line, label: t('settings.providers.page.models.capability.reasoning') }); if (metadata?.attachment) capabilityIcons.push({ key: 'image', icon: RiFileImageLine, label: t('settings.providers.page.models.capability.imageInput') }); return (
{modelName}
{(contextTokens || outputTokens) && ( {contextTokens ? `${contextTokens} ${t('settings.providers.page.models.tokenBadge.context')}` : ''} {contextTokens && outputTokens ? ' ยท ' : ''} {outputTokens ? `${outputTokens} ${t('settings.providers.page.models.tokenBadge.output')}` : ''} )} {capabilityIcons.length > 0 && (
{capabilityIcons.map(({ key, icon: Icon, label }) => ( ))}
)}
); })}
)}
); };