fix(desktop): collect + persist API key for custom OpenAI endpoints (#43896)

The desktop "Local / custom endpoint" onboarding never collected an API
key and /api/model/set silently dropped one, so an auth-gated endpoint
(e.g. a hosted vLLM behind a key) could never enumerate models — and
Settings' "Set up custom endpoint" routed `custom` into a non-existent
OAuth flow, booting the user back to the first screen (the reported loop).

Backend (web_server.py):
- /api/providers/validate accepts an optional api_key and sends it as a
  Bearer header when probing a custom endpoint's /v1/models.
- /api/model/set accepts api_key, persists it to model.api_key (same
  switch/preserve lifecycle as base_url), and registers a named
  custom_providers entry via _save_custom_provider — matching the
  `hermes model` CLI flow so the endpoint shows up as a ready picker row.

Desktop:
- ApiKeyForm shows an optional API key field for the local/custom option;
  the key is threaded through saveOnboardingLocalEndpoint → validate +
  setModelAssignment.
- New onboarding `localEndpoint` intent + startManualLocalEndpoint(); the
  Settings "Set up custom endpoint" button now opens the local-endpoint
  form (URL + key) instead of the OAuth dead-end.
- Added localApiKeyPlaceholder i18n key (en + types + zh).

Tests: api_key lifecycle on _apply_main_model_assignment, key persistence
+ custom_providers registration on /api/model/set, Bearer-header probe;
onboarding store forwards + persists the key.
This commit is contained in:
brooklyn!
2026-06-12 00:03:55 +00:00
committed by GitHub
parent c6007e5c1a
commit 4ddb03390a
13 changed files with 363 additions and 37 deletions
@@ -26,7 +26,8 @@ function setProviders(providers: OAuthProvider[]) {
reason: null,
requested: false,
firstRunSkipped: false,
manual: false
manual: false,
localEndpoint: false
} satisfies DesktopOnboardingState)
}
@@ -49,7 +50,8 @@ afterEach(() => {
reason: null,
requested: false,
firstRunSkipped: false,
manual: false
manual: false,
localEndpoint: false
})
})
@@ -430,19 +430,24 @@ const persistShowAll = (value: boolean) => {
export function Picker({ ctx }: { ctx: OnboardingContext }) {
const { t } = useI18n()
const { manual, mode, providers } = useStore($desktopOnboarding)
const { localEndpoint, manual, mode, providers } = useStore($desktopOnboarding)
const [showAll, setShowAll] = useState(readShowAll)
const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
const hasOauth = ordered.length > 0
const apiKeyOptions = useApiKeyCatalog()
if (mode === 'apikey' || !hasOauth) {
// localEndpoint forces the key form regardless of `mode` (which a manual
// provider refresh may flip back to 'oauth'); it preselects the local option
// and hides the "back to sign in" link since the user came specifically to
// configure a custom endpoint.
if (localEndpoint || mode === 'apikey' || !hasOauth) {
return (
<div className="grid gap-3">
<ApiKeyForm
canGoBack={hasOauth}
canGoBack={hasOauth && !localEndpoint}
initialEnvKey={localEndpoint ? 'OPENAI_BASE_URL' : undefined}
onBack={() => setOnboardingMode('oauth')}
onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)}
onSave={(envKey, value, name, apiKey) => saveOnboardingApiKey(envKey, value, name, ctx, apiKey)}
options={apiKeyOptions}
/>
{manual ? null : (
@@ -630,6 +635,7 @@ export function ProviderRow({
// surfaces render the identical form.
export function ApiKeyForm({
canGoBack,
initialEnvKey,
isSet,
onBack,
onClear,
@@ -638,16 +644,31 @@ export function ApiKeyForm({
redactedValue
}: {
canGoBack: boolean
/** Preselect a specific option by env key (e.g. 'OPENAI_BASE_URL' to land on
* the local / custom endpoint form). Falls back to the first option. */
initialEnvKey?: string
isSet?: (envKey: string) => boolean
onBack: () => void
onClear?: (envKey: string) => void
onSave: (envKey: string, value: string, name: string) => Promise<{ message?: string; ok: boolean }>
onSave: (
envKey: string,
value: string,
name: string,
apiKey?: string
) => Promise<{ message?: string; ok: boolean }>
options?: ApiKeyOption[]
redactedValue?: (envKey: string) => null | string | undefined
}) {
const { t } = useI18n()
const [option, setOption] = useState<ApiKeyOption>(options[0])
const [option, setOption] = useState<ApiKeyOption>(
() => options.find(o => o.envKey === initialEnvKey) ?? options[0]
)
const [value, setValue] = useState('')
// Optional endpoint API key, only used by the local / custom endpoint option
// (whose `value` is the base URL). Cleared whenever the option changes.
const [localKey, setLocalKey] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
// `options` can change at runtime when callers filter the catalog (e.g. the
@@ -657,6 +678,7 @@ export function ApiKeyForm({
if (options.length > 0 && !options.some(o => o.envKey === option.envKey)) {
setOption(options[0])
setValue('')
setLocalKey('')
setError(null)
}
}, [option.envKey, options])
@@ -668,6 +690,7 @@ export function ApiKeyForm({
const pick = (o: ApiKeyOption) => {
setOption(o)
setValue('')
setLocalKey('')
setError(null)
requestAnimationFrame(() => {
entryRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
@@ -693,10 +716,11 @@ export function ApiKeyForm({
setSaving(true)
setError(null)
const result = await onSave(option.envKey, value, option.name)
const result = await onSave(option.envKey, value, option.name, isLocal ? localKey : undefined)
if (result.ok) {
setValue('')
setLocalKey('')
} else {
setError(result.message ?? t.onboarding.couldNotSave)
}
@@ -759,6 +783,17 @@ export function ApiKeyForm({
type={isLocal ? 'text' : 'password'}
value={value}
/>
{isLocal ? (
<Input
autoComplete="off"
className="font-mono"
onChange={e => setLocalKey(e.target.value)}
onKeyDown={e => e.key === 'Enter' && void submit()}
placeholder={t.onboarding.localApiKeyPlaceholder}
type="password"
value={localKey}
/>
) : null}
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</div>
@@ -41,7 +41,8 @@ function resetStores() {
reason: null,
requested: false,
firstRunSkipped: false,
manual: false
manual: false,
localEndpoint: false
})
}