import { useStore } from '@nanostores/react' import type { ReactNode } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { runInTerminal } from '@/app/right-sidebar/store' import { FEATURED_ID, FeaturedProviderRow, FireworksProviderRow, LocalModelsProviderRow, OpenRouterProviderRow, ProviderRow, providerTitle, sortProviders } from '@/components/onboarding' import { Button } from '@/components/ui/button' import { RowButton } from '@/components/ui/row-button' import { SearchField } from '@/components/ui/search-field' import { disconnectOAuthProvider, listOAuthProviders } from '@/hermes' import { useI18n } from '@/i18n' import { Check, ChevronDown, ChevronRight, KeyRound, Loader2, Terminal, Trash2 } from '@/lib/icons' import { normalize } from '@/lib/text' import { cn } from '@/lib/utils' import { confirm } from '@/store/confirm' import { $localModelsEnabled } from '@/store/local-models-flag' import { notify, notifyError } from '@/store/notifications' import { $desktopOnboarding, startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding' import type { EnvVarInfo, OAuthProvider } from '@/types/hermes' import { isKeyVar, ProviderKeyRows } from './credential-key-ui' import { CustomEndpointsSettings } from './custom-endpoints-settings' import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials' import { providerDescription, providerGroup, providerMeta, providerPriority } from './helpers' import { LocalModelsSettings } from './local-models-settings' import { SettingsContent, SettingsSkeleton } from './primitives' // The embedded terminal (and thus the "run disconnect command" path) only // exists in the Electron desktop shell, not the web dashboard. const canRunInTerminal = () => typeof window !== 'undefined' && Boolean(window.hermesDesktop?.terminal) // Parallel group headers ("Connected", "Other providers") so the expanded list // reads as its own section instead of bleeding into the connected group. function GroupLabel({ children }: { children: ReactNode }) { return (

{children}

) } // Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys. export const PROVIDER_VIEWS = ['accounts', 'keys', 'custom-endpoints', 'local'] as const export type ProviderView = (typeof PROVIDER_VIEWS)[number] // Group the env catalog by provider — one ListRow per vendor plus optional // advanced overrides (base URL, region, etc.). Groups without a key field are // skipped. // // Grouping key precedence: // 1. Backend `provider_label` / `provider` (from the unified provider catalog // in hermes_cli/provider_catalog.py) — the SAME provider identity // `hermes model` uses. This is authoritative: a provider tagged by the // backend always renders a card, even with no PROVIDER_GROUPS row. // 2. Desktop prefix match (`providerGroup`) — legacy fallback for provider // env vars that predate the backend tagging. // Only entries that resolve to neither (the "Other" bucket) are skipped. function buildProviderKeyGroups(vars: Record): ProviderKeyGroup[] { const buckets = new Map() for (const [key, info] of Object.entries(vars)) { if (info.category !== 'provider') { continue } // Prefer the backend-supplied provider label/id so the Keys tab groups by // the same identity the CLI picker uses; fall back to the prefix guess. const name = info.provider_label?.trim() || info.provider?.trim() || providerGroup(key) if (name === 'Other') { continue } buckets.set(name, [...(buckets.get(name) ?? []), [key, info]]) } const groups: ProviderKeyGroup[] = [] for (const [name, entries] of buckets) { const primary = entries.find(([k, i]) => !i.advanced && isKeyVar(k, i)) ?? entries.find(([k, i]) => isKeyVar(k, i)) if (!primary) { continue } // Presentation overlay (priority, blurb, docs) is keyed by the prefix-based // group name; when the backend introduced this provider it may have no // overlay entry, so fall back to the backend/env metadata for display. const meta = providerMeta(name) groups.push({ // Advanced = the provider's non-key knobs (base URL, region, deployment). // Skip redundant alias key vars (e.g. ANTHROPIC_TOKEN vs ANTHROPIC_API_KEY) // so we never render a second "Paste key" input — unless one is already // set, in which case keep it visible so it stays clearable. advanced: entries .filter(([k, i]) => k !== primary[0] && (!isKeyVar(k, i) || i.is_set)) .sort(([a], [b]) => a.localeCompare(b)), description: meta?.description ?? primary[1].description, docsUrl: meta?.docsUrl ?? primary[1].url ?? undefined, hasAnySet: entries.some(([, i]) => i.is_set), name, primary, priority: providerPriority(name) }) } return groups.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name)) } // Deliberately a near-1:1 replica of the first-run onboarding picker // (`Picker` in desktop-onboarding-overlay): same recommended card, same // always-visible Local models row, same provider rows, same "Other // providers" disclosure (Fireworks and OpenRouter quick-key rows live // inside it on both surfaces), and the same bottom-right "I have an API // key" affordance. The leaf cards are the exact shared components, so // the two surfaces stay visually identical. Selecting a provider hands // off to the shared onboarding overlay, which runs that provider's real // sign-in flow; the key affordances open the API-key catalog below. function OAuthPicker({ disconnecting, onDisconnect, onTerminalDisconnect, onWantApiKey, onWantLocalModels, providers }: { disconnecting: null | string onDisconnect: (provider: OAuthProvider) => void onTerminalDisconnect: (provider: OAuthProvider) => void onWantApiKey: () => void onWantLocalModels: () => void providers: OAuthProvider[] }) { const { t } = useI18n() const p = t.settings.providers const [showAll, setShowAll] = useState(false) const ordered = useMemo(() => sortProviders(providers), [providers]) if (ordered.length === 0) { return null } const select = (p: OAuthProvider) => startManualProviderOAuth(p.id) const featured = ordered.find(p => p.id === FEATURED_ID && !p.status?.logged_in) ?? null const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered // Keep connected accounts grouped and always visible; only the unconnected // providers hide behind the disclosure, so the page leads with what's set up. // Both lists preserve `sortProviders` order (curated priority, then name). const connected = rest.filter(p => p.status?.logged_in) const others = rest.filter(p => !p.status?.logged_in) const collapsible = others.length > 0 const showOthers = !collapsible || showAll return (

