mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-12 08:51:53 +00:00
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:
parent
c6007e5c1a
commit
4ddb03390a
13 changed files with 363 additions and 37 deletions
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ?? '' }
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1041,6 +1041,7 @@ export interface Translations {
|
|||
getKey: string
|
||||
replaceCurrent: string
|
||||
pasteApiKey: string
|
||||
localApiKeyPlaceholder: string
|
||||
couldNotSave: string
|
||||
connecting: string
|
||||
update: string
|
||||
|
|
|
|||
|
|
@ -1554,6 +1554,7 @@ export const zh: Translations = {
|
|||
getKey: '获取密钥',
|
||||
replaceCurrent: '替换当前值',
|
||||
pasteApiKey: '粘贴 API 密钥',
|
||||
localApiKeyPlaceholder: 'API 密钥(可选 — 仅当端点需要时填写)',
|
||||
couldNotSave: '无法保存凭据。',
|
||||
connecting: '连接中',
|
||||
update: '更新',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}."}
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue