diff --git a/apps/desktop/src/api/models.ts b/apps/desktop/src/api/models.ts index 6f38cfcc2d..4357a80cc2 100644 --- a/apps/desktop/src/api/models.ts +++ b/apps/desktop/src/api/models.ts @@ -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 { return hermesApi({ ...profileScoped(profile), path: '/api/model/info', - timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + timeoutMs: MODEL_INFO_REQUEST_TIMEOUT_MS }) } diff --git a/apps/desktop/src/app/settings/model-settings.test.tsx b/apps/desktop/src/app/settings/model-settings.test.tsx index 4df24a781c..12dc52f85d 100644 --- a/apps/desktop/src/app/settings/model-settings.test.tsx +++ b/apps/desktop/src/app/settings/model-settings.test.tsx @@ -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: [ diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index 9b0663b17c..43f196caff 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -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 = (result: PromiseSettledResult): 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) diff --git a/apps/desktop/src/hermes.test.ts b/apps/desktop/src/hermes.test.ts index 221f129166..6a052b509c 100644 --- a/apps/desktop/src/hermes.test.ts +++ b/apps/desktop/src/hermes.test.ts @@ -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 () => {