Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/desktop/src/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
132 changes: 131 additions & 1 deletion apps/desktop/src/store/onboarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
type DesktopOnboardingState,
type OnboardingContext,
refreshOnboarding,
requestDesktopOnboarding
requestDesktopOnboarding,
saveOnboardingLocalEndpoint
} from './onboarding'

function provider(id: string, name = id): OAuthProvider {
Expand Down Expand Up @@ -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)
})
})
77 changes: 76 additions & 1 deletion apps/desktop/src/store/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/types/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,13 +577,18 @@ 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'
task?: string
}

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. */
Expand Down
53 changes: 48 additions & 5 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,12 @@ class ModelAssignment(BaseModel):
provider: str
model: str
task: str = ""
# Optional OpenAI-compatible endpoint URL. Only honored for custom/local
# providers on the main slot — lets the GUI configure a self-hosted endpoint
# (vLLM, llama.cpp, Ollama, …) that needs no API key. The runtime resolver
# 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 = ""


_GATEWAY_HEALTH_URL = os.getenv("GATEWAY_HEALTH_URL")
Expand Down Expand Up @@ -1954,6 +1960,7 @@ async def set_model_assignment(body: ModelAssignment):
provider = (body.provider or "").strip()
model = (body.model or "").strip()
task = (body.task or "").strip().lower()
base_url = (body.base_url or "").strip()

if scope not in {"main", "auxiliary"}:
raise HTTPException(status_code=400, detail="scope must be 'main' or 'auxiliary'")
Expand All @@ -1969,8 +1976,14 @@ async def set_model_assignment(body: ModelAssignment):
model_cfg = {}
model_cfg["provider"] = provider
model_cfg["default"] = model
# Clear stale base_url so the resolver picks the provider's own default.
if "base_url" in model_cfg and model_cfg.get("base_url"):
# Custom/local providers are defined by their endpoint URL, so a
# base_url must be persisted here — the runtime resolver reads
# model.base_url from config and no longer consults OPENAI_BASE_URL.
# For every other provider, clear any stale base_url so the
# resolver picks the provider's own default endpoint.
if provider.strip().lower() == "custom" and base_url:
model_cfg["base_url"] = base_url
elif "base_url" in model_cfg and model_cfg.get("base_url"):
model_cfg["base_url"] = ""
# Also clear hardcoded context_length override — new model may have
# a different context window.
Expand Down Expand Up @@ -2013,6 +2026,7 @@ async def set_model_assignment(body: ModelAssignment):
"scope": "main",
"provider": provider,
"model": model,
"base_url": model_cfg.get("base_url", ""),
"gateway_tools": gateway_tools,
}

Expand Down Expand Up @@ -2181,6 +2195,33 @@ async def set_env_var(body: EnvVarUpdate):
}


def _parse_model_ids(resp: "Any") -> List[str]:
"""Extract model ids from an OpenAI-compatible ``/v1/models`` response.

Tolerant of the common shapes: ``{"data": [{"id": ...}]}`` (OpenAI / vLLM /
llama.cpp) and a bare ``{"data": ["id", ...]}``. Returns ``[]`` on any
parse/HTTP error so a slightly non-standard endpoint never hard-blocks.
"""
try:
if not resp.is_success:
return []
payload = resp.json()
except Exception:
return []
data = payload.get("data") if isinstance(payload, dict) else payload
if not isinstance(data, list):
return []
ids: List[str] = []
for item in data:
if isinstance(item, dict):
mid = str(item.get("id") or "").strip()
else:
mid = str(item or "").strip()
if mid:
ids.append(mid)
return ids


@app.post("/api/providers/validate")
async def validate_provider_credential(body: EnvVarUpdate, request: Request):
"""Live-probe a provider credential before it's saved.
Expand All @@ -2199,13 +2240,15 @@ async def validate_provider_credential(body: EnvVarUpdate, request: Request):
return {"ok": False, "reachable": True, "message": "Enter a value first."}

# Local / custom endpoint: validate connectivity, not auth — any HTTP
# response (even 401) proves the endpoint is up.
# response (even 401) proves the endpoint is up. Also surface the model
# ids the endpoint advertises (OpenAI ``/v1/models`` shape) so the GUI can
# auto-pick a default without asking the user to type a model name.
if key == "OPENAI_BASE_URL":
url = value.rstrip("/") + "/models"
try:
with httpx.Client(timeout=httpx.Timeout(8.0)) as client:
client.get(url)
return {"ok": True, "reachable": True, "message": ""}
resp = client.get(url)
return {"ok": True, "reachable": True, "message": "", "models": _parse_model_ids(resp)}
except Exception:
return {"ok": False, "reachable": False, "message": f"Could not reach {url}."}

Expand Down
Loading
Loading