diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index 4a7ffcfd94f..c55fa6b4773 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/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]) diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx index 38084ae4c91..930280faf9d 100644 --- a/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx +++ b/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 }) }) diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.tsx index 2b6068de3f2..a3808e634d1 100644 --- a/apps/desktop/src/components/desktop-onboarding-overlay.tsx +++ b/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 = 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 (
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(options[0]) + + const [option, setOption] = useState( + () => 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) // `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 ? ( + setLocalKey(e.target.value)} + onKeyDown={e => e.key === 'Enter' && void submit()} + placeholder={t.onboarding.localApiKeyPlaceholder} + type="password" + value={localKey} + /> + ) : null} {error ?

{error}

: null}
diff --git a/apps/desktop/src/components/gateway-connecting-overlay.test.tsx b/apps/desktop/src/components/gateway-connecting-overlay.test.tsx index eef3b371e27..5e35a2b2679 100644 --- a/apps/desktop/src/components/gateway-connecting-overlay.test.tsx +++ b/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 }) } diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index da3247a36a9..c21fb0a106b 100644 --- a/apps/desktop/src/hermes.ts +++ b/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 ?? '' } }) } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index c3ac422f604..a0cfdbb08b7 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/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', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 268b2474da3..592fe2bfa2c 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/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 diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 435e891600c..de6f467ab61 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1554,6 +1554,7 @@ export const zh: Translations = { getKey: '获取密钥', replaceCurrent: '替换当前值', pasteApiKey: '粘贴 API 密钥', + localApiKeyPlaceholder: 'API 密钥(可选 — 仅当端点需要时填写)', couldNotSave: '无法保存凭据。', connecting: '连接中', update: '更新', diff --git a/apps/desktop/src/store/onboarding.test.ts b/apps/desktop/src/store/onboarding.test.ts index 2958fd03f92..7173e89f572 100644 --- a/apps/desktop/src/store/onboarding.test.ts +++ b/apps/desktop/src/store/onboarding.test.ts @@ -33,6 +33,7 @@ function baseState(overrides: Partial = {}): 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 }) diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts index 7c8ece26469..927c0911dda 100644 --- a/apps/desktop/src/store/onboarding.ts +++ b/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(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) diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index b79b34d7f26..f90e31c53bb 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/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 diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index a3f503afaf9..6ae7727d686 100644 --- a/hermes_cli/web_server.py +++ b/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 = (body.api_key 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 @@ async def set_model_assignment(body: ModelAssignment, profile: Optional[str] = N 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 = (body.api_key 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}."} diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 61c4aa466be..c6f186b9f63 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1880,6 +1880,28 @@ class TestWebServerEndpoints: 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 @@ class TestWebServerEndpoints: 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 @@ class TestValidateProviderCredential: 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."""