From ff5eb8ac8406e00f60ae50b19e7356358da76d67 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 5 Jun 2026 17:53:04 +0100 Subject: [PATCH] Add Assistant fallback route recovery --- .../v6/internal/subsystems/ai-runtime.md | 9 ++- .../AI/Chat/__tests__/AIChat.test.tsx | 72 +++++++++++++++++++ .../src/components/AI/Chat/index.tsx | 42 ++++++++++- 3 files changed, 117 insertions(+), 6 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 9897af8a8..533c1ddd9 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -122,9 +122,12 @@ runtime cost control, and shared AI transport surfaces. provider route to a configured gateway or alternate model without losing the draft or creating a parallel picker. When the same model is available through another configured provider route, the failed turn must offer that route as a - direct one-click route-and-retry action before falling back to the general - model selector. Retry remains available, but it must not be the only visible - action when a failed Assistant turn is shown. + direct one-click route-and-retry action, but route recovery must not loop + between providers that already failed in the same transcript; once equivalent + routes are exhausted, the failed turn must fall back to another configured + notable model/provider before falling back to the general model selector. + Retry remains available, but it must not be the only visible action when a + failed Assistant turn is shown. Restored Assistant sessions must hydrate saved assistant content and persisted tool calls into the same transcript event shape used by live streams so switching sessions does not hide prior tool evidence or collapse diff --git a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx index 5501dd078..a850ddb92 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -513,6 +513,78 @@ describe('AIChat', () => { ); }); + it('falls back to another configured provider after equivalent routes have already failed', async () => { + const openRouterFailure: ChatMessage = { + id: 'assistant-error-openrouter', + role: 'assistant', + content: '', + error: 'The AI provider rejected the credentials. Check your AI provider API key in Settings.', + timestamp: new Date('2026-06-05T10:00:00Z'), + model: 'openrouter:deepseek/deepseek-v4-pro', + }; + const deepSeekFailure: ChatMessage = { + id: 'assistant-error-deepseek', + role: 'assistant', + content: '', + error: + 'Pulse could not reach the AI provider endpoint. Check the selected provider URL and network connection, then retry.', + timestamp: new Date('2026-06-05T10:01:00Z'), + model: 'deepseek:deepseek-v4-pro', + }; + mockChat.model.mockReturnValue('deepseek:deepseek-v4-pro'); + mockChat.messages.mockReturnValue([openRouterFailure, deepSeekFailure]); + mockAIAPI.getSettings.mockResolvedValue({ + model: 'deepseek:deepseek-v4-pro', + chat_model: '', + control_level: 'read_only', + autonomous_mode: false, + discovery_enabled: true, + configured_providers: ['deepseek', 'openrouter', 'openai'], + }); + mockAIAPI.getModels.mockResolvedValue({ + models: [ + { + id: 'deepseek:deepseek-v4-pro', + name: 'DeepSeek V4 Pro', + provider: 'deepseek', + notable: true, + }, + { + id: 'openrouter:deepseek/deepseek-v4-pro', + name: 'DeepSeek: DeepSeek V4 Pro', + provider: 'openrouter', + notable: true, + }, + { + id: 'openai:gpt-4o', + name: 'GPT-4o', + provider: 'openai', + notable: true, + }, + ], + }); + + renderChat(); + + await waitFor(() => { + expect(mockAIAPI.getModels).toHaveBeenCalled(); + }); + + const props = mockChatMessagesProps[mockChatMessagesProps.length - 1]; + const alternative = props.getModelRouteAlternative?.(deepSeekFailure); + + expect(alternative).toMatchObject({ + id: 'openai:gpt-4o', + provider: 'openai', + providerLabel: 'OpenAI', + }); + + props.onUseModelRoute?.(alternative!.id, deepSeekFailure.id); + + expect(mockChat.setModel).toHaveBeenCalledWith('openai:gpt-4o'); + expect(mockChat.retryMessage).toHaveBeenCalledWith('assistant-error-deepseek'); + }); + it('checks the selected provider and shows a readiness issue before the first send', async () => { mockAIAPI.getSettings.mockResolvedValue({ model: 'deepseek:deepseek-v4-pro', diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 9fc6831be..5f6b6eb97 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -172,6 +172,8 @@ const resolveRuntimeModelProvider = ( ): string => model.provider?.trim() || getProviderFromModelId(model.id); const findProviderReadinessAlternative = (args: { + avoidProviders?: string[]; + avoidModelIds?: string[]; configuredProviders?: string[]; models: RuntimeModelInfo[]; selectedModel: string; @@ -187,8 +189,10 @@ const findProviderReadinessAlternative = (args: { const configuredProviderOrder = new Map( configuredProviders.map((provider, index) => [provider, index]), ); + const avoidProviders = new Set((args.avoidProviders ?? []).map((provider) => provider.trim())); + const avoidModelIds = new Set((args.avoidModelIds ?? []).map((modelId) => modelId.trim())); - const candidates = args.models + const sortedCandidates = args.models .map((model) => { const provider = resolveRuntimeModelProvider(model).trim(); return { model, provider }; @@ -197,10 +201,13 @@ const findProviderReadinessAlternative = (args: { if (!provider || provider === selectedProvider || model.id === args.selectedModel) { return false; } + if (avoidProviders.has(provider) || avoidModelIds.has(model.id)) { + return false; + } if (configuredProviderOrder.size > 0 && !configuredProviderOrder.has(provider)) { return false; } - return normalizeComparableModelKey(model.id) === selectedKey; + return true; }) .sort((left, right) => { const leftProviderOrder = @@ -211,12 +218,18 @@ const findProviderReadinessAlternative = (args: { if (Boolean(left.model.notable) !== Boolean(right.model.notable)) { return right.model.notable ? 1 : -1; } + if (Boolean(left.model.is_default) !== Boolean(right.model.is_default)) { + return right.model.is_default ? 1 : -1; + } return formatAIModelRouteLabel(left.model).localeCompare( formatAIModelRouteLabel(right.model), ); }); - const candidate = candidates[0]; + const candidate = + sortedCandidates.find( + ({ model }) => normalizeComparableModelKey(model.id) === selectedKey, + ) || sortedCandidates[0]; if (!candidate) return null; return { @@ -743,10 +756,33 @@ export const AIChat: Component = (props) => { return match?.provider?.trim() || getProviderFromModelId(normalized); }; + const failedModelRouteHistory = createMemo(() => { + const modelIds = new Set(); + const providers = new Set(); + + for (const message of chat.messages()) { + const modelId = message.error && message.model?.trim(); + if (!modelId) continue; + modelIds.add(modelId); + const provider = providerForModelRoute(modelId); + if (provider) providers.add(provider); + } + + return { + modelIds: Array.from(modelIds), + providers: Array.from(providers), + }; + }); + const modelRouteAlternativeFor = (modelId: string): ModelRouteRecoveryOption | null => { const normalized = modelId.trim(); if (!normalized) return null; + const failedHistory = failedModelRouteHistory(); return findProviderReadinessAlternative({ + avoidModelIds: failedHistory.modelIds.filter((failedModelId) => failedModelId !== normalized), + avoidProviders: failedHistory.providers.filter( + (failedProvider) => failedProvider !== providerForModelRoute(normalized), + ), configuredProviders: aiRuntimeSettings()?.configured_providers, models: aiRuntimeModels(), selectedModel: normalized,