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
+46 -3
View File
@@ -33,6 +33,7 @@ function baseState(overrides: Partial<DesktopOnboardingState> = {}): DesktopOnbo
requested: false,
firstRunSkipped: false,
manual: false,
localEndpoint: false,
...overrides
}
}
@@ -233,10 +234,12 @@ describe('OAuth onboarding', () => {
const state = $desktopOnboarding.get()
expect(state.reason).toBeNull()
expect(state.flow.status).toBe('confirming_model')
if (state.flow.status === 'confirming_model') {
expect(state.flow.label).toBe('Nous Portal')
expect(state.flow.currentModel).toBe(model)
}
expect(calls.some(c => c.path === '/api/model/set')).toBe(true)
})
})
@@ -283,7 +286,7 @@ describe('saveOnboardingLocalEndpoint', () => {
throw new Error(`unexpected api path: ${path}`)
})
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', {
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', '', {
requestGateway: readyGateway()
})
@@ -313,7 +316,7 @@ describe('saveOnboardingLocalEndpoint', () => {
installApiMock(api)
const onCompleted = vi.fn()
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', {
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', '', {
onCompleted,
requestGateway: readyGateway()
})
@@ -332,6 +335,46 @@ describe('saveOnboardingLocalEndpoint', () => {
expect($desktopOnboarding.get().configured).toBe(true)
})
it('forwards the API key to the probe and persists it for auth-gated endpoints', async () => {
const calls: { body?: unknown; path: string }[] = []
const api = vi.fn(async ({ body, path }: { body?: unknown; path: string }) => {
calls.push({ body, path })
if (path === '/api/providers/validate') {
return { ok: true, reachable: true, message: '', models: ['gpt-oss-120b'] }
}
if (path === '/api/model/set') {
return { ok: true, provider: 'custom', model: 'gpt-oss-120b', base_url: 'https://text.example.com/v1' }
}
throw new Error(`unexpected api path: ${path}`)
})
installApiMock(api)
const result = await saveOnboardingLocalEndpoint('https://text.example.com/v1', 'sk-secret', {
requestGateway: readyGateway()
})
expect(result.ok).toBe(true)
// The probe must receive the key so an auth-gated /v1/models enumerates.
const probe = calls.find(c => c.path === '/api/providers/validate')
expect(probe?.body).toMatchObject({ key: 'OPENAI_BASE_URL', value: 'https://text.example.com/v1', api_key: 'sk-secret' })
// And the key must be persisted alongside the endpoint for runtime auth.
const assign = calls.find(c => c.path === '/api/model/set')
expect(assign?.body).toMatchObject({
scope: 'main',
provider: 'custom',
model: 'gpt-oss-120b',
base_url: 'https://text.example.com/v1',
api_key: 'sk-secret'
})
})
it('reports the runtime reason when resolution still fails after saving', async () => {
installApiMock(async ({ path }: { path: string }) => {
if (path === '/api/providers/validate') {
@@ -361,7 +404,7 @@ describe('saveOnboardingLocalEndpoint', () => {
throw new Error(`unexpected gateway method: ${method}`)
}
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', {
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', '', {
requestGateway: failingGateway
})
+52 -13
View File
@@ -72,6 +72,11 @@ export interface DesktopOnboardingState {
* picker's "Add provider" button). Forces the overlay to show the picker
* even when configured === true, and adds a close affordance. */
manual: boolean
/** True when the overlay was opened specifically to configure a local /
* custom OpenAI-compatible endpoint (e.g. from Settings → Model's "Set up
* custom endpoint"). Forces the API-key form with the local option
* preselected instead of the OAuth picker. */
localEndpoint: boolean
}
export interface OnboardingContext {
@@ -150,7 +155,8 @@ const INITIAL: DesktopOnboardingState = {
reason: null,
requested: false,
firstRunSkipped: readCachedSkipped(),
manual: false
manual: false,
localEndpoint: false
}
export const $desktopOnboarding = atom<DesktopOnboardingState>(INITIAL)
@@ -392,6 +398,7 @@ export function startManualOnboarding(reason: null | string = DEFAULT_MANUAL_ONB
patch({
manual: true,
requested: true,
localEndpoint: false,
// `null` opts out of the prompt banner entirely (e.g. when the user already
// picked a specific provider and we auto-start its sign-in).
reason: reason ? reason.trim() || DEFAULT_ONBOARDING_REASON : null,
@@ -400,6 +407,24 @@ export function startManualOnboarding(reason: null | string = DEFAULT_MANUAL_ONB
void refreshProviders()
}
// Open the onboarding overlay directly on the local / custom endpoint form
// (URL + optional API key), bypassing the OAuth picker. Used by Settings →
// Model's "Set up custom endpoint" so it lands on a form that can actually
// configure the endpoint instead of dead-ending on the OAuth provider list
// (`custom` is not an OAuth provider, so the generic manual flow would just
// re-show the picker — the original "booted back to the first screen" loop).
export function startManualLocalEndpoint(reason: null | string = null) {
pendingProviderOAuthId = null
patch({
manual: true,
requested: true,
localEndpoint: true,
mode: 'apikey',
reason: reason ? reason.trim() || DEFAULT_ONBOARDING_REASON : null,
flow: { status: 'idle' }
})
}
// One-shot hand-off used when the dedicated Providers settings page launches a
// specific provider's sign-in: we open the manual onboarding overlay AND
// remember which provider to start, so the overlay drives that exact OAuth
@@ -431,7 +456,7 @@ export function clearPendingProviderOAuth() {
export function closeManualOnboarding() {
pendingProviderOAuthId = null
patch({ manual: false, requested: false, flow: { status: 'idle' } })
patch({ manual: false, requested: false, localEndpoint: false, flow: { status: 'idle' } })
}
export function completeDesktopOnboarding() {
@@ -448,7 +473,8 @@ export function completeDesktopOnboarding() {
reason: null,
requested: false,
firstRunSkipped: false,
manual: false
manual: false,
localEndpoint: false
})
}
@@ -461,7 +487,7 @@ export function completeDesktopOnboarding() {
export function dismissFirstRunOnboarding() {
clearPoll()
writeCachedSkipped(true)
patch({ firstRunSkipped: true, requested: false, manual: false, flow: { status: 'idle' } })
patch({ firstRunSkipped: true, requested: false, manual: false, localEndpoint: false, flow: { status: 'idle' } })
}
export function setOnboardingMode(mode: OnboardingMode) {
@@ -701,18 +727,28 @@ export async function recheckExternalSignin(ctx: OnboardingContext) {
)
}
export async function saveOnboardingApiKey(envKey: string, value: string, label: string, ctx: OnboardingContext) {
export async function saveOnboardingApiKey(
envKey: string,
value: string,
label: string,
ctx: OnboardingContext,
// Optional endpoint key — only meaningful for the "Local / custom endpoint"
// option, whose primary `value` is the base URL. Ignored for plain API-key
// providers (their key IS `value`).
endpointApiKey?: string
) {
const trimmed = value.trim()
if (!trimmed) {
return { ok: false, message: 'Enter a value first.' }
}
// The "Local / custom endpoint" option carries a base URL, not an API key.
// It must be wired into config (provider=custom + base_url + model), not
// dropped into .env — runtime resolution ignores OPENAI_BASE_URL.
// The "Local / custom endpoint" option carries a base URL (in `value`) plus
// an optional API key. It must be wired into config (provider=custom +
// base_url + model + api_key), not dropped into .env — runtime resolution
// ignores OPENAI_BASE_URL.
if (envKey === 'OPENAI_BASE_URL') {
return saveOnboardingLocalEndpoint(trimmed, ctx)
return saveOnboardingLocalEndpoint(trimmed, endpointApiKey?.trim() ?? '', ctx)
}
// No key validation here on purpose: we previously live-probed the key and
@@ -748,14 +784,17 @@ export async function saveOnboardingApiKey(envKey: string, value: string, label:
// env var that resolution never consults.
//
// The model is auto-discovered from the endpoint's /v1/models (surfaced by the
// validate probe) so the user only has to paste a URL — no extra UI field.
// validate probe). The optional API key is forwarded to the probe (so hosted
// endpoints that gate /v1/models behind auth still enumerate models) and
// persisted to model.api_key so the runtime can authenticate.
//
// We deliberately don't route through completeWithModelConfirm: that path
// re-assigns the model from /api/model/options WITHOUT a base_url, which would
// wipe the base_url we just wrote. We have a concrete model already, so we
// verify the runtime directly and finish.
export async function saveOnboardingLocalEndpoint(baseUrl: string, ctx: OnboardingContext) {
export async function saveOnboardingLocalEndpoint(baseUrl: string, apiKey: string, ctx: OnboardingContext) {
const url = baseUrl.trim()
const key = apiKey.trim()
if (!url) {
return { ok: false, message: 'Enter the endpoint URL first.' }
@@ -767,7 +806,7 @@ export async function saveOnboardingLocalEndpoint(baseUrl: string, ctx: Onboardi
let model = ''
try {
const probe = await validateProviderCredential('OPENAI_BASE_URL', url)
const probe = await validateProviderCredential('OPENAI_BASE_URL', url, key)
if (!probe.ok && probe.reachable) {
return { ok: false, message: probe.message || 'Could not reach that endpoint.' }
@@ -790,7 +829,7 @@ export async function saveOnboardingLocalEndpoint(baseUrl: string, ctx: Onboardi
}
try {
await setModelAssignment({ scope: 'main', provider: 'custom', model, base_url: url })
await setModelAssignment({ scope: 'main', provider: 'custom', model, base_url: url, api_key: key })
await ctx.requestGateway('reload.env').catch(() => undefined)
const runtime = await checkRuntime(ctx)