From c5bee4a7859506aa48fc0facd054e9e58c92be56 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 5 Jun 2026 12:50:02 +0100 Subject: [PATCH] Surface Assistant provider readiness --- .../v6/internal/subsystems/ai-runtime.md | 7 + .../v6/internal/subsystems/api-contracts.md | 17 +- frontend-modern/src/api/__tests__/ai.test.ts | 13 +- frontend-modern/src/api/ai.ts | 4 +- .../AI/Chat/__tests__/AIChat.test.tsx | 112 +++++++++++ .../src/components/AI/Chat/index.tsx | 180 ++++++++++++++++++ .../Settings/__tests__/AISettings.test.tsx | 2 +- .../__tests__/aiChatPresentation.test.ts | 30 +++ .../src/utils/aiChatPresentation.ts | 42 ++++ internal/ai/patrol_runtime_failure.go | 66 +++++++ internal/ai/patrol_runtime_failure_test.go | 35 ++++ internal/api/ai_handlers.go | 50 +++-- internal/api/ai_handlers_test.go | 63 +++++- 13 files changed, 595 insertions(+), 26 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 7a88952e5..bf35efcb5 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -70,6 +70,13 @@ runtime cost control, and shared AI transport surfaces. provider JSON bodies, request URLs, request methods, dashboard links, and key-management links must not be streamed or persisted as chat-visible assistant output. + Assistant provider readiness is part of that same request flow: when the + drawer opens or the selected chat model changes, the frontend must verify + the selected provider/model through `/api/ai/test/{provider}` before the + next user send. That check must use neutral provider diagnostic copy owned + by `internal/ai/`, not Patrol runtime-finding wording, and the drawer may + surface the result as actionable settings/retry status without converting + it into assistant-authored output or disabling model-owned chat by default. 4. Add or change Patrol, alert-analysis, or remediation transport through `internal/api/ai_handlers.go`, `internal/api/ai_intelligence_handlers.go`, and `frontend-modern/src/api/patrol.ts` Provider preflight diagnostics returned from `internal/api/ai_handlers.go` must reuse the Patrol runtime failure classifier in `internal/ai/` and diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index fad314c9a..9d0838cf8 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -444,12 +444,19 @@ payload shape change when the portal presents compact client rows. structured diagnostic envelope: `success`, `message`, optional `model`, `cause`, `summary`, `recommendation`, and `action`, plus `provider` on the provider-specific endpoint. + The provider-specific endpoint may accept an optional JSON request body + with `model` so Assistant can test the exact selected chat model; when that + field carries an explicit provider prefix it must match the route provider + instead of silently testing a different provider's model. Failure payloads must use the AI runtime's Patrol failure-cause vocabulary - and safe remediation text instead of returning raw upstream provider errors, - while still leaving those raw details available only to server logs or - redacted governed internal Patrol evidence. The frontend API client and - settings shell must treat this payload as the canonical provider health - contract rather than parsing free-form provider error strings. + and safe remediation text instead of returning raw upstream provider errors. + General `/api/ai/test*` payloads use neutral provider diagnostic copy for + Assistant and settings readiness; Patrol-specific wording is reserved for + Patrol preflight, Patrol findings, and Patrol run records. Raw details stay + available only to server logs or redacted governed internal Patrol + evidence. The frontend API client, settings shell, and Assistant drawer + must treat this payload as the canonical provider health contract rather + than parsing free-form provider error strings. 35. `internal/api/ai_intelligence_handlers.go` shared with `ai-runtime`: AI intelligence handlers are both an AI runtime control surface and a canonical API payload contract boundary. 36. `internal/api/config_setup_handlers.go` shared with `agent-lifecycle`: auto-register and setup handlers are both an agent lifecycle control surface and a canonical API payload contract boundary. That same shared boundary also owns reachable-host selection truth for canonical Proxmox registration: runtime callers may propose ordered `candidateHosts`, but the API contract must persist and echo the first candidate Pulse can actually reach instead of freezing the caller's rejected first preference into the stored node endpoint. diff --git a/frontend-modern/src/api/__tests__/ai.test.ts b/frontend-modern/src/api/__tests__/ai.test.ts index 80943d603..47def5638 100644 --- a/frontend-modern/src/api/__tests__/ai.test.ts +++ b/frontend-modern/src/api/__tests__/ai.test.ts @@ -212,6 +212,12 @@ describe('AIAPI', () => { method: 'POST', }); + await AIAPI.testProvider('deepseek', 'deepseek:deepseek-v4-pro'); + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/test/deepseek', { + method: 'POST', + body: JSON.stringify({ model: 'deepseek:deepseek-v4-pro' }), + }); + await AIAPI.getRemediationPlan('plan/1?x=1'); expect(apiFetchJSONMock).toHaveBeenCalledWith( '/api/ai/remediation/plan?plan_id=plan%2F1%3Fx%3D1', @@ -228,17 +234,16 @@ describe('AIAPI', () => { }); }); - it('returns provider preflight diagnostics without narrowing the API payload', async () => { + it('returns provider diagnostics without narrowing the API payload', async () => { const diagnostic = { success: false, message: 'Provider authentication issue', provider: 'openrouter', model: 'openrouter:deepseek/deepseek-r1', cause: 'provider_auth', - summary: - 'Pulse Patrol cannot analyze your infrastructure because the provider rejected the configured credentials or account access.', + summary: 'The provider rejected the configured credentials or account access.', recommendation: - 'Check the API key or provider authentication in Patrol provider settings, then rerun Patrol.', + 'Check the API key or provider authentication in Assistant and Patrol settings, then retry.', action: 'open_provider_settings', }; apiFetchJSONMock.mockResolvedValueOnce(diagnostic as any); diff --git a/frontend-modern/src/api/ai.ts b/frontend-modern/src/api/ai.ts index 667beee8a..aac27b636 100644 --- a/frontend-modern/src/api/ai.ts +++ b/frontend-modern/src/api/ai.ts @@ -50,9 +50,11 @@ export class AIAPI { } // Test a specific provider connection - static async testProvider(provider: string): Promise { + static async testProvider(provider: string, model?: string): Promise { + const body = model?.trim() ? JSON.stringify({ model: model.trim() }) : undefined; return apiFetchJSON(`${this.baseUrl}/ai/test/${encodeURIComponent(provider)}`, { method: 'POST', + ...(body ? { body } : {}), }) as Promise; } 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 6fd6205a6..9f5874f91 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -41,6 +41,12 @@ const { autonomous_mode: false, discovery_enabled: true, }), + testProvider: vi.fn().mockResolvedValue({ + success: true, + message: 'Connection successful', + provider: 'openai', + model: 'openai:gpt-4', + }), updateSettings: vi.fn().mockResolvedValue({ control_level: 'controlled' }), }; @@ -305,6 +311,12 @@ beforeEach(() => { autonomous_mode: false, discovery_enabled: true, }); + mockAIAPI.testProvider.mockResolvedValue({ + success: true, + message: 'Connection successful', + provider: 'openai', + model: 'openai:gpt-4', + }); mockAIChatAPI.getStatus.mockResolvedValue({ running: true }); mockAIChatAPI.listSessions.mockResolvedValue([]); Element.prototype.scrollIntoView = vi.fn(); @@ -349,6 +361,106 @@ describe('AIChat', () => { expect(screen.getByTestId('model-selector')).toBeInTheDocument(); }); + it('checks the selected provider and shows a readiness issue before the first send', async () => { + mockAIAPI.getSettings.mockResolvedValue({ + model: 'deepseek:deepseek-v4-pro', + chat_model: '', + control_level: 'read_only', + autonomous_mode: false, + discovery_enabled: true, + }); + mockAIAPI.testProvider.mockResolvedValueOnce({ + success: false, + message: 'Provider connection issue', + provider: 'deepseek', + model: 'deepseek:deepseek-v4-pro', + cause: 'provider_connection', + summary: 'Pulse could not maintain a healthy connection to this provider.', + recommendation: 'Check provider reachability, base URL, firewall or proxy rules.', + action: 'open_provider_settings', + }); + + renderChat(); + + await waitFor(() => { + expect(mockAIAPI.testProvider).toHaveBeenCalledWith('deepseek', 'deepseek:deepseek-v4-pro'); + expect(screen.getByLabelText('Assistant provider status')).toHaveTextContent( + 'DeepSeek provider issue', + ); + }); + expect(screen.getByLabelText('Assistant provider status')).toHaveTextContent( + 'Pulse could not maintain a healthy connection to this provider.', + ); + expect(screen.getByRole('link', { name: /Open settings/ })).toHaveAttribute( + 'href', + '/settings/system-ai', + ); + }); + + it('rechecks provider readiness from the drawer status banner', async () => { + mockAIAPI.getSettings.mockResolvedValue({ + model: 'deepseek:deepseek-v4-pro', + chat_model: '', + control_level: 'read_only', + autonomous_mode: false, + discovery_enabled: true, + }); + mockAIAPI.testProvider + .mockResolvedValueOnce({ + success: false, + message: 'Provider connection issue', + provider: 'deepseek', + model: 'deepseek:deepseek-v4-pro', + cause: 'provider_connection', + summary: 'Pulse could not maintain a healthy connection to this provider.', + recommendation: 'Check provider reachability.', + action: 'open_provider_settings', + }) + .mockResolvedValueOnce({ + success: true, + message: 'Connection successful', + provider: 'deepseek', + model: 'deepseek:deepseek-v4-pro', + }); + + renderChat(); + + await waitFor(() => { + expect(screen.getByText('DeepSeek provider issue')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Retry provider check' })); + + await waitFor(() => { + expect(mockAIAPI.testProvider).toHaveBeenCalledTimes(2); + expect(screen.queryByLabelText('Assistant provider status')).not.toBeInTheDocument(); + }); + }); + + it('checks the explicitly selected chat model provider instead of the default provider', async () => { + mockChat.model.mockReturnValue('openrouter:anthropic/claude-sonnet-4.5'); + mockAIAPI.getSettings.mockResolvedValue({ + model: 'deepseek:deepseek-v4-pro', + chat_model: '', + control_level: 'read_only', + autonomous_mode: false, + discovery_enabled: true, + }); + + renderChat(); + + await waitFor(() => { + expect(mockAIAPI.testProvider).toHaveBeenCalledWith( + 'openrouter', + 'openrouter:anthropic/claude-sonnet-4.5', + ); + }); + expect(mockAIAPI.testProvider).not.toHaveBeenCalledWith( + 'deepseek', + 'deepseek:deepseek-v4-pro', + ); + }); + it('renders the compact composer send control', () => { renderChat(); expect(screen.getByRole('button', { name: 'Send message' })).toBeInTheDocument(); diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index a1ed5fb52..dc93a4955 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -11,6 +11,8 @@ import { import { unwrap } from 'solid-js/store'; import SendIcon from 'lucide-solid/icons/send'; import SquareIcon from 'lucide-solid/icons/square'; +import RefreshCwIcon from 'lucide-solid/icons/refresh-cw'; +import SettingsIcon from 'lucide-solid/icons/settings'; import XIcon from 'lucide-solid/icons/x'; import { AIAPI } from '@/api/ai'; import { AIChatAPI, type ChatSession, type ChatSessionHandoffSummary } from '@/api/aiChat'; @@ -35,15 +37,23 @@ import { AI_CHAT_NEW_SESSION_BUTTON_TITLE, AI_CHAT_NEW_SESSION_MENU_LABEL, AI_CHAT_NEW_SESSION_SHORT_LABEL, + AI_CHAT_PROVIDER_READINESS_RETRY_LABEL, + AI_CHAT_PROVIDER_READINESS_SETTINGS_HREF, + AI_CHAT_PROVIDER_READINESS_SETTINGS_LABEL, AI_CHAT_SESSION_MENU_TITLE, AI_CHAT_SESSION_EMPTY_STATE, getAIChatEmptyStatePresentation, + getAIChatProviderReadinessPresentation, } from '@/utils/aiChatPresentation'; import { getAIChatControlLevelPresentation, normalizeAIControlLevel, type AIControlLevel, } from '@/utils/aiControlLevelPresentation'; +import { + getAIProviderDisplayName, + getProviderFromModelId, +} from '@/utils/aiProviderPresentation'; import { getCachedUnifiedResources } from '@/hooks/useUnifiedResources'; import type { Resource } from '@/types/resource'; import { isAppContainerDiscoveryResourceType } from '@/utils/discoveryTarget'; @@ -71,6 +81,18 @@ const AI_CHAT_MIN_DOCKED_VIEWPORT_WIDTH = 1200; const STRUCTURED_PATROL_CONTEXT_TARGETS = new Set(['patrol-configuration', 'patrol-run']); const STRUCTURED_RESOURCE_CONTEXT_HANDOFF_KINDS = new Set(['resource_context']); +type ChatProviderReadinessStatus = 'idle' | 'checking' | 'ready' | 'error'; + +interface ChatProviderReadinessState { + status: ChatProviderReadinessStatus; + provider: string; + model?: string; + message?: string; + summary?: string; + recommendation?: string; + action?: string; +} + interface AIChatProps { onClose: () => void; } @@ -299,6 +321,11 @@ export const AIChat: Component = (props) => { let sessionButtonRef: HTMLButtonElement | undefined; const [defaultModel, setDefaultModel] = createSignal(''); const [chatOverrideModel, setChatOverrideModel] = createSignal(''); + const [providerReadiness, setProviderReadiness] = createSignal({ + status: 'idle', + provider: '', + }); + const [providerReadinessRetryNonce, setProviderReadinessRetryNonce] = createSignal(0); const [controlLevel, setControlLevel] = createSignal('read_only'); const [showControlMenu, setShowControlMenu] = createSignal(false); const [controlSaving, setControlSaving] = createSignal(false); @@ -404,6 +431,76 @@ export const AIChat: Component = (props) => { return match ? match.name || match.id.split(':').pop() || match.id : override; }); + const selectedChatModel = createMemo(() => { + const selected = chat.model().trim(); + return selected || defaultModel().trim(); + }); + + const selectedChatProvider = createMemo(() => { + const model = selectedChatModel(); + if (!model) return ''; + const match = aiRuntimeModels().find((candidate) => candidate.id === model); + return match?.provider?.trim() || getProviderFromModelId(model); + }); + + const providerReadinessPresentation = createMemo(() => { + const readiness = providerReadiness(); + if (!readiness.provider || readiness.status === 'idle' || readiness.status === 'ready') { + return null; + } + return getAIChatProviderReadinessPresentation({ + status: readiness.status === 'checking' ? 'checking' : 'error', + providerLabel: getAIProviderDisplayName(readiness.provider), + message: readiness.message, + summary: readiness.summary, + recommendation: readiness.recommendation, + }); + }); + + let providerReadinessRequestId = 0; + let lastProviderReadinessKey = ''; + + const refreshSelectedProviderReadiness = async (provider: string, model: string) => { + const requestId = ++providerReadinessRequestId; + setProviderReadiness({ + status: 'checking', + provider, + model, + }); + + try { + const result = await AIAPI.testProvider(provider, model); + if (requestId !== providerReadinessRequestId) return; + setProviderReadiness({ + status: result.success ? 'ready' : 'error', + provider: result.provider || provider, + model: result.model, + message: result.message, + summary: result.summary, + recommendation: result.recommendation, + action: result.action, + }); + } catch (error) { + if (requestId !== providerReadinessRequestId) return; + logger.error('[AIChat] Failed to check selected provider readiness:', error); + setProviderReadiness({ + status: 'error', + provider, + model, + message: 'Provider check failed', + summary: 'Pulse could not verify the selected provider before this chat sends work.', + recommendation: + 'Check provider settings and network reachability, then retry the provider check.', + action: 'open_provider_settings', + }); + } + }; + + const retrySelectedProviderReadiness = () => { + setProviderReadinessRetryNonce((value) => value + 1); + focusComposer(); + }; + const isOverlayLayout = createMemo(() => width() < AI_CHAT_MIN_DOCKED_VIEWPORT_WIDTH); const rootClassName = createMemo(() => { if (isOverlayLayout()) { @@ -636,6 +733,35 @@ export const AIChat: Component = (props) => { void initializeWhenOpen(); }); + createEffect(() => { + const open = isOpen(); + const model = selectedChatModel().trim(); + const provider = selectedChatProvider().trim(); + const retryNonce = providerReadinessRetryNonce(); + + if (!open) { + providerReadinessRequestId += 1; + lastProviderReadinessKey = ''; + setProviderReadiness({ status: 'idle', provider: '' }); + return; + } + + if (!provider || !model) { + providerReadinessRequestId += 1; + lastProviderReadinessKey = ''; + setProviderReadiness({ status: 'idle', provider: '' }); + return; + } + + const key = `${provider}:${model}:${retryNonce}`; + if (key === lastProviderReadinessKey) { + return; + } + + lastProviderReadinessKey = key; + void refreshSelectedProviderReadiness(provider, model); + }); + // Click outside handler to close all dropdowns onMount(() => { aiChatStore.registerInput?.(textareaRef ?? null); @@ -1508,6 +1634,60 @@ export const AIChat: Component = (props) => { + + {(presentation) => ( +
+
+
+ +
+
{presentation().title}
+
{presentation().body}
+ + {(recommendation) => ( +
{recommendation()}
+ )} +
+
+
+ +
+ + + + {AI_CHAT_PROVIDER_READINESS_SETTINGS_LABEL} + +
+
+
+
+ )} +
+ {/* Discovery hint - show when discovery is disabled */}
diff --git a/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx b/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx index e8e4626e6..2d111274c 100644 --- a/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx @@ -759,7 +759,7 @@ describe('AISettings provider save failure context', () => { provider === 'openrouter' ? 'Provider authentication issue' : `${provider} reachable`, recommendation: provider === 'openrouter' - ? 'Check the API key or provider authentication in Patrol provider settings, then rerun Patrol.' + ? 'Check the API key or provider authentication in Assistant and Patrol settings, then retry.' : undefined, cause: provider === 'openrouter' ? 'provider_auth' : undefined, provider, diff --git a/frontend-modern/src/utils/__tests__/aiChatPresentation.test.ts b/frontend-modern/src/utils/__tests__/aiChatPresentation.test.ts index d03e3d232..8a8932c1a 100644 --- a/frontend-modern/src/utils/__tests__/aiChatPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/aiChatPresentation.test.ts @@ -12,9 +12,13 @@ import { AI_CHAT_MODEL_SELECTOR_EMPTY_STATE, AI_CHAT_NEW_SESSION_MENU_LABEL, AI_CHAT_NEW_SESSION_SHORT_LABEL, + AI_CHAT_PROVIDER_READINESS_RETRY_LABEL, + AI_CHAT_PROVIDER_READINESS_SETTINGS_HREF, + AI_CHAT_PROVIDER_READINESS_SETTINGS_LABEL, AI_CHAT_QUESTION_CARD_TITLE, AI_CHAT_SESSION_EMPTY_STATE, AI_CHAT_SESSION_MENU_TITLE, + getAIChatProviderReadinessPresentation, getAIChatLauncherTitle, getAIChatEmptyStatePresentation, } from '@/utils/aiChatPresentation'; @@ -42,6 +46,9 @@ describe('aiChatPresentation', () => { expect(AI_CHAT_QUESTION_CARD_TITLE).toBe('Pulse Assistant needs your input'); expect(AI_CHAT_ASSISTANT_MESSAGE_LABEL).toBe('Pulse Assistant'); expect(AI_CHAT_CONTEXT_USED_LABEL).toBe('Context used'); + expect(AI_CHAT_PROVIDER_READINESS_SETTINGS_HREF).toBe('/settings/system-ai'); + expect(AI_CHAT_PROVIDER_READINESS_SETTINGS_LABEL).toBe('Open settings'); + expect(AI_CHAT_PROVIDER_READINESS_RETRY_LABEL).toBe('Retry'); }); it('builds canonical launcher titles without implying a keyboard shortcut', () => { @@ -65,4 +72,27 @@ describe('aiChatPresentation', () => { subtitle: 'Pulse Patrol · Patrol assessment attached · Coverage incomplete', }); }); + + it('builds neutral provider readiness presentation copy for Assistant checks', () => { + expect( + getAIChatProviderReadinessPresentation({ + status: 'error', + providerLabel: 'DeepSeek', + summary: 'Pulse could not maintain a healthy connection to this provider.', + recommendation: 'Check provider reachability.', + }), + ).toEqual({ + tone: 'error', + title: 'DeepSeek provider issue', + body: 'Pulse could not maintain a healthy connection to this provider.', + recommendation: 'Check provider reachability.', + }); + + expect( + getAIChatProviderReadinessPresentation({ + status: 'checking', + providerLabel: 'OpenRouter', + }).title, + ).toBe('Checking OpenRouter provider'); + }); }); diff --git a/frontend-modern/src/utils/aiChatPresentation.ts b/frontend-modern/src/utils/aiChatPresentation.ts index 9a91bae69..72a5c434b 100644 --- a/frontend-modern/src/utils/aiChatPresentation.ts +++ b/frontend-modern/src/utils/aiChatPresentation.ts @@ -20,6 +20,9 @@ export const AI_CHAT_QUESTION_CARD_TITLE = 'Pulse Assistant needs your input'; export const AI_CHAT_QUESTION_CARD_PLACEHOLDER = 'Type your answer...'; export const AI_CHAT_ASSISTANT_MESSAGE_LABEL = 'Pulse Assistant'; export const AI_CHAT_CONTEXT_USED_LABEL = 'Context used'; +export const AI_CHAT_PROVIDER_READINESS_SETTINGS_HREF = '/settings/system-ai'; +export const AI_CHAT_PROVIDER_READINESS_SETTINGS_LABEL = 'Open settings'; +export const AI_CHAT_PROVIDER_READINESS_RETRY_LABEL = 'Retry'; export interface AIChatEmptyStateBriefingInput { sourceLabel?: string; @@ -32,6 +35,15 @@ export interface AIChatEmptyStatePresentation { title: string; } +export type AIChatProviderReadinessStatus = 'checking' | 'error'; + +export interface AIChatProviderReadinessPresentation { + body: string; + recommendation?: string; + title: string; + tone: AIChatProviderReadinessStatus; +} + export function getAIChatLauncherTitle(contextName?: unknown) { if (typeof contextName === 'string' && contextName.trim().length > 0) { return `Open Pulse Assistant for ${contextName}`; @@ -61,3 +73,33 @@ export function getAIChatEmptyStatePresentation(args: { subtitle: AI_CHAT_EMPTY_STATE_SUBTITLE, }; } + +export function getAIChatProviderReadinessPresentation(args: { + message?: string; + providerLabel?: string; + recommendation?: string; + status: AIChatProviderReadinessStatus; + summary?: string; +}): AIChatProviderReadinessPresentation { + const providerLabel = args.providerLabel?.trim() || 'Selected'; + if (args.status === 'checking') { + return { + tone: 'checking', + title: `Checking ${providerLabel} provider`, + body: 'Pulse is verifying the selected provider before this chat sends work.', + }; + } + + const body = + args.summary?.trim() || + args.message?.trim() || + 'Pulse could not verify the selected provider before this chat sends work.'; + const recommendation = args.recommendation?.trim() || undefined; + + return { + tone: 'error', + title: `${providerLabel} provider issue`, + body, + recommendation, + }; +} diff --git a/internal/ai/patrol_runtime_failure.go b/internal/ai/patrol_runtime_failure.go index 987bc55b9..dbc2ac1df 100644 --- a/internal/ai/patrol_runtime_failure.go +++ b/internal/ai/patrol_runtime_failure.go @@ -126,6 +126,72 @@ func ClassifyPatrolRuntimeFailure(err error) PatrolRuntimeFailureDiagnostic { } } +func ClassifyProviderConnectionFailure(err error) PatrolRuntimeFailureDiagnostic { + failure := patrolRuntimeFailureFromError(err) + diagnostic := PatrolRuntimeFailureDiagnostic{ + Title: "Provider connection issue", + Summary: "Provider connection issue", + Cause: failure.Cause, + Description: "Pulse could not maintain a healthy connection to this provider.", + Recommendation: "Check provider reachability, base URL, firewall or proxy rules, and provider availability, then retry.", + } + + switch failure.Cause { + case PatrolFailureCauseMalformedToolHistory: + diagnostic.Title = "Provider conversation state issue" + diagnostic.Summary = "Provider conversation state issue" + diagnostic.Description = "The provider rejected the conversation structure used by Pulse." + diagnostic.Recommendation = "Start a new assistant session and retry. If the issue persists, restart Pulse and report the selected provider and model." + case PatrolFailureCauseToolChoiceRejected: + diagnostic.Title = "Provider rejected tool-choice request" + diagnostic.Summary = "Provider rejected tool-choice request" + diagnostic.Description = "Pulse reached the provider, but the provider rejected a tool-choice transport setting." + diagnostic.Recommendation = "Retry with automatic tool selection, or switch to a provider route with reliable tool-call support." + case PatrolFailureCauseNoToolCapableEndpoint: + diagnostic.Title = "No tool-capable provider endpoint available" + diagnostic.Summary = "No tool-capable provider endpoint available" + diagnostic.Description = "Pulse reached the provider, but the provider reports no available endpoint with tool support for the selected model." + diagnostic.Recommendation = "Review provider routing and privacy filters, broaden allowed providers, or switch to a model with broader tool support." + case PatrolFailureCauseModelUnsupportedTools: + diagnostic.Title = "Selected model does not support tools" + diagnostic.Summary = "Selected model does not support tools" + diagnostic.Description = "Pulse reached the provider, but the selected model or routed endpoint rejected tool calling." + diagnostic.Recommendation = "Choose a model or provider route that supports tool calling for governed Assistant and Patrol workflows." + case PatrolFailureCauseModelUnavailable: + diagnostic.Title = "Selected model unavailable" + diagnostic.Summary = "Selected model unavailable" + diagnostic.Description = "The selected model is not available from this provider path." + diagnostic.Recommendation = "Choose one of the models currently returned by the provider, then retry." + case PatrolFailureCauseContextWindowTooSmall: + diagnostic.Title = "Selected model context window too small" + diagnostic.Summary = "Selected model context window too small" + diagnostic.Description = "The provider rejected the request because the selected model could not fit the current context." + diagnostic.Recommendation = "Choose a model with a larger context window or retry with a narrower request." + case PatrolFailureCauseProviderBilling: + diagnostic.Title = "Provider billing or quota issue" + diagnostic.Summary = "Provider billing or quota issue" + diagnostic.Description = "The provider rejected the request for billing or quota reasons." + diagnostic.Recommendation = "Resolve the billing or quota issue with your provider, or switch to a different provider or model." + case PatrolFailureCauseProviderRateLimited: + diagnostic.Title = "Provider rate limited" + diagnostic.Summary = "Provider rate limited" + diagnostic.Description = "The provider is rate limiting requests for this account or model." + diagnostic.Recommendation = "Wait for the provider rate limit to reset, increase provider limits, or switch to another model." + case PatrolFailureCauseProviderAuth: + diagnostic.Title = "Provider authentication issue" + diagnostic.Summary = "Provider authentication issue" + diagnostic.Description = "The provider rejected the configured credentials or account access." + diagnostic.Recommendation = "Check the API key or provider authentication in Assistant and Patrol settings, then retry." + case PatrolFailureCauseProviderNotConfigured, PatrolFailureCauseModelNotSelected, PatrolFailureCauseModelProviderUnconfigured, PatrolFailureCauseAssistantDisabled, PatrolFailureCauseSettingsPersistence: + diagnostic.Title = "Provider not ready" + diagnostic.Summary = "Provider not ready" + diagnostic.Description = "Pulse cannot test this provider because the provider runtime is not ready." + diagnostic.Recommendation = "Open Assistant and Patrol provider settings, complete provider configuration, verify the selected model, and retry." + } + + return diagnostic +} + func patrolRuntimeFailureFromError(err error) patrolRuntimeFailure { raw := "" if err != nil { diff --git a/internal/ai/patrol_runtime_failure_test.go b/internal/ai/patrol_runtime_failure_test.go index 0b5a3b0f8..87ecdd249 100644 --- a/internal/ai/patrol_runtime_failure_test.go +++ b/internal/ai/patrol_runtime_failure_test.go @@ -194,6 +194,41 @@ func TestClassifyPatrolRuntimeFailureOmitsRawProviderEvidence(t *testing.T) { } } +func TestClassifyProviderConnectionFailureUsesNeutralProviderCopy(t *testing.T) { + diagnostic := ClassifyProviderConnectionFailure(errors.New(`Ollama returned status 500: raw upstream body`)) + + if diagnostic.Summary != "Provider connection issue" { + t.Fatalf("unexpected summary %q", diagnostic.Summary) + } + if diagnostic.Cause != PatrolFailureCauseProviderConnection { + t.Fatalf("unexpected cause %q", diagnostic.Cause) + } + for _, field := range []string{diagnostic.Title, diagnostic.Description, diagnostic.Recommendation} { + if strings.Contains(field, "Patrol") { + t.Fatalf("provider diagnostic leaked Patrol copy: %q", field) + } + } + if !strings.Contains(diagnostic.Description, "this provider") { + t.Fatalf("expected provider-specific description, got %q", diagnostic.Description) + } +} + +func TestClassifyProviderConnectionFailureKeepsSafeModelUnavailableCopy(t *testing.T) { + diagnostic := ClassifyProviderConnectionFailure(errors.New(`connected to Ollama but model "qwen3.5:2b" is not available; found: qwen3.5:4b`)) + + if diagnostic.Summary != "Selected model unavailable" { + t.Fatalf("unexpected summary %q", diagnostic.Summary) + } + if diagnostic.Cause != PatrolFailureCauseModelUnavailable { + t.Fatalf("unexpected cause %q", diagnostic.Cause) + } + for _, field := range []string{diagnostic.Description, diagnostic.Recommendation} { + if strings.Contains(field, "Patrol") || strings.Contains(field, "qwen3.5") { + t.Fatalf("provider diagnostic leaked scoped or raw detail: %q", field) + } + } +} + func TestPatrolRuntimeFailureFromErrorRedactsSecretLikeDetail(t *testing.T) { failure := patrolRuntimeFailureFromError(errors.New(`request failed: Get "https://generativelanguage.googleapis.com/v1beta/models?key=AIzaSy-secret-token": Authorization: Bearer sk-live-secret {"api_key":"sk-json-secret"} https://user:pass@example.test/v1`)) diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index 0da6f8758..86de59ce2 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -3044,7 +3044,7 @@ func (h *AISettingsHandler) HandleTestAIConnection(w http.ResponseWriter, r *htt cfg := h.GetAIService(r.Context()).GetConfig() err := h.GetAIService(r.Context()).TestConnection(ctx) if err != nil { - diagnostic := ai.ClassifyPatrolRuntimeFailure(err) + diagnostic := ai.ClassifyProviderConnectionFailure(err) testResult.Success = false testResult.Message = diagnostic.Summary testResult.Cause = string(diagnostic.Cause) @@ -3079,6 +3079,10 @@ type aiProviderTestResponse struct { Action string `json:"action,omitempty"` } +type aiProviderTestRequest struct { + Model string `json:"model,omitempty"` +} + func newAIProviderTestResponse(provider string) aiProviderTestResponse { return aiProviderTestResponse{Provider: provider} } @@ -3089,14 +3093,14 @@ func newAIProviderTestNotConfiguredResponse(provider string) aiProviderTestRespo Message: "Provider not configured", Provider: provider, Cause: string(ai.PatrolFailureCauseProviderNotConfigured), - Summary: "Pulse Patrol cannot test this provider because it is not configured for the current Assistant & Patrol settings.", - Recommendation: "Open Assistant & Patrol provider settings, configure the provider credentials or base URL, choose a Patrol model, and run provider preflight again.", + Summary: "Pulse cannot test this provider because it is not configured for the current Assistant and Patrol settings.", + Recommendation: "Open Assistant and Patrol provider settings, configure the provider credentials or base URL, choose a model, and retry.", Action: "open_provider_settings", } } func newAIProviderTestFailureResponse(provider, model string, err error) aiProviderTestResponse { - diagnostic := ai.ClassifyPatrolRuntimeFailure(err) + diagnostic := ai.ClassifyProviderConnectionFailure(err) return aiProviderTestResponse{ Success: false, Message: diagnostic.Summary, @@ -3139,6 +3143,26 @@ func (h *AISettingsHandler) HandleTestProvider(w http.ResponseWriter, r *http.Re return } + var body aiProviderTestRequest + if r.ContentLength > 0 { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, `{"error":"Invalid JSON body"}`, http.StatusBadRequest) + return + } + } + requestedModel := config.NormalizeQuickstartModelString(body.Model) + if len(requestedModel) > 256 { + http.Error(w, `{"error":"Model id too long"}`, http.StatusBadRequest) + return + } + if requestedModel != "" && strings.Contains(requestedModel, ":") { + modelProvider, _ := config.ParseModelString(requestedModel) + if modelProvider != provider { + http.Error(w, `{"error":"Model provider does not match test provider"}`, http.StatusBadRequest) + return + } + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() @@ -3165,14 +3189,18 @@ func (h *AISettingsHandler) HandleTestProvider(w http.ResponseWriter, r *http.Re } // Create provider and test connection - model, err := ai.ResolvePreferredModelForProvider(ctx, cfg, provider) - if err != nil { - testResult = newAIProviderTestFailureResponse(provider, "", err) - log.Error().Err(err).Str("provider", provider).Msg("AI provider model resolution failed") - if err := utils.WriteJSONResponse(w, testResult); err != nil { - log.Error().Err(err).Msg("failed to write provider test response") + model := requestedModel + if model == "" { + var err error + model, err = ai.ResolvePreferredModelForProvider(ctx, cfg, provider) + if err != nil { + testResult = newAIProviderTestFailureResponse(provider, "", err) + log.Error().Err(err).Str("provider", provider).Msg("AI provider model resolution failed") + if err := utils.WriteJSONResponse(w, testResult); err != nil { + log.Error().Err(err).Msg("failed to write provider test response") + } + return } - return } testProvider, err := providers.NewForProvider(cfg, provider, model) diff --git a/internal/api/ai_handlers_test.go b/internal/api/ai_handlers_test.go index 64453153a..6800fed2b 100644 --- a/internal/api/ai_handlers_test.go +++ b/internal/api/ai_handlers_test.go @@ -1384,7 +1384,7 @@ func TestAISettingsHandler_TestConnection_Failure(t *testing.T) { assert.Equal(t, "Provider connection issue", resp.Message) assert.Equal(t, "ollama:llama3", resp.Model) assert.Equal(t, string(ai.PatrolFailureCauseProviderConnection), resp.Cause) - assert.Contains(t, resp.Summary, "healthy connection") + assert.Contains(t, resp.Summary, "healthy connection to this provider") assert.Contains(t, resp.Recommendation, "Check provider reachability") assert.Equal(t, "open_provider_settings", resp.Action) assert.NotContains(t, rec.Body.String(), "Ollama returned status 500") @@ -1560,7 +1560,7 @@ func TestAISettingsHandler_TestProvider_ConnectionFailure(t *testing.T) { assert.Equal(t, "ollama", resp.Provider) assert.Equal(t, "ollama:llama3", resp.Model) assert.Equal(t, string(ai.PatrolFailureCauseProviderConnection), resp.Cause) - assert.Contains(t, resp.Summary, "healthy connection") + assert.Contains(t, resp.Summary, "healthy connection to this provider") assert.Contains(t, resp.Recommendation, "Check provider reachability") assert.Equal(t, "open_provider_settings", resp.Action) assert.NotContains(t, rec.Body.String(), "Ollama returned status 500") @@ -1667,12 +1667,67 @@ func TestAISettingsHandler_TestProvider_ModelUnavailableUsesSafeDiagnostic(t *te assert.Equal(t, "ollama", resp.Provider) assert.Equal(t, "ollama:missing:latest", resp.Model) assert.Equal(t, string(ai.PatrolFailureCauseModelUnavailable), resp.Cause) - assert.Contains(t, resp.Summary, "configured Patrol model") - assert.Contains(t, resp.Recommendation, "choose one of the models") + assert.Contains(t, resp.Summary, "selected model is not available") + assert.Contains(t, resp.Recommendation, "Choose one of the models") assert.Equal(t, "open_provider_settings", resp.Action) assert.NotContains(t, rec.Body.String(), "found: llama3:latest") } +func TestAISettingsHandler_TestProvider_UsesRequestedModelOverride(t *testing.T) { + t.Parallel() + + ollama := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/version": + _ = json.NewEncoder(w).Encode(map[string]any{"version": "0.1.0"}) + case "/api/tags": + _ = json.NewEncoder(w).Encode(map[string]any{"models": []map[string]any{{"name": "llama3:latest"}}}) + default: + http.NotFound(w, r) + } + })) + defer ollama.Close() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + + aiCfg := config.NewDefaultAIConfig() + aiCfg.Enabled = true + aiCfg.Model = "ollama:llama3" + aiCfg.OllamaBaseURL = ollama.URL + if err := persistence.SaveAIConfig(*aiCfg); err != nil { + t.Fatalf("SaveAIConfig: %v", err) + } + + handler := newTestAISettingsHandler(cfg, persistence, nil) + + req := newLoopbackRequest( + http.MethodPost, + "/api/ai/test/ollama", + strings.NewReader(`{"model":"ollama:missing:latest"}`), + ) + rec := httptest.NewRecorder() + handler.HandleTestProvider(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + + var resp struct { + Success bool `json:"success"` + Message string `json:"message"` + Provider string `json:"provider"` + Model string `json:"model"` + Cause string `json:"cause"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.False(t, resp.Success) + assert.Equal(t, "Selected model unavailable", resp.Message) + assert.Equal(t, "ollama", resp.Provider) + assert.Equal(t, "ollama:missing:latest", resp.Model) + assert.Equal(t, string(ai.PatrolFailureCauseModelUnavailable), resp.Cause) + assert.NotContains(t, rec.Body.String(), "found: llama3:latest") +} + // ======================================== // HandleGetAICostSummary tests // ========================================