Surface Assistant provider readiness

This commit is contained in:
rcourtman 2026-06-05 12:50:02 +01:00
parent 6ba21c7a2b
commit c5bee4a785
13 changed files with 595 additions and 26 deletions

View file

@ -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

View file

@ -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.

View file

@ -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);

View file

@ -50,9 +50,11 @@ export class AIAPI {
}
// Test a specific provider connection
static async testProvider(provider: string): Promise<AIProviderTestResult> {
static async testProvider(provider: string, model?: string): Promise<AIProviderTestResult> {
const body = model?.trim() ? JSON.stringify({ model: model.trim() }) : undefined;
return apiFetchJSON(`${this.baseUrl}/ai/test/${encodeURIComponent(provider)}`, {
method: 'POST',
...(body ? { body } : {}),
}) as Promise<AIProviderTestResult>;
}

View file

@ -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();

View file

@ -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<AIChatProps> = (props) => {
let sessionButtonRef: HTMLButtonElement | undefined;
const [defaultModel, setDefaultModel] = createSignal('');
const [chatOverrideModel, setChatOverrideModel] = createSignal('');
const [providerReadiness, setProviderReadiness] = createSignal<ChatProviderReadinessState>({
status: 'idle',
provider: '',
});
const [providerReadinessRetryNonce, setProviderReadinessRetryNonce] = createSignal(0);
const [controlLevel, setControlLevel] = createSignal<AIControlLevel>('read_only');
const [showControlMenu, setShowControlMenu] = createSignal(false);
const [controlSaving, setControlSaving] = createSignal(false);
@ -404,6 +431,76 @@ export const AIChat: Component<AIChatProps> = (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<AIChatProps> = (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<AIChatProps> = (props) => {
</div>
</Show>
<Show when={providerReadinessPresentation()}>
{(presentation) => (
<section
class={`border-b px-4 py-2.5 text-[11px] ${
presentation().tone === 'checking'
? 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-200'
: 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100'
}`}
aria-label="Assistant provider status"
>
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div class="flex min-w-0 items-start gap-2.5">
<span
class={`mt-1 h-2 w-2 flex-shrink-0 rounded-full ${
presentation().tone === 'checking'
? 'bg-blue-500 dark:bg-blue-300'
: 'bg-amber-500 dark:bg-amber-300'
}`}
/>
<div class="min-w-0">
<div class="font-semibold text-base-content">{presentation().title}</div>
<div class="mt-0.5 leading-5">{presentation().body}</div>
<Show when={presentation().recommendation}>
{(recommendation) => (
<div class="mt-0.5 leading-5">{recommendation()}</div>
)}
</Show>
</div>
</div>
<Show when={providerReadiness().status === 'error'}>
<div class="flex flex-shrink-0 items-center gap-2 sm:justify-end">
<button
type="button"
onClick={retrySelectedProviderReadiness}
class="inline-flex items-center gap-1.5 rounded-md border border-current/20 bg-surface px-2 py-1 text-[10px] font-medium text-base-content hover:bg-surface-hover"
aria-label="Retry provider check"
>
<RefreshCwIcon class="h-3.5 w-3.5" />
<span>{AI_CHAT_PROVIDER_READINESS_RETRY_LABEL}</span>
</button>
<a
href={AI_CHAT_PROVIDER_READINESS_SETTINGS_HREF}
class="inline-flex items-center gap-1.5 rounded-md border border-current/20 bg-surface px-2 py-1 text-[10px] font-medium text-base-content hover:bg-surface-hover"
>
<SettingsIcon class="h-3.5 w-3.5" />
<span>{AI_CHAT_PROVIDER_READINESS_SETTINGS_LABEL}</span>
</a>
</div>
</Show>
</div>
</section>
)}
</Show>
{/* Discovery hint - show when discovery is disabled */}
<Show when={discoveryEnabled() === false && !discoveryHintDismissed()}>
<div class="px-4 py-2 border-b border-cyan-200 dark:border-cyan-800 bg-cyan-50 dark:bg-cyan-900 flex items-center justify-between gap-3 text-[11px] text-cyan-700 dark:text-cyan-200">

View file

@ -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,

View file

@ -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');
});
});

View file

@ -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,
};
}

View file

@ -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 {

View file

@ -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`))

View file

@ -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)

View file

@ -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
// ========================================