Add Assistant fallback route recovery

This commit is contained in:
rcourtman
2026-06-05 17:53:04 +01:00
parent 5462cefa19
commit ff5eb8ac84
3 changed files with 117 additions and 6 deletions
@@ -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
@@ -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',
@@ -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<AIChatProps> = (props) => {
return match?.provider?.trim() || getProviderFromModelId(normalized);
};
const failedModelRouteHistory = createMemo(() => {
const modelIds = new Set<string>();
const providers = new Set<string>();
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,