fix(desktop): configure local/custom endpoint without an API key or UI changes

Onboarding's "Local / custom endpoint" only wrote the OPENAI_BASE_URL env
var, which runtime resolution ignores — so a self-hosted endpoint was never
wired in and setup failed with "No usable credentials found for custom" even
though local servers need no key.

Route the local option through saveOnboardingLocalEndpoint: probe the
endpoint, auto-discover a model from /v1/models, persist provider=custom +
base_url + model via /api/model/set, then verify the runtime directly
(not via completeWithModelConfirm, which would re-assign the model without
base_url and wipe it). No onboarding form/UI changes — the existing single
URL field is enough.
This commit is contained in:
xxxigm
2026-06-03 21:41:29 +07:00
committed by Teknium
parent ca06715721
commit 5a22cd427d
4 changed files with 214 additions and 4 deletions

View File

@ -255,8 +255,8 @@ export function setEnvVar(key: string, value: string): Promise<{ ok: boolean }>
export function validateProviderCredential(
key: string,
value: string
): Promise<{ ok: boolean; reachable: boolean; message: string }> {
return window.hermesDesktop.api<{ ok: boolean; reachable: boolean; message: string }>({
): Promise<{ ok: boolean; reachable: boolean; message: string; models?: string[] }> {
return window.hermesDesktop.api<{ ok: boolean; reachable: boolean; message: string; models?: string[] }>({
path: '/api/providers/validate',
method: 'POST',
body: { key, value }

View File

@ -7,7 +7,8 @@ import {
type DesktopOnboardingState,
type OnboardingContext,
refreshOnboarding,
requestDesktopOnboarding
requestDesktopOnboarding,
saveOnboardingLocalEndpoint
} from './onboarding'
function provider(id: string, name = id): OAuthProvider {
@ -143,3 +144,132 @@ describe('refreshOnboarding', () => {
expect($desktopOnboarding.get().providers?.map(p => p.id)).toEqual(['shared'])
})
})
describe('saveOnboardingLocalEndpoint', () => {
beforeEach(() => {
window.localStorage.clear()
$desktopOnboarding.set(baseState())
})
afterEach(() => {
window.localStorage.clear()
$desktopOnboarding.set(baseState())
vi.restoreAllMocks()
})
function readyGateway(): OnboardingContext['requestGateway'] {
return async method => {
if (method === 'reload.env') {
return {} as never
}
if (method === 'setup.status') {
return { provider_configured: true } as never
}
if (method === 'setup.runtime_check') {
return { ok: true } as never
}
throw new Error(`unexpected gateway method: ${method}`)
}
}
it('errors when the endpoint advertises no models (nothing to route to)', async () => {
const calls: string[] = []
installApiMock(async ({ path }: { path: string }) => {
calls.push(path)
if (path === '/api/providers/validate') {
return { ok: true, reachable: true, message: '', models: [] }
}
throw new Error(`unexpected api path: ${path}`)
})
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', {
requestGateway: readyGateway()
})
expect(result.ok).toBe(false)
expect(result.message).toContain('no models')
// Must not attempt to persist an assignment without a model.
expect(calls).not.toContain('/api/model/set')
})
it('auto-discovers the model and persists provider=custom + base_url, then finishes', 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: ['llama-3.1-8b', 'qwen2.5-7b'] }
}
if (path === '/api/model/set') {
return { ok: true, provider: 'custom', model: 'llama-3.1-8b', base_url: 'http://127.0.0.1:8000/v1' }
}
throw new Error(`unexpected api path: ${path}`)
})
installApiMock(api)
const onCompleted = vi.fn()
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', {
onCompleted,
requestGateway: readyGateway()
})
expect(result.ok).toBe(true)
const assign = calls.find(c => c.path === '/api/model/set')
expect(assign?.body).toMatchObject({
scope: 'main',
provider: 'custom',
model: 'llama-3.1-8b',
base_url: 'http://127.0.0.1:8000/v1'
})
expect(onCompleted).toHaveBeenCalledTimes(1)
expect($desktopOnboarding.get().configured).toBe(true)
})
it('reports the runtime reason when resolution still fails after saving', async () => {
installApiMock(async ({ path }: { path: string }) => {
if (path === '/api/providers/validate') {
return { ok: true, reachable: true, message: '', models: ['llama-3.1-8b'] }
}
if (path === '/api/model/set') {
return { ok: true }
}
throw new Error(`unexpected api path: ${path}`)
})
const failingGateway: OnboardingContext['requestGateway'] = async method => {
if (method === 'reload.env') {
return {} as never
}
if (method === 'setup.status') {
return { provider_configured: false } as never
}
if (method === 'setup.runtime_check') {
return { ok: false, error: 'No provider can serve the selected model.' } as never
}
throw new Error(`unexpected gateway method: ${method}`)
}
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', {
requestGateway: failingGateway
})
expect(result.ok).toBe(false)
expect(result.message).toContain('No provider can serve the selected model.')
expect($desktopOnboarding.get().configured).not.toBe(true)
})
})