{p.intro}

{featured && } {/* Slot #2 — the no-account path, matching onboarding. Behind the --local launch flag like every local-models surface. */} {$localModelsEnabled.get() && } {connected.length > 0 && ( <> {p.connected} {connected.map(p => ( ))} )} {showOthers && ( <> {connected.length > 0 && {p.otherProviders}} {others.map(p => ( ))} )} {collapsible && ( )}
) } function ConnectedProviderRow({ disconnecting, onDisconnect, onSelect, onTerminalDisconnect, provider }: { disconnecting: boolean onDisconnect: (provider: OAuthProvider) => void onSelect: (provider: OAuthProvider) => void onTerminalDisconnect: (provider: OAuthProvider) => void provider: OAuthProvider }) { const { t } = useI18n() const copy = t.settings.providers const title = providerTitle(provider) const Trail = provider.flow === 'external' ? Terminal : ChevronRight // Hermes can clear this provider's creds via the API. const canDisconnect = provider.disconnectable ?? provider.flow !== 'external' // External (CLI-managed) provider Hermes can't clear via the API, but ships a // command we can run in the embedded terminal (Electron shell only). const terminalDisconnect = !canDisconnect && Boolean(provider.disconnect_command) && canRunInTerminal() // Only fall back to a static "remove it elsewhere" hint when we offer no button. const showHint = !canDisconnect && !terminalDisconnect return (
onSelect(provider)}>
{title} {copy.connected}

{t.onboarding.flowSubtitles[provider.flow]}

