Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions app/src/components/settings/panels/AIPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
import { ConfirmationModal } from '../../intelligence/ConfirmationModal';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import { presentProviderSetupError, ProviderSetupErrorNotice } from './ProviderSetupErrorNotice';
import { useReembedBackfillModal } from './useReembedBackfillModal';

// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -577,7 +578,13 @@ const ProviderKeyDialog = ({
try {
await onSubmit(trimmed);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
const message = err instanceof Error ? err.message : String(err);
console.warn('[ai-settings] provider setup failed', {
slug,
local_runtime: isLocalRuntime,
summary: presentProviderSetupError(message).summary,
});
setError(message);
setPhase('idle');
}
};
Expand Down Expand Up @@ -619,9 +626,7 @@ const ProviderKeyDialog = ({
}}
className={`rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:opacity-60 ${isLocalRuntime ? 'font-mono' : ''}`}
/>
{error ? (
<p className="text-xs font-medium text-red-600 dark:text-red-300">{error}</p>
) : null}
{error ? <ProviderSetupErrorNotice error={error} /> : null}
</div>

<div className="mt-6 flex justify-end gap-2">
Expand Down Expand Up @@ -2617,11 +2622,7 @@ const CloudProviderEditor = ({
/>
</div>
)}
{submitError && (
<div className="rounded-md border border-red-200 dark:border-red-500/30 bg-red-50 dark:bg-red-500/10 px-3 py-2 text-xs text-red-700 dark:text-red-300 break-words">
{submitError}
</div>
)}
{submitError ? <ProviderSetupErrorNotice error={submitError} /> : null}
</div>
<div className="flex items-center justify-end gap-2 border-t border-stone-200 dark:border-neutral-800 px-4 py-3">
<button
Expand Down Expand Up @@ -2650,7 +2651,12 @@ const CloudProviderEditor = ({
// Caller throws when the live /models probe rejects — surface
// the failure inline and keep the dialog open so the user can
// fix the key/URL and retry.
setSubmitError(err instanceof Error ? err.message : String(err));
const message = err instanceof Error ? err.message : String(err);
console.warn('[ai-settings] cloud provider editor submit failed', {
slug,
summary: presentProviderSetupError(message).summary,
});
setSubmitError(message);
} finally {
setSaving(false);
}
Expand Down
81 changes: 81 additions & 0 deletions app/src/components/settings/panels/ProviderSetupErrorNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
export type ProviderErrorPresentation = { summary: string; details: string };

function decodeJsonString(value: string): string {
try {
return JSON.parse(`"${value}"`) as string;
} catch {
return value;
}
}

function findProviderJsonMessage(raw: string): string | null {
const match = raw.match(/"message"\s*:\s*"((?:\\.|[^"\\])*)"/);
return match ? decodeJsonString(match[1]) : null;
}

function cleanProviderMessage(message: string): string {
return message.replace(/\s+/g, ' ').trim();
}

export function presentProviderSetupError(raw: string): ProviderErrorPresentation {
const details = raw.trim() || 'Provider setup failed.';
const couldNotReach = details.match(/^Could not reach\s+([^:]+):\s*(.*)$/i);
const provider = couldNotReach?.[1]?.trim();
const cause = couldNotReach?.[2]?.trim() || details;
const status = cause.match(/provider returned\s+(\d{3})/i)?.[1];
const providerLabel = provider || 'The provider';

let summary: string | null = null;

if (status === '401' || status === '403') {
summary = `${providerLabel} rejected the credentials. Check the API key and try again.`;
} else if (status === '404') {
summary = `${providerLabel} did not recognize the endpoint. Check the base URL and try again.`;
} else if (status && Number(status) >= 500) {
summary = `${providerLabel} is unavailable right now. Try again or check the provider status.`;
} else if (/HTTP request failed|error sending request|timed out|ECONNREFUSED/i.test(cause)) {
summary = `Could not reach ${providerLabel}. Check the endpoint URL and network connection, then try again.`;
}

if (!summary) {
const jsonMessage = findProviderJsonMessage(cause);
if (jsonMessage) {
summary = provider
? `Could not reach ${provider}: ${cleanProviderMessage(jsonMessage)}`
: cleanProviderMessage(jsonMessage);
}
}

if (!summary) {
summary = cleanProviderMessage(cause);
}

if (summary.length > 220) {
summary = `${summary.slice(0, 217).trimEnd()}...`;
}

return { summary, details };
}

export const ProviderSetupErrorNotice = ({ error }: { error: string }) => {
const { summary, details } = presentProviderSetupError(error);
const hasDetails = details !== summary;

return (
<div
role="alert"
className="max-w-full min-w-0 overflow-hidden rounded-md border border-red-200 dark:border-red-500/30 bg-red-50 dark:bg-red-500/10 px-3 py-2 text-xs text-red-700 dark:text-red-300">
<p className="break-words font-medium leading-relaxed [overflow-wrap:anywhere]">{summary}</p>
{hasDetails ? (
<details className="mt-2 max-w-full min-w-0">
<summary className="cursor-pointer text-[11px] font-medium text-red-700 dark:text-red-200">
Technical details
</summary>
<pre className="mt-1 max-h-32 max-w-full overflow-auto whitespace-pre-wrap break-words rounded border border-red-200/70 dark:border-red-500/30 bg-white/70 dark:bg-neutral-950/40 p-2 font-mono text-[11px] leading-relaxed text-red-800 dark:text-red-200 [overflow-wrap:anywhere]">
{details}
</pre>
</details>
) : null}
</div>
);
};
64 changes: 63 additions & 1 deletion app/src/components/settings/panels/__tests__/AIPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';

import { listConnections as listComposioConnections } from '../../../../lib/composio/composioApi';
import {
listProviderModels,
loadAISettings,
loadLocalProviderSnapshot,
saveAISettings,
Expand Down Expand Up @@ -35,7 +36,7 @@ vi.mock('../../../../services/api/aiSettingsApi', () => ({
saveAISettings: vi.fn(),
loadLocalProviderSnapshot: vi.fn(),
setCloudProviderKey: vi.fn(),
clearCloudProviderKey: vi.fn(),
clearCloudProviderKey: vi.fn().mockResolvedValue(undefined),
serializeProviderRef: vi.fn((r: { kind: string; providerSlug?: string; model?: string }) =>
r.kind === 'openhuman'
? 'openhuman'
Expand Down Expand Up @@ -189,6 +190,8 @@ describe('AIPanel', () => {
vi.clearAllMocks();
vi.mocked(loadAISettings).mockResolvedValue(baseSettings);
vi.mocked(loadLocalProviderSnapshot).mockResolvedValue(baseLocalSnapshot);
vi.mocked(setCloudProviderKey).mockResolvedValue(undefined);
vi.mocked(listProviderModels).mockResolvedValue([]);
vi.mocked(openhumanHeartbeatSettingsGet).mockResolvedValue({
result: { settings: baseHeartbeatSettings },
logs: [],
Expand Down Expand Up @@ -463,6 +466,65 @@ describe('AIPanel', () => {
expect(screen.queryByRole('switch', { name: /Disconnect OpenAI/i })).not.toBeInTheDocument();
});

it('wraps long provider setup errors and hides raw JSON behind technical details', async () => {
vi.mocked(loadAISettings).mockResolvedValue({ ...baseSettings, cloudProviders: [] });
vi.mocked(listProviderModels).mockRejectedValue(
new Error(
'provider returned 401: {"error":{"message":"Incorrect API key provided: sk-this-is-a-very-long-invalid-key-value-that-should-not-dominate-the-modal-or-force-horizontal-overflow. You can find your API key at https://platform.openai.com/account/api-keys.","type":"invalid_request_error","param":null,"code":"invalid_api_key"},"request_id":"req_1234567890abcdefghijklmnopqrstuvwxyz"}'
)
);

renderWithProviders(<AIPanel />);
await waitFor(() =>
expect(screen.getByRole('switch', { name: /Connect OpenAI/i })).toBeInTheDocument()
);

fireEvent.click(screen.getByRole('switch', { name: /Connect OpenAI/i }));
const dialog = await screen.findByRole('dialog', { name: /Connect OpenAI/i });
fireEvent.change(within(dialog).getByLabelText(/API key/i), {
target: { value: 'sk-bad-key' },
});
fireEvent.click(within(dialog).getByRole('button', { name: /^Save$/i }));

const alert = await within(dialog).findByRole('alert');
expect(alert).toHaveClass('max-w-full', 'min-w-0', 'overflow-hidden');
expect(
within(alert).getByText('OpenAI rejected the credentials. Check the API key and try again.')
).toBeInTheDocument();
expect(within(alert).getByText('Technical details')).toBeInTheDocument();
expect(within(alert).getByText(/provider returned 401/)).toBeInTheDocument();
expect(screen.queryByRole('switch', { name: /Disconnect OpenAI/i })).not.toBeInTheDocument();
});

it('summarizes advanced provider editor JSON errors and preserves details', async () => {
vi.mocked(loadAISettings).mockResolvedValue({ ...baseSettings, cloudProviders: [] });
vi.mocked(listProviderModels).mockRejectedValue(
new Error(
'provider returned 418: {"error":{"message":"Provider teapot says no. Try another endpoint."},"request_id":"req_teapot"}'
)
);

renderWithProviders(<AIPanel />);
await waitFor(() =>
expect(screen.getByRole('switch', { name: /Connect Custom/i })).toBeInTheDocument()
);

fireEvent.click(screen.getByRole('switch', { name: /Connect Custom/i }));
await waitFor(() => expect(screen.getByText(/Add cloud provider/i)).toBeInTheDocument());
fireEvent.change(screen.getByPlaceholderText('sk-...'), { target: { value: 'sk-test-key' } });
fireEvent.click(screen.getByRole('button', { name: /Add provider/i }));

const alert = await screen.findByRole('alert');
expect(
within(alert).getByText(
'Could not reach OpenAI: Provider teapot says no. Try another endpoint.'
)
).toBeInTheDocument();
expect(within(alert).getByText('Technical details')).toBeInTheDocument();
expect(within(alert).getByText(/provider returned 418/)).toBeInTheDocument();
expect(screen.queryByRole('switch', { name: /Disconnect OpenAI/i })).not.toBeInTheDocument();
});

// ─── local runtime: Ollama endpoint URL dialog ──────────────────────────────

it('toggling Ollama ON shows an Endpoint URL field with localhost default', async () => {
Expand Down
Loading