mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-21 18:53:37 +00:00
Add Assistant fallback route recovery
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user