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:
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user