{showHint && (

{provider.flow === 'external' ? copy.removeExternalGeneric(title) : copy.removeKeyManaged(title)}

)}
{canDisconnect && ( )} {terminalDisconnect && ( )}
) } function NoProviderKeys() { const { t } = useI18n() return (
{t.settings.providers.noProviderKeys}
) } // Surfaces the "Local / custom endpoint" entry point directly in the API-keys // tab so users can add any OpenAI-compatible endpoint (Zyphra, vLLM, Ollama…) // from the GUI. The composer pill and the providers "have an API key" affordance // both dead-end on the env-var-driven key catalog, which never lists a custom // endpoint — so without this row there is no reachable Desktop path to it. // The whole row is the button so the click target and a11y focus match the // visible area (the chevron + gutter are inside the button, not beside it). // Pass reason: null — the onboarding overlay renders an unmapped reason string // verbatim as a banner (see ReasonNotice in onboarding/index.tsx), and we don't // want a raw identifier like "providers-keys-tab" showing as literal text. function LocalEndpointRow({ onOpen }: { onOpen: (reason: null | string) => void }) { const { t } = useI18n() const copy = t.settings.providers.localEndpoint return ( onOpen(null)} >
{copy.title} {copy.description}
) } export function ProvidersSettings({ onClose, onConfigSaved, onMainModelChanged, onViewChange, view }: ProvidersSettingsProps) { const { t, locale } = useI18n() const { rowProps, vars } = useEnvCredentials() const [oauthProviders, setOauthProviders] = useState([]) const [openProvider, setOpenProvider] = useState(null) const [disconnecting, setDisconnecting] = useState(null) // Free-text filter for the API-keys view (provider name / env-var key / desc). const [keyQuery, setKeyQuery] = useState('') // The onboarding overlay owns the OAuth flow. Watch its `manual` flag so we // re-read connection state when the user finishes (or dismisses) a sign-in // they launched from this page — otherwise the cards keep their stale status. const onboardingActive = useStore($desktopOnboarding).manual const refreshOAuthProviders = useCallback(async () => { // OAuth providers are best-effort — a failure here just hides the panel. const { providers } = await listOAuthProviders() setOauthProviders(providers) }, []) useEffect(() => { let cancelled = false void (async () => { if (onboardingActive) { return } try { const { providers } = await listOAuthProviders() if (!cancelled) { setOauthProviders(providers) } } catch { // Ignore — the OAuth panel just won't render. } })() return () => void (cancelled = true) }, [onboardingActive]) // External (CLI-managed) providers can't be cleared via the API by design — // Hermes never deletes creds another tool owns behind a silent API call. // Instead we run the documented removal command in the embedded terminal so // the user sees exactly what executes, then return them to chat to watch it. async function handleTerminalDisconnect(provider: OAuthProvider) { const command = provider.disconnect_command if (!command) { return } const name = providerTitle(provider) const ok = await confirm({ confirmLabel: t.settings.providers.disconnect, destructive: true, title: t.settings.providers.removeTerminalConfirm(name, command) }) if (!ok) { return } // Leave the settings overlay so the terminal pane (chat-only) is visible. onClose() runInTerminal(command) notify({ kind: 'info', title: t.settings.providers.removedTitle, message: t.settings.providers.removeTerminalRunning(name) }) } async function handleDisconnect(provider: OAuthProvider) { const name = providerTitle(provider) const ok = await confirm({ confirmLabel: t.settings.providers.disconnect, destructive: true, title: t.settings.providers.removeConfirm(name) }) if (!ok) { return } setDisconnecting(provider.id) try { await disconnectOAuthProvider(provider.id) notify({ durationMs: 3_000, kind: 'success', title: t.settings.providers.removedTitle, message: t.settings.providers.removedMessage(name) }) await refreshOAuthProviders().catch(() => undefined) } catch (err) { notifyError(err, t.settings.providers.failedRemove(name)) } finally { setDisconnecting(null) } } if (!vars) { return } const hasOauth = oauthProviders.length > 0 // The sidebar subnav owns the Accounts/API-keys split now; with no OAuth // providers there's nothing for the "Accounts" view to show, so fall to keys. const showApiKeys = view === 'keys' || (!hasOauth && view !== 'custom-endpoints') const keyGroups = buildProviderKeyGroups(vars) if (showApiKeys) { const q = normalize(keyQuery) const visibleGroups = q ? keyGroups.filter(group => { const haystack = [ group.name, providerDescription(group.name, group.description, locale) ?? '', group.description ?? '', group.primary[0], ...group.advanced.map(([k]) => k) ] return haystack.some(s => normalize(s).includes(q)) }) : keyGroups return ( {keyGroups.length > 0 ? (
{visibleGroups.length > 0 ? (
{visibleGroups.map(group => ( setOpenProvider(group.name)} onToggle={() => setOpenProvider(prev => (prev === group.name ? null : group.name))} rowProps={rowProps} /> ))}
) : (
{t.settings.providers.noKeysMatch}
)}
) : ( )}
) } if (view === 'custom-endpoints') { return } if (view === 'local') { // Strict --local gate: without the launch flag the pane doesn't render // even when local models are configured — a stale ?pview=local deep link // (or an old shortcut) lands on the accounts view instead. return $localModelsEnabled.get() ? : null } return ( void handleDisconnect(provider)} onTerminalDisconnect={provider => void handleTerminalDisconnect(provider)} onWantApiKey={() => onViewChange('keys')} onWantLocalModels={() => onViewChange('local')} providers={oauthProviders} /> ) } interface ProviderKeyGroup { advanced: [string, EnvVarInfo][] description?: string docsUrl?: string hasAnySet: boolean name: string primary: [string, EnvVarInfo] priority: number } interface ProvidersSettingsProps { onClose: () => void onConfigSaved?: () => void onMainModelChanged?: (provider: string, model: string) => void onViewChange: (view: ProviderView) => void view: ProviderView }