View File

@ -9,7 +9,8 @@ import {
setEnvVar,
setModelAssignment,
startOAuthLogin,
submitOAuthCode
submitOAuthCode,
validateProviderCredential
} from '@/hermes'
import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { notify, notifyError } from '@/store/notifications'
@ -619,6 +620,13 @@ export async function saveOnboardingApiKey(envKey: string, value: string, label:
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.
if (envKey === 'OPENAI_BASE_URL') {
return saveOnboardingLocalEndpoint(trimmed, ctx)
}
// No key validation here on purpose: we previously live-probed the key and
// hard-blocked on a runtime check after saving, which rejected too many
// legitimate users (corporate proxies, regional blocks, flaky/rate-limited
@ -644,6 +652,73 @@ export async function saveOnboardingApiKey(envKey: string, value: string, label:
}
}
// Configure a local / self-hosted OpenAI-compatible endpoint (vLLM, llama.cpp,
// Ollama, …). Unlike API-key providers, a local endpoint is defined by its URL
// and usually needs NO key. The runtime resolver reads model.base_url from
// config (it ignores the OPENAI_BASE_URL env var), so we persist
// provider=custom + base_url + model via /api/model/set rather than dropping an
// 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.
//
// 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) {
const url = baseUrl.trim()
if (!url) {
return { ok: false, message: 'Enter the endpoint URL first.' }
}
// Probe connectivity + discover the served models. Any HTTP response proves
// the endpoint is up; an unreachable probe hard-blocks because we can't
// resolve a model to route to.
let model = ''
try {
const probe = await validateProviderCredential('OPENAI_BASE_URL', url)
if (!probe.ok && probe.reachable) {
return { ok: false, message: probe.message || 'Could not reach that endpoint.' }
}
if (!probe.reachable) {
return { ok: false, message: probe.message || `Could not reach ${url}.` }
}
model = (probe.models?.[0] ?? '').trim()
} catch {
return { ok: false, message: `Could not reach ${url}.` }
}
if (!model) {
return {
ok: false,
message: `Connected to ${url}, but it advertised no models at /v1/models. Start a model on that endpoint and try again.`
}
}
try {
await setModelAssignment({ scope: 'main', provider: 'custom', model, base_url: url })
await ctx.requestGateway('reload.env').catch(() => undefined)
const runtime = await checkRuntime(ctx)
if (!runtime.ready) {
const detail = (runtime.reason ?? '').trim()
return { ok: false, message: detail || `Saved, but Hermes still cannot reach ${url}.` }
}
notifyReady('Local / custom endpoint')
completeDesktopOnboarding()
ctx.onCompleted?.()
return { ok: true }
} catch (error) {
notifyError(error, 'Could not save local endpoint')
return { ok: false, message: errMessage(error) }
}
}
// User picked a different model from the dropdown on the confirm card.
// Persists immediately so the displayed value is always what's on disk.
export async function setOnboardingModel(model: string) {

View File

@ -577,6 +577,9 @@ export interface AuxiliaryModelsResponse {
}
export interface ModelAssignmentRequest {
/** 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
model: string
provider: string
scope: 'main' | 'auxiliary'
@ -584,6 +587,8 @@ export interface ModelAssignmentRequest {
}
export interface ModelAssignmentResponse {
/** Persisted endpoint URL for custom/local providers (echoed back). */
base_url?: string
/** Toolset keys auto-routed through the Nous Tool Gateway as a result of
* switching the main provider to Nous. Empty unless provider === 'nous'
* and the user is a paid subscriber with unconfigured tools. */