From 5a22cd427dd6a7e8139db45f2680012425f7f119 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Wed, 3 Jun 2026 21:41:29 +0700 Subject: [PATCH] fix(desktop): configure local/custom endpoint without an API key or UI changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/desktop/src/hermes.ts | 4 +- apps/desktop/src/store/onboarding.test.ts | 132 +++++++++++++++++++++- apps/desktop/src/store/onboarding.ts | 77 ++++++++++++- apps/desktop/src/types/hermes.ts | 5 + 4 files changed, 214 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 6e41f3828..b96cfceb6 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -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 } diff --git a/apps/desktop/src/store/onboarding.test.ts b/apps/desktop/src/store/onboarding.test.ts index 17c15930c..3828670c8 100644 --- a/apps/desktop/src/store/onboarding.test.ts +++ b/apps/desktop/src/store/onboarding.test.ts @@ -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) + }) +}) diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts index 75ebd5074..aa4143509 100644 --- a/apps/desktop/src/store/onboarding.ts +++ b/apps/desktop/src/store/onboarding.ts @@ -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) { diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 9300ea14e..4d92d7222 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -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. */