fix(desktop): degrade model settings when /api/model/info hangs

ModelSettings.refresh() wrapped all four reads in one Promise.all, and
only getMoaModels had a .catch — when the provider backend was
unreachable, getGlobalModelInfo never settled and the page showed
skeletons forever while the fast config-file reads (auxiliary, MOA)
sat finished but unrendered.

Switch to Promise.allSettled with per-call degradation: the main-model
assignment falls back to the auxiliary config read, the provider
catalog and aux/MOA sections render whatever succeeded, and the
failures surface in the load banner. getGlobalModelInfo also drops the
60s startup timeout for its own 5s budget so the client stops waiting
on a hung probe.

Fixes https://github.com/NousResearch/hermes-agent/issues/63214 (frontend half)

Co-Authored-By: embwl0x <embwl0x@users.noreply.github.com>
This commit is contained in:
Hermes Agent
2026-09-25 12:31:55 -05:00
committed by brooklyn!
parent 43c1eee1fa
commit 2dcb8d7562
4 changed files with 86 additions and 15 deletions

View File

@@ -11,11 +11,17 @@ import type {
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client'
// /api/model/info resolves the live context window, which probes the configured
// provider's /models endpoint. An unreachable provider must not hold the Model
// Settings page hostage (the backend bounds the same probe; this is the client
// side of that budget).
const MODEL_INFO_REQUEST_TIMEOUT_MS = 5_000
export function getGlobalModelInfo(profile?: null | string): Promise<ModelInfoResponse> {
return hermesApi<ModelInfoResponse>({
...profileScoped(profile),
path: '/api/model/info',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
timeoutMs: MODEL_INFO_REQUEST_TIMEOUT_MS
})
}

View File

@@ -362,6 +362,21 @@ describe('ModelSettings', () => {
)
})
it('keeps config-backed settings usable when live model metadata times out (#63214)', async () => {
getGlobalModelInfo.mockRejectedValueOnce(new Error('Model metadata request timed out'))
renderModelSettings()
// Auxiliary assignments are a config-file read: they must still render
// instead of the whole page waiting on the hung metadata probe.
expect((await screen.findAllByRole('button', { name: 'Set to main' })).length).toBeGreaterThan(0)
// The failure surfaces in the load banner rather than skeletons forever.
await waitFor(() => expect(screen.getByText('Model metadata request timed out')).toBeTruthy())
// The main-model selector still resolves from the config-backed auxiliary
// read, so the page is interactive, not just an error shell.
await waitFor(() => expect(screen.getAllByRole('combobox')[0].textContent).toContain('Nous'))
})
it('carries the user-defined endpoint when an aux slot is set to a local main model', async () => {
getGlobalModelOptions.mockResolvedValueOnce({
providers: [

View File

@@ -291,26 +291,57 @@ export function ModelSettings({ onMainModelChanged, scopeProfile, subpage }: Mod
setSkewRestart(false)
try {
const [modelInfo, modelOptions, auxiliaryModels, moaModels] = await Promise.all([
getGlobalModelInfo(scopeProfile),
getGlobalModelOptions(undefined, scopeProfile),
getAuxiliaryModels(scopeProfile),
getMoaModels(scopeProfile).catch(() => null)
])
// Degrade per call: a hung /api/model/info (its context-length probe
// hits the configured provider, which can be unreachable) must not
// block the config-backed sections — auxiliary and MOA are fast
// config-file reads — behind a single all-or-nothing Promise.all.
const [modelInfoResult, modelOptionsResult, auxiliaryModelsResult, moaModelsResult] =
await Promise.allSettled([
getGlobalModelInfo(scopeProfile),
getGlobalModelOptions(undefined, scopeProfile),
getAuxiliaryModels(scopeProfile),
getMoaModels(scopeProfile)
])
if (profileEpoch.current !== epoch) {
return
}
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
setCatalogProviders(modelOptions.providers || [])
const failures: string[] = []
if (replaceSelection) {
setSelectedProvider(modelInfo.provider)
setSelectedModel(modelInfo.model)
} else {
setSelectedProvider(prev => prev || modelInfo.provider)
setSelectedModel(prev => prev || modelInfo.model)
const settledValue = <T,>(result: PromiseSettledResult<T>): T | null => {
if (result.status === 'fulfilled') {
return result.value
}
failures.push(result.reason instanceof Error ? result.reason.message : String(result.reason))
return null
}
const modelInfo = settledValue(modelInfoResult)
const modelOptions = settledValue(modelOptionsResult)
const auxiliaryModels = settledValue(auxiliaryModelsResult)
// MOA has always been optional-on-failure; keep it out of the banner.
const moaModels = moaModelsResult.status === 'fulfilled' ? moaModelsResult.value : null
// The main assignment also lives in the auxiliary config read, so the
// page still knows the current model when only the live probe failed.
const resolvedMain = modelInfo ?? auxiliaryModels?.main ?? null
if (resolvedMain) {
setMainModel({ model: resolvedMain.model, provider: resolvedMain.provider })
if (replaceSelection) {
setSelectedProvider(resolvedMain.provider)
setSelectedModel(resolvedMain.model)
} else {
setSelectedProvider(prev => prev || resolvedMain.provider)
setSelectedModel(prev => prev || resolvedMain.model)
}
}
if (modelOptions) {
setCatalogProviders(modelOptions.providers || [])
}
setAuxiliary(auxiliaryModels)
@@ -320,6 +351,10 @@ export function ModelSettings({ onMainModelChanged, scopeProfile, subpage }: Mod
setSelectedMoaPreset(prev => (prev && moaModels.presets[prev] ? prev : moaModels.default_preset))
}
if (failures.length > 0) {
setCaughtError(new Error(failures.join('; ')), m.loadFailed)
}
// The config record loads via its own shared query; a model switch can
// change it server-side (aux slots), so nudge that cache to refetch.
void invalidateHermesConfig(scopeProfile)

View File

@@ -465,6 +465,21 @@ describe('Hermes REST helpers', () => {
expect(call.timeoutMs).toBeUndefined()
})
it('bounds the live model metadata probe so a dead provider cannot hold the settings page', async () => {
api.mockResolvedValue({})
api.mockClear()
await getGlobalModelInfo()
// /api/model/info resolves the live context window by probing the
// configured provider; it carries its own short budget rather than the
// 60s startup timeout so Model Settings degrades instead of hanging
// when the provider backend is unreachable (#63214).
const call = api.mock.calls[0]?.[0] as { path: string; timeoutMs?: number }
expect(call.path).toBe('/api/model/info')
expect(call.timeoutMs).toBe(5_000)
})
// Explicit profile/connection writes (deleting a profile) carry the foreground
// dial tag; session reads stay on the ambient default (#111651).
it('tags cross-profile message reads for Electron routing and backend lookup', async () => {