Desktop GUI custom endpoint and provider configuration

NousResearch/hermes-agent · cluster 27808 · apps/desktop

Data as of

4 open items. Fix merged in #43896. Check it covers your case.

The short answer

3open issues
1open PR
22items grouped
Known?
Yes. 22 related items grouped here, 11 issues already closed.
Fix event
A fix was merged to main in PR #43896. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #62818, #122260. That is separate from the fix event.
Still open
3 issues and 1 PR.

These issues and pull requests concern adding and improving UI in the Hermes desktop app for configuring custom or local OpenAI-compatible endpoints, including API key fields, provider listings, and model picker integration. Multiple bug reports and feature requests address gaps in the desktop GUI's ability to set up and manage custom providers.

Open issues3

  • Issue#27808hermes dashboard model change 界面优化建议
  • Issue#46337[Feature]: Add UI for Custom Local STT/TTS and Local Media Generation Providers in Hermes Desktop
  • Issue#51148[Feature]: 新版桌面 GUI添加模型

Open PRs1

  • PR#48540fix(desktop): recover onboarding from missing credentials

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

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

    merged to main

    files: apps/desktop/src/app/settings/model-settings.tsx, apps/desktop/src/components/desktop-onboarding-overlay.test.tsx, apps/desktop/src/components/desktop-onboarding-overlay.tsx, apps/desktop/src/components/gateway-connecting-overlay.test.tsx, apps/desktop/src/hermes.ts, apps/desktop/src/i18n/en.ts, apps/desktop/src/i18n/types.ts, apps/desktop/src/i18n/zh.ts, apps/desktop/src/store/onboarding.test.ts, apps/desktop/src/store/onboarding.ts, apps/desktop/src/types/hermes.ts, hermes_cli/web_server.py, tests/hermes_cli/test_web_server.py

    Show the diff
    • apps/desktop/src/app/settings/model-settings.tsx
      @@ -15,7 +15,7 @@ import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment }
       import { useI18n } from '@/i18n'
       import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
       import { cn } from '@/lib/utils'
      -import { startManualProviderOAuth } from '@/store/onboarding'
      +import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
       
       import { CONTROL_TEXT } from './constants'
       import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
      @@ -224,10 +224,23 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
         }, [apiKeyDraft, selectedProviderRow])
       
         // OAuth / external providers can't be activated with a pasted key — hand off
      -  // to the shared onboarding flow scoped to this provider's real sign-in.
      +  // to the shared onboarding flow scoped to this provider's real sign-in. The
      +  // custom / local endpoint is NOT an OAuth provider, so it gets the dedicated
      +  // local-endpoint form (URL + optional API key) instead of being dead-ended
      +  // on the OAuth picker (the original "booted back to the first screen" loop).
         const startProviderSetup = useCallback(() => {
      -    if (selectedProviderRow?.slug) {
      -      startManualProviderOAuth(selectedProviderRow.slug)
      +    const slug = selectedProviderRow?.slug
      +
      +    if (!slug) {
      +      return
      +    }
      +
      +    const lower = slug.toLowerCase()
      +
      +    if (lower === 'custom' || lower === 'local' || lower.startsWith('custom:')) {
      +      startManualLocalEndpoint()
      +    } else {
      +      startManualProviderOAuth(slug)
           }
         }, [selectedProviderRow])
       
    • apps/desktop/src/components/desktop-onboarding-overlay.test.tsx
      @@ -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
         })
       })
       
    • apps/desktop/src/components/desktop-onboarding-overlay.tsx
      @@ -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 = [redacted:secret-assignment]
       
      -  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>
       
    • apps/desktop/src/components/gateway-connecting-overlay.test.tsx
      @@ -41,7 +41,8 @@ function resetStores() {
           reason: null,
           requested: false,
           firstRunSkipped: false,
      -    manual: false
      +    manual: false,
      +    localEndpoint: false
         })
       }
       
    • apps/desktop/src/hermes.ts
      @@ -343,13 +343,14 @@ export function setEnvVar(key: string, value: string): Promise<{ ok: boolean }>
       
       export function validateProviderCredential(
         key: string,
      -  value: string
      +  value: string,
      +  apiKey?: string
       ): Promise<{ ok: boolean; reachable: boolean; message: string; models?: string[] }> {
         return window.hermesDesktop.api<{ ok: boolean; reachable: boolean; message: string; models?: string[] }>({
           ...profileScoped(),
           path: '/api/providers/validate',
           method: 'POST',
      -    body: { key, value }
      +    body: { key, value, api_key: apiKey ?? '' }
         })
       }
       
    • apps/desktop/src/i18n/en.ts
      @@ -1372,6 +1372,7 @@ export const en: Translations = {
           getKey: 'Get a key',
           replaceCurrent: 'Replace current value',
           pasteApiKey: 'Paste API key',
      +    localApiKeyPlaceholder: 'API key (optional — only if your endpoint requires one)',
           couldNotSave: 'Could not save credential.',
           connecting: 'Connecting',
           update: 'Update',
    • apps/desktop/src/i18n/types.ts
      @@ -1041,6 +1041,7 @@ export interface Translations {
           getKey: string
           replaceCurrent: string
           pasteApiKey: string
      +    localApiKeyPlaceholder: string
           couldNotSave: string
           connecting: string
           update: string
    • apps/desktop/src/i18n/zh.ts
      @@ -1554,6 +1554,7 @@ export const zh: Translations = {
           getKey: '获取密钥',
           replaceCurrent: '替换当前值',
           pasteApiKey: '粘贴 API 密钥',
      +    localApiKeyPlaceholder: 'API 密钥(可选 — 仅当端点需要时填写)',
           couldNotSave: '无法保存凭据。',
           connecting: '连接中',
           update: '更新',
    • apps/desktop/src/store/onboarding.test.ts
      @@ -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: '[redacted:secret-assignment]' })
      +
      +    // 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: '[redacted:secret-assignment]'
      +    })
      +  })
      +
         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
           })
       
    • apps/desktop/src/store/onboarding.ts
      @@ -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: [redacted:secret-assignment] 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)
    • apps/desktop/src/types/hermes.ts
      @@ -638,6 +638,10 @@ export interface AuxiliaryModelsResponse {
       }
       
       export interface ModelAssignmentRequest {
      +  /** Optional API key for a custom/local endpoint. Persisted to model.api_key
      +   *  (where the runtime reads it) for self-hosted endpoints that require auth.
      +   *  Only honored for custom/local providers on the main slot. */
      +  api_key?: string
         /** OpenAI-compatible endpoint URL. Only honored for custom/local providers
          *  on the main slot — wires a self-hosted endpoint into runtime resolution. */
         base_url?: string
    • hermes_cli/web_server.py
      @@ -632,6 +632,12 @@ class EnvVarUpdate(BaseModel):
           key: str
           value: str
           profile: Optional[str] = None
      +    # Optional bearer key for the connectivity probe of a custom/local endpoint
      +    # (``key == "OPENAI_BASE_URL"``). Self-hosted endpoints that gate
      +    # ``/v1/models`` behind auth otherwise look "reachable but empty"; sending
      +    # the key lets the probe enumerate the served models. Ignored for the
      +    # regular PUT /api/env path (which only reads key/value).
      +    api_key: str = ""
       
       
       class EnvVarDelete(BaseModel):
      @@ -719,6 +725,12 @@ class ModelAssignment(BaseModel):
           # reads model.base_url from config (it ignores OPENAI_BASE_URL), so this is
           # the path that actually wires a local endpoint into resolution.
           base_url: str = ""
      +    # Optional API key for a custom/local endpoint. Persisted to
      +    # ``model.api_key`` (where the runtime resolver reads it) so a self-hosted
      +    # endpoint that requires auth works from the GUI — mirrors the key the
      +    # ``hermes model`` custom flow collects. Honored only on the main slot for
      +    # custom/local providers.
      +    api_key: str = ""
           confirm_expensive_model: bool = False
           profile: Optional[str] = None
       
      @@ -791,7 +803,7 @@ def _normalize_main_model_assignment(provider: str, model: str) -> tuple[str, st
       
       
       def _apply_main_model_assignment(
      -    model_cfg: "Any", provider: str, model: str, base_url: str = ""
      +    model_cfg: "Any", provider: str, model: str, base_url: str = "", api_key: str = ""
       ) -> dict:
           """Apply a main-slot model assignment to a ``model`` config dict in place.
       
      @@ -831,6 +843,14 @@ def _apply_main_model_assignment(
               # it so the new provider's default endpoint is used. Same-provider
               # re-assignment keeps the user's configured base_url intact.
               model_cfg["base_url"] = ""
      +    # The endpoint key follows the same lifecycle as base_url: an explicit key
      +    # is always persisted; an existing key is dropped only when switching to a
      +    # different provider (it belonged to the old endpoint), and preserved on a
      +    # same-provider re-pick so re-selecting a model doesn't wipe the key.
      +    if api_key.strip():
      +        model_cfg["api_key"] = api_key.strip()
      +    elif model_cfg.get("api_key") and new_provider != prev_provider:
      +        model_cfg["api_key"] = ""
           model_cfg.pop("context_length", None)
           return model_cfg
       
      @@ -3196,6 +3216,7 @@ async def set_model_assignment(body: ModelAssignment, profile: Optional[str] = N
           model = (body.model or "").strip()
           task = (body.task or "").strip().lower()
           base_url = (body.base_url or "").strip()
      +    api_key = [redacted:secret-assignment] or "").strip()
       
           if scope not in {"main", "auxiliary"}:
               raise HTTPException(status_code=400, detail="scope must be 'main' or 'auxiliary'")
      @@ -3232,7 +3253,7 @@ async def set_model_assignment(body: ModelAssignment, profile: Optional[str] = N
               def _apply_assignment():
                   with _profile_scope(body.profile or profile):
                       return _apply_model_assignment_sync(
      -                    scope, provider, model, task, base_url
      +                    scope, provider, model, task, base_url, api_key
                       )
       
               return await asyncio.to_thread(_apply_assignment)
      @@ -3244,7 +3265,7 @@ def _apply_assignment():
       
       
       def _apply_model_assignment_sync(
      -    scope: str, provider: str, model: str, task: str, base_url: str
      +    scope: str, provider: str, model: str, task: str, base_url: str, api_key: str = ""
       ):
           """Synchronous body of POST /api/model/set.
       
      @@ -3259,7 +3280,7 @@ def _apply_model_assignment_sync(
                   raise HTTPException(status_code=400, detail="provider and model required for main")
               provider, model = _normalize_main_model_assignment(provider, model)
               model_cfg = _apply_main_model_assignment(
      -            cfg.get("model", {}), provider, model, base_url
      +            cfg.get("model", {}), provider, model, base_url, api_key
               )
               cfg["model"] = model_cfg
       
      @@ -3294,6 +3315,27 @@ def _apply_model_assignment_sync(
       
               save_config(cfg)
       
      +        # Register a named ``custom_providers`` entry for a custom/local
      +        # endpoint, mirroring the ``hermes model`` custom flow
      +        # (_save_custom_provider). Without this the endpoint only lives in
      +        # ``model.*`` and the picker has no proper ready row for it — the
      +        # GUI then surfaces a "needs setup" dead-end on the bare ``custom``
      +        # provider. Dedups by base_url, so re-saving is idempotent.
      +        if provider.strip().lower() in {"custom", "local"} and base_url:
      +            try:
      +                from hermes_cli.main import _auto_provider_name, _save_custom_provider
      +
      +                _save_custom_provider(
      +                    base_url,
      +                    api_key,
      +                    model,
      +                    name=_auto_provider_name(base_url),
      +                )
      +            except Exception:
      +                # Never block the assignment on the bookkeeping write —
      +                # model.* is already persisted and routable.
      +                _log.debug("custom_providers registration skipped", exc_info=True)
      +
               # Surface auxiliary slots still pinned to a *different* provider than
               # the new main one. Switching the main model does NOT touch aux pins
               # (they're independent, sticky per-task overrides — see
      @@ -3548,9 +3590,14 @@ async def validate_provider_credential(body: EnvVarUpdate, request: Request):
           # auto-pick a default without asking the user to type a model name.
           if key == "OPENAI_BASE_URL":
               url = value.rstrip("/") + "/models"
      +        # Send the optional API key so endpoints that require auth on
      +        # ``/v1/models`` (many hosted OpenAI-compatible servers) still enumerate
      +        # their models instead of returning an empty list behind a 401.
      +        api_key = [redacted:secret-assignment] or "").strip()
      +        headers = {"Authorization": f"Bearer {api_key}"} if api_key else None
               try:
                   with httpx.Client(timeout=httpx.Timeout(8.0)) as client:
      -                resp = client.get(url)
      +                resp = client.get(url, headers=headers)
                   return {"ok": True, "reachable": True, "message": "", "models": _parse_model_ids(resp)}
               except Exception:
                   return {"ok": False, "reachable": False, "message": f"Could not reach {url}."}
    • tests/hermes_cli/test_web_server.py
      @@ -1880,6 +1880,28 @@ def test_apply_main_model_assignment_base_url_and_context_reconcile(self):
               out = _apply_main_model_assignment("not-a-dict", "custom", "m", "http://x/v1")
               assert out == {"provider": "custom", "default": "m", "base_url": "http://x/v1"}
       
      +        # api_key follows the same lifecycle as base_url:
      +        # supplied → persisted.
      +        out = _apply_main_model_assignment(
      +            {}, "custom", "m", "http://x/v1", "sk-secret"
      +        )
      +        assert out["api_key"] == "sk-secret"
      +
      +        # same provider, no new key → existing key preserved (re-picking a model
      +        # on the same custom endpoint must not wipe the saved key).
      +        out = _apply_main_model_assignment(
      +            {"provider": "custom", "base_url": "http://x/v1", "api_key": "sk-keep"},
      +            "custom",
      +            "m2",
      +        )
      +        assert out["api_key"] == "sk-keep"
      +
      +        # switching providers without a new key → stale key cleared.
      +        out = _apply_main_model_assignment(
      +            {"provider": "custom", "api_key": "sk-old"}, "openrouter", "m"
      +        )
      +        assert out["api_key"] == ""
      +
           def test_parse_model_ids_handles_openai_and_bare_shapes(self):
               """Model discovery must tolerate the common /v1/models shapes and
               never raise (so a slightly non-standard local endpoint still works)."""
      @@ -1936,6 +1958,45 @@ def test_set_model_main_custom_persists_base_url(self):
               assert model_cfg["default"] == "llama-3.1-8b"
               assert model_cfg["base_url"] == "http://127.0.0.1:8000/v1"
       
      +    def test_set_model_main_custom_persists_api_key_and_registers_provider(self):
      +        """A custom endpoint that requires auth must persist model.api_key (where
      +        the runtime reads it) AND register a named custom_providers entry so the
      +        endpoint reappears as a ready row in the picker — matching the
      +        ``hermes model`` custom flow. Regression for the desktop loop where a
      +        keyed custom endpoint could never be configured from the GUI."""
      +        from hermes_cli.config import load_config
      +
      +        resp = self.client.post(
      +            "/api/model/set",
      +            json={
      +                "scope": "main",
      +                "provider": "custom",
      +                "model": "gpt-oss-120b",
      +                "base_url": "https://text.example.com/v1",
      +                "api_key": "sk-secret",
      +            },
      +        )
      +        assert resp.status_code == 200
      +        assert resp.json()["ok"] is True
      +
      +        cfg = load_config()
      +        model_cfg = cfg.get("model")
      +        assert isinstance(model_cfg, dict)
      +        assert model_cfg["provider"] == "custom"
      +        assert model_cfg["base_url"] == "https://text.example.com/v1"
      +        assert model_cfg["api_key"] == "sk-secret"
      +
      +        # Registered in custom_providers (dedup by base_url) so the picker shows
      +        # a proper ready row instead of the "needs setup" dead-end.
      +        custom = cfg.get("custom_providers") or []
      +        assert any(
      +            isinstance(e, dict)
      +            and e.get("base_url") == "https://text.example.com/v1"
      +            and e.get("api_key") == "sk-secret"
      +            and e.get("model") == "gpt-oss-120b"
      +            for e in custom
      +        )
      +
           def test_set_model_main_non_custom_clears_stale_base_url(self):
               """Switching to a hosted provider must clear a stale base_url so the
               resolver picks that provider's own default endpoint."""
      @@ -5018,6 +5079,83 @@ def test_empty_value_rejected(self):
               data = self._post("OPENAI_API_KEY", "   ").json()
               assert data["ok"] is False
       
      +    def test_local_endpoint_forwards_api_key_as_bearer(self, monkeypatch):
      +        """A custom endpoint that gates /v1/models behind auth must still
      +        enumerate models: the optional api_key is sent as a Bearer header so the
      +        probe doesn't come back empty (the desktop loop's root cause)."""
      +        captured = {}
      +
      +        class _Resp:
      +            status_code = 200
      +            is_success = True
      +
      +            def json(self):
      +                return {"data": [{"id": "gpt-oss-120b"}]}
      +
      +        class _Client:
      +            def __init__(self, *a, **k):
      +                pass
      +
      +            def __enter__(self):
      +                return self
      +
      +            def __exit__(self, *a):
      +                return False
      +
      +            def get(self, url, *a, headers=None, **k):
      +                captured["url"] = url
      +                captured["headers"] = headers
      +                return _Resp()
      +
      +        monkeypatch.setattr("httpx.Client", _Client)
      +
      +        resp = self.client.post(
      +            "/api/providers/validate",
      +            json={
      +                "key": "OPENAI_BASE_URL",
      +                "value": "https://text.example.com/v1",
      +                "api_key": "sk-secret",
      +            },
      +        )
      +        data = resp.json()
      +        assert data["ok"] is True and data["reachable"] is True
      +        assert data["models"] == ["gpt-oss-120b"]
      +        assert captured["url"] == "https://text.example.com/v1/models"
      +        assert captured["headers"] == {"Authorization": "Bearer sk-secret"}
      +
      +    def test_local_endpoint_without_key_sends_no_auth_header(self, monkeypatch):
      +        """No key → no Authorization header (keyless local servers unaffected)."""
      +        captured = {}
      +
      +        class _Resp:
      +            status_code = 200
      +            is_success = True
      +
      +            def json(self):
      +                return {"data": []}
      +
      +        class _Client:
      +            def __init__(self, *a, **k):
      +                pass
      +
      +            def __enter__(self):
      +                return self
      +
      +            def __exit__(self, *a):
      +                return False
      +
      +            def get(self, url, *a, headers=None, **k):
      +                captured["headers"] = headers
      +                return _Resp()
      +
      +        monkeypatch.setattr("httpx.Client", _Client)
      +
      +        self.client.post(
      +            "/api/providers/validate",
      +            json={"key": "OPENAI_BASE_URL", "value": "http://127.0.0.1:8000/v1"},
      +        )
      +        assert captured["headers"] is None
      +
       
       class TestDesktopCronTicker:
           """The dashboard backend fires cron jobs itself only when desktop-spawned."""
  • #62818 fix(desktop): expose Local / custom endpoint in Providers API-keys tab

    merged to main

    files: apps/desktop/src/app/settings/providers-settings.test.tsx, apps/desktop/src/app/settings/providers-settings.tsx, apps/desktop/src/i18n/en.ts, apps/desktop/src/i18n/ja.ts, apps/desktop/src/i18n/types.ts, apps/desktop/src/i18n/zh-hant.ts, apps/desktop/src/i18n/zh.ts

  • #122260 fix(providers): normalize local OpenAI-compatible aliases to custom in every table

    merged to main

    files: hermes_cli/auth.py, hermes_cli/models_catalog_static.py, hermes_cli/providers.py, tests/hermes_cli/test_local_provider_alias_parity.py

4 PRs closed without merging

From the close comments: done elsewhere 3 · other 1

  • #39009 Add desktop custom provider setup · done elsewhere → 4ddb033, #43896, #38975
  • #39717 fix(cli): require explicit setup before treating env keys as configured · other → #38471
  • #42243 feat(desktop): add optional API key field for custom endpoint onboarding · done elsewhere → 4ddb033, #43896, #42042
  • #62239 fix(agent): unify local OpenAI-compatible provider aliases to custom (#62213) · done elsewhere → 7a5c715, 70f7fa0, #62213
Closed issues · 11
  • Closed#38348Desktop: Custom/OpenAI-compatible endpoint setup does not allow API key configuration
  • Closed#38471[Bug]: Hermes Desktop skips onboarding, finds random invalid OAI-API key, nowhere to place OAI/Codex information.
  • Closed#38975Desktop UI: add full custom OpenAI-compatible provider setup
  • Closed#42042Desktop UI: add API key field for Local / custom endpoint
  • Closed#47053[FEATURE REQUEST] Add 'Add Custom Endpoint' entry in desktop model picker for easy custom provider setup
  • Closed#48022Desktop installer skips API key setup, first launch shows unrecoverable provider error
  • Closed#48925[Feature Request] Desktop App: Add Custom Model Configuration UI
  • Closed#48928[Feature]: 桌面版:添加自定义模型配置界面
  • Closed#52807Feature: add UI for configuring third-party API providers (custom_providers)
  • Closed#62213[Bug]: Local OpenAI-compatible providers are no longer configurable from Hermes Desktop
  • Closed#62817[Bug]: Hermes Desktop has no reachable GUI path to add a custom / OpenAI-compatible endpoint
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.07: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
apps/desktop
Merged PRs
#43896, #62818, #122260
Closed issues
11
Attempts
4 PRs closed without merging
Fix link checked
25 Sep 2026

For your agent

The same facts, typed.