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 (
)
}
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 ? (