fix(desktop): expose Local / custom endpoint in Providers API-keys tab (#62818)

The onboarding overlay already contains a 'Local / custom endpoint' card
that writes model.provider:custom + base_url + api_key, but no reachable
Desktop GUI path opens it for a fresh add. The composer model pill falls
back to the gateway menu panel (Edit Models…), and Settings → Providers →
API keys is env-var-driven and never lists a custom endpoint — so users
following their instincts cannot add an OpenAI-compatible endpoint (Zyphra,
vLLM, Ollama, …) from the GUI.

Add a 'Local / custom endpoint' row to the API-keys tab that calls
startManualLocalEndpoint(), landing the overlay directly on the existing
custom-endpoint form. Reuses the tested onboarding flow; no new UI surface.

Regression test in providers-settings.test.tsx asserts the row renders and
opens the custom-endpoint flow.

Fixes #62817

Co-authored-by: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com>
This commit is contained in:
David Metcalfe 2026-07-17 15:58:37 -07:00 committed by GitHub
parent b9267b50ea
commit e7a8b374b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 74 additions and 2 deletions

View file

@ -8,6 +8,7 @@ const listOAuthProviders = vi.fn()
const disconnectOAuthProvider = vi.fn()
const getEnvVars = vi.fn()
const startManualProviderOAuth = vi.fn()
const startManualLocalEndpoint = vi.fn()
const onboarding = atom({ manual: false })
vi.mock('@/hermes', () => ({
@ -18,7 +19,8 @@ vi.mock('@/hermes', () => ({
vi.mock('@/store/onboarding', () => ({
$desktopOnboarding: onboarding,
startManualProviderOAuth: (providerId: string) => startManualProviderOAuth(providerId)
startManualProviderOAuth: (providerId: string) => startManualProviderOAuth(providerId),
startManualLocalEndpoint: (reason: null | string) => startManualLocalEndpoint(reason)
}))
function provider(id: string, loggedIn: boolean, patch: Partial<OAuthProvider> = {}): OAuthProvider {
@ -182,4 +184,23 @@ describe('ProvidersSettings', () => {
})
expect(await screen.findByText('No providers match your search.')).toBeTruthy()
})
it('offers a Local / custom endpoint entry in the API-keys tab that opens the custom-endpoint flow', async () => {
// Regression: 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 GUI path to add one. See issue #62817.
getEnvVars.mockResolvedValue({})
listOAuthProviders.mockResolvedValue({ providers: [] })
const { ProvidersSettings } = await import('./providers-settings')
render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="keys" />)
const row = await screen.findByText('Local / custom endpoint')
expect(screen.getByText(/OpenAI-compatible endpoint/)).toBeTruthy()
fireEvent.click(row)
await waitFor(() => expect(startManualLocalEndpoint).toHaveBeenCalledWith(null))
})
})

View file

@ -21,7 +21,7 @@ import { Check, ChevronDown, ChevronRight, KeyRound, Loader2, Terminal, Trash2 }
import { normalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding'
import { $desktopOnboarding, startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
import { isKeyVar, ProviderKeyRows } from './credential-key-ui'
@ -300,6 +300,36 @@ function 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 (
<RowButton
className="group grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)"
onClick={() => onOpen(null)}
>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-[length:var(--conversation-text-font-size)] font-semibold">{copy.title}</span>
<span className="truncate text-[length:var(--conversation-caption-font-size)] leading-5 text-muted-foreground">
{copy.description}
</span>
</div>
<ChevronRight className="size-4 text-muted-foreground transition group-hover:text-foreground" />
</RowButton>
)
}
export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSettingsProps) {
const { t } = useI18n()
const { rowProps, vars } = useEnvCredentials()
@ -417,6 +447,7 @@ export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSett
return (
<SettingsContent>
<LocalEndpointRow onOpen={startManualLocalEndpoint} />
{keyGroups.length > 0 ? (
<div className="grid gap-3">
<SearchField

View file

@ -766,6 +766,10 @@ export const en: Translations = {
noProviderKeys: 'No provider API keys available.',
searchKeys: 'Search providers…',
noKeysMatch: 'No providers match your search.',
localEndpoint: {
title: 'Local / custom endpoint',
description: 'Point Hermes at any OpenAI-compatible endpoint (Zyphra, vLLM, llama.cpp, Ollama, etc).'
},
loading: 'Loading providers...'
},
sessions: {

View file

@ -799,6 +799,10 @@ export const ja = defineLocale({
noProviderKeys: '利用可能なプロバイダー API キーがありません。',
searchKeys: 'プロバイダーを検索…',
noKeysMatch: '一致するプロバイダーがありません。',
localEndpoint: {
title: 'ローカル / カスタムエンドポイント',
description: 'OpenAI 互換のエンドポイントZyphra、vLLM、llama.cpp、Ollama など)を指定します。'
},
loading: 'プロバイダーを読み込み中...'
},
sessions: {

View file

@ -657,6 +657,10 @@ export interface Translations {
noProviderKeys: string
searchKeys: string
noKeysMatch: string
localEndpoint: {
title: string
description: string
}
loading: string
}
sessions: {

View file

@ -775,6 +775,10 @@ export const zhHant = defineLocale({
noProviderKeys: '沒有可用的提供方 API 金鑰。',
searchKeys: '搜尋提供方…',
noKeysMatch: '沒有符合的提供方。',
localEndpoint: {
title: '本地 / 自訂端點',
description: '將 Hermes 指向任意 OpenAI 相容端點Zyphra、vLLM、llama.cpp、Ollama 等)。'
},
loading: '正在載入提供方...'
},
sessions: {

View file

@ -953,6 +953,10 @@ export const zh: Translations = {
noProviderKeys: '没有可用的提供方 API 密钥。',
searchKeys: '搜索提供方…',
noKeysMatch: '没有匹配的提供方。',
localEndpoint: {
title: '本地 / 自定义端点',
description: '将 Hermes 指向任意 OpenAI 兼容端点Zyphra、vLLM、llama.cpp、Ollama 等)。'
},
loading: '正在加载提供方...'
},
sessions: {