From fd80cea0fb62741f6b77599fdfd79d1595deaea9 Mon Sep 17 00:00:00 2001 From: Maxence Haouari Date: Thu, 21 May 2026 11:42:00 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8(backend)=20display=20specific=20e?= =?UTF-8?q?rror=20when=20LLM=20provider=20is=20down?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AlbertAPI status page to settings Send event when http error is raised --- CHANGELOG.md | 3 + docs/errors.md | 31 +++ src/backend/chat/clients/pydantic_ai.py | 53 ++++- .../pydantic_ai/test_stream_methods.py | 185 +++++++++++++++++- .../chat/vercel_ai_sdk/encoder/encoder.py | 4 + src/backend/conversations/settings.py | 2 + src/backend/core/api/viewsets.py | 1 + src/backend/core/tests/test_api_config.py | 17 ++ 8 files changed, 292 insertions(+), 4 deletions(-) create mode 100644 docs/errors.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 24471f6a..4b74988e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ and this project adheres to - 🔒️(front) disable yarn install scripts in docker build +### Added +- ✨(frontend): display specific error when LLM provider is down + ## [0.0.16] - 2026-05-21 ### Added diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 00000000..664e7c61 --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,31 @@ +# LLM Provider Error Handling + +When the LLM provider fails, the backend emits a typed error code in the Vercel AI SDK stream. The frontend reads this code and displays a specific message. + +## Error mapping + +| Provider failure | pydantic-ai exception | Error code | User-facing message | +|---|---|---|---| +| HTTP 5xx except 503 (500, 502, 504…) | `ModelHTTPError` (`status_code >= 500`) | `model_unavailable` | "The assistant is temporarily unavailable. Please try again later." | +| HTTP 503 (service busy) | `ModelHTTPError` (`status_code == 503`) | `model_busy` | "The assistant is too busy. Please try again later." | +| HTTP 429 (rate limit) | `ModelHTTPError` (`status_code == 429`) | `model_rate_limited` | "The assistant is overloaded. Please try again in a few minutes." | +| HTTP 404 (not found) | `ModelHTTPError` (`status_code == 404`) | `model_not_found` | "Model not found." | +| HTTP 422 (validation error) | `HTTPValidationError` (`status_code == 422`) | `model_wrong_type` | "Wrong model type." | +| No TCP connection, DNS failure, timeout | `ModelAPIError` | `model_connection_error` | "Unable to reach the assistant. Please check your connection or try again later." | +| Any other error | uncaught → generic Vercel AI SDK error | `generic` | "Sorry, an error occurred. Please try again." | + +## Status page link + +Set `STATUS_PAGE_URL` to display a "Check service status" link alongside any non-`generic` provider error (`model_unavailable`, `model_busy`, `model_rate_limited`, `model_not_found`, `model_wrong_type`, `model_connection_error`). + +```bash +ALBERT_STATUS_PAGE_URL=https://albert.sites.beta.gouv.fr/about/status/ +``` + +The link is omitted when the variable is unset. + +## Implementation + +- **Backend:** `src/backend/chat/clients/pydantic_ai.py` — `_stream_content` catches provider exceptions and emits `ErrorPart` events. +- **Frontend:** `src/frontend/apps/conversations/src/features/chat/components/ChatError.tsx` — renders the message based on `errorType` prop. +- **Config:** `src/backend/conversations/settings.py` and `src/backend/core/api/viewsets.py` — `ALBERT_STATUS_PAGE_URL` is exposed via the `config/` API. diff --git a/src/backend/chat/clients/pydantic_ai.py b/src/backend/chat/clients/pydantic_ai.py index 517e47b5..c6b9ac91 100644 --- a/src/backend/chat/clients/pydantic_ai.py +++ b/src/backend/chat/clients/pydantic_ai.py @@ -97,7 +97,9 @@ from asgiref.sync import sync_to_async from langfuse import get_client +from mistralai.client.errors import HTTPValidationError, SDKError from pydantic_ai import Agent, InstrumentationSettings, RunContext, RunUsage +from pydantic_ai.exceptions import ModelAPIError, ModelHTTPError from pydantic_ai.messages import ( BinaryContent, DocumentUrl, @@ -177,6 +179,22 @@ CACHE_TIMEOUT = 30 * 60 # 30 minutes timeout DOCUMENT_URL_PREFIX = "/media-key/" +_HTTP_STATUS_TO_ERROR_CODE = { + 429: "model_rate_limited", + 503: "model_busy", + 404: "model_not_found", + 422: "model_wrong_type", +} + + +def _resolve_http_error_code(status_code: int) -> str | None: + """Map an HTTP status code to an error code string, or None to re-raise.""" + if status_code in _HTTP_STATUS_TO_ERROR_CODE: + return _HTTP_STATUS_TO_ERROR_CODE[status_code] + if status_code >= 500: + return "model_unavailable" + return None + def get_model_configuration(model_hrid: str): """Get the model configuration from settings.""" @@ -342,9 +360,38 @@ async def _stream_content( span = stack.enter_context(get_client().start_as_current_span(name="conversation")) span.update_trace(user_id=str(self.user.sub), session_id=str(self.conversation.pk)) - async for event in self._run_agent(messages, force_web_search): - if stream_text := encoder_fn(event): - yield stream_text + try: + async for event in self._run_agent(messages, force_web_search): + if stream_text := encoder_fn(event): + yield stream_text + except (ModelHTTPError, HTTPValidationError, SDKError) as exc: + # HTTPValidationError and SDKError are mistral-specific exceptions not + # wrapped by pydantic_ai into ModelHTTPError. + error_code = _resolve_http_error_code(exc.status_code) + if error_code is None: + raise + logger.exception( + "LLM provider HTTP error (status=%s) for conversation %s: %s", + exc.status_code, + self.conversation.pk, + exc, + ) + error_event = events_v4.ErrorPart(error=error_code) + if encoded := encoder_fn(error_event): + yield encoded + else: + raise + except ModelAPIError as exc: + logger.exception( + "LLM provider connection error for conversation %s: %s", + self.conversation.pk, + exc, + ) + error_event = events_v4.ErrorPart(error="model_connection_error") + if encoded := encoder_fn(error_event): + yield encoded + else: + raise async def stream_text_async(self, messages: List[UIMessage], force_web_search: bool = False): """Return only the assistant text deltas (legacy text mode).""" diff --git a/src/backend/chat/tests/clients/pydantic_ai/test_stream_methods.py b/src/backend/chat/tests/clients/pydantic_ai/test_stream_methods.py index c7e4bc8d..c9e58a64 100644 --- a/src/backend/chat/tests/clients/pydantic_ai/test_stream_methods.py +++ b/src/backend/chat/tests/clients/pydantic_ai/test_stream_methods.py @@ -1,9 +1,13 @@ """Unit tests for AIAgentService stream methods.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch +import httpx import pytest from asgiref.sync import sync_to_async +from mistralai.client.errors import HTTPValidationError, SDKError +from mistralai.client.errors.httpvalidationerror import HTTPValidationErrorData +from pydantic_ai.exceptions import ModelAPIError, ModelHTTPError from chat.ai_sdk_types import UIMessage from chat.clients.pydantic_ai import AIAgentService @@ -13,6 +17,19 @@ pytestmark = pytest.mark.django_db() +class AsyncRaiseIterator: + """Async iterator that raises the given exception on the first iteration.""" + + def __init__(self, exc): + self.exc = exc + + def __aiter__(self): + return self + + async def __anext__(self): + raise self.exc + + @pytest.fixture(autouse=True) def base_settings(settings): """Set up base settings for the tests.""" @@ -109,3 +126,169 @@ async def mock_run_agent(*args, **kwargs): 'd:{"finishReason":"stop","usage":{"promptTokens":120,"completionTokens":456,' '"co2Impact":0.0}}\n', ] + + +@pytest.mark.asyncio +async def test_stream_data_async_emits_model_busy_on_503(): + """503 ModelHTTPError emits model_busy ErrorPart.""" + conversation = await sync_to_async(ChatConversationFactory)() + service = AIAgentService(conversation, user=conversation.owner) + + def mock_run_agent(*args, **kwargs): + return AsyncRaiseIterator(ModelHTTPError(status_code=503, model_name="test-model")) + + with patch.object(service, "_run_agent", side_effect=mock_run_agent): + results = [] + async for result in service.stream_data_async([]): + results.append(result) + + assert results == ['3:"model_busy"\n'] + + +@pytest.mark.asyncio +async def test_stream_data_async_emits_model_unavailable_on_5xx(): + """Generic 5xx ModelHTTPError emits model_unavailable ErrorPart.""" + conversation = await sync_to_async(ChatConversationFactory)() + service = AIAgentService(conversation, user=conversation.owner) + + def mock_run_agent(*args, **kwargs): + return AsyncRaiseIterator(ModelHTTPError(status_code=500, model_name="test-model")) + + with patch.object(service, "_run_agent", side_effect=mock_run_agent): + results = [] + async for result in service.stream_data_async([]): + results.append(result) + + assert results == ['3:"model_unavailable"\n'] + + +@pytest.mark.asyncio +async def test_stream_data_async_emits_model_rate_limited_on_429(): + """429 ModelHTTPError emits model_rate_limited ErrorPart.""" + conversation = await sync_to_async(ChatConversationFactory)() + service = AIAgentService(conversation, user=conversation.owner) + + def mock_run_agent(*args, **kwargs): + return AsyncRaiseIterator(ModelHTTPError(status_code=429, model_name="test-model")) + + with patch.object(service, "_run_agent", side_effect=mock_run_agent): + results = [] + async for result in service.stream_data_async([]): + results.append(result) + + assert results == ['3:"model_rate_limited"\n'] + + +@pytest.mark.asyncio +async def test_stream_data_async_emits_model_connection_error_on_api_error(): + """ModelAPIError (e.g. connection refused) emits model_connection_error ErrorPart.""" + conversation = await sync_to_async(ChatConversationFactory)() + service = AIAgentService(conversation, user=conversation.owner) + + def mock_run_agent(*args, **kwargs): + return AsyncRaiseIterator( + ModelAPIError(model_name="test-model", message="Connection error.") + ) + + with patch.object(service, "_run_agent", side_effect=mock_run_agent): + results = [] + async for result in service.stream_data_async([]): + results.append(result) + + assert results == ['3:"model_connection_error"\n'] + + +@pytest.mark.asyncio +async def test_stream_data_async_emits_model_busy_on_sdk_error_503(): + """Mistral SDKError with 503 emits model_busy ErrorPart.""" + conversation = await sync_to_async(ChatConversationFactory)() + service = AIAgentService(conversation, user=conversation.owner) + + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 503 + mock_response.text = "" + mock_response.headers = httpx.Headers() + + def mock_run_agent(*args, **kwargs): + return AsyncRaiseIterator( + SDKError(message="service unavailable", raw_response=mock_response) + ) + + with patch.object(service, "_run_agent", side_effect=mock_run_agent): + results = [] + async for result in service.stream_data_async([]): + results.append(result) + + assert results == ['3:"model_busy"\n'] + + +@pytest.mark.asyncio +async def test_stream_data_async_emits_model_wrong_type_on_http_validation_error_422(): + """Mistral HTTPValidationError with 422 emits model_wrong_type ErrorPart.""" + conversation = await sync_to_async(ChatConversationFactory)() + service = AIAgentService(conversation, user=conversation.owner) + + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 422 + mock_response.text = "" + mock_response.headers = httpx.Headers() + + def mock_run_agent(*args, **kwargs): + return AsyncRaiseIterator( + HTTPValidationError(data=HTTPValidationErrorData(), raw_response=mock_response) + ) + + with patch.object(service, "_run_agent", side_effect=mock_run_agent): + results = [] + async for result in service.stream_data_async([]): + results.append(result) + + assert results == ['3:"model_wrong_type"\n'] + + +@pytest.mark.asyncio +async def test_stream_data_async_reraises_unknown_exceptions(): + """Unknown exceptions are not swallowed — they propagate.""" + conversation = await sync_to_async(ChatConversationFactory)() + service = AIAgentService(conversation, user=conversation.owner) + + def mock_run_agent(*args, **kwargs): + return AsyncRaiseIterator(ValueError("unexpected")) + + with patch.object(service, "_run_agent", side_effect=mock_run_agent): + with pytest.raises(ValueError, match="unexpected"): + async for _ in service.stream_data_async([]): + pass + + +@pytest.mark.asyncio +async def test_stream_text_async_reraises_http_error(): + """ModelHTTPError is re-raised on text protocol path + (encode_text returns None for ErrorPart).""" + conversation = await sync_to_async(ChatConversationFactory)() + service = AIAgentService(conversation, user=conversation.owner) + + def mock_run_agent(*args, **kwargs): + return AsyncRaiseIterator(ModelHTTPError(status_code=503, model_name="test-model")) + + with patch.object(service, "_run_agent", side_effect=mock_run_agent): + with pytest.raises(ModelHTTPError): + async for _ in service.stream_text_async([]): + pass + + +@pytest.mark.asyncio +async def test_stream_text_async_reraises_connection_error(): + """ModelAPIError is re-raised on text protocol path (encode_text returns None for ErrorPart).""" + conversation = await sync_to_async(ChatConversationFactory)() + service = AIAgentService(conversation, user=conversation.owner) + + def mock_run_agent(*args, **kwargs): + return AsyncRaiseIterator( + ModelAPIError(model_name="test-model", message="Connection error.") + ) + + with patch.object(service, "_run_agent", side_effect=mock_run_agent): + with pytest.raises(ModelAPIError): + async for _ in service.stream_text_async([]): + pass diff --git a/src/backend/chat/vercel_ai_sdk/encoder/encoder.py b/src/backend/chat/vercel_ai_sdk/encoder/encoder.py index fe922d07..3c9a630d 100644 --- a/src/backend/chat/vercel_ai_sdk/encoder/encoder.py +++ b/src/backend/chat/vercel_ai_sdk/encoder/encoder.py @@ -66,6 +66,10 @@ def encode_text(self, event: Union[V4BaseEvent, V5BaseEvent]) -> str | None: Returns: str | None: The encoded event as a string, or None if the event type is not adapted to the SDK version. + + Note: Only TextPart/TextDeltaEvent are handled. ErrorPart events are silently + dropped on the text protocol path — the `data` protocol (encode()) is the only + supported path for surfacing provider errors to the frontend. """ if self.version == EventEncoderVersion.V4 and isinstance(event, TextPart): return event.text diff --git a/src/backend/conversations/settings.py b/src/backend/conversations/settings.py index 7750df61..1f545fab 100755 --- a/src/backend/conversations/settings.py +++ b/src/backend/conversations/settings.py @@ -502,6 +502,8 @@ class Base(BraveSettings, Configuration): default=False, environ_name="POSTHOG_MW_CAPTURE_EXCEPTIONS", environ_prefix=None ) + STATUS_PAGE_URL = values.Value(None, environ_name="STATUS_PAGE_URL", environ_prefix=None) + # Crisp CRISP_WEBSITE_ID = values.Value(None, environ_name="CRISP_WEBSITE_ID", environ_prefix=None) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 7758c439..635d6c75 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -200,6 +200,7 @@ def get(self, request): """ array_settings = [ "ACTIVATION_REQUIRED", + "STATUS_PAGE_URL", "CRISP_WEBSITE_ID", "ENVIRONMENT", "FRONTEND_CSS_URL", diff --git a/src/backend/core/tests/test_api_config.py b/src/backend/core/tests/test_api_config.py index e56e0578..2befb907 100644 --- a/src/backend/core/tests/test_api_config.py +++ b/src/backend/core/tests/test_api_config.py @@ -21,6 +21,7 @@ @override_settings( + STATUS_PAGE_URL="https://status.example.com", CRISP_WEBSITE_ID="123", FRONTEND_CSS_URL="http://testcss/", FRONTEND_THEME="test-theme", @@ -46,6 +47,7 @@ def test_api_config(is_authenticated): assert response.status_code == HTTP_200_OK assert response.json() == { "ACTIVATION_REQUIRED": False, + "STATUS_PAGE_URL": "https://status.example.com", "CRISP_WEBSITE_ID": "123", "ENVIRONMENT": "test", "FEATURE_FLAGS": {"document-upload": "enabled", "web-search": "enabled"}, @@ -167,6 +169,7 @@ def test_api_config_with_original_theme_customization(is_authenticated, settings @override_settings( + STATUS_PAGE_URL="https://status.example.com", CRISP_WEBSITE_ID="123", FRONTEND_CSS_URL="http://testcss/", FRONTEND_THEME="test-theme", @@ -193,6 +196,7 @@ async def test_api_config_async(is_authenticated): assert response.status_code == HTTP_200_OK assert response.json() == { "ACTIVATION_REQUIRED": False, + "STATUS_PAGE_URL": "https://status.example.com", "CRISP_WEBSITE_ID": "123", "ENVIRONMENT": "test", "FEATURE_FLAGS": {"document-upload": "enabled", "web-search": "enabled"}, @@ -220,6 +224,19 @@ async def test_api_config_async(is_authenticated): } +@override_settings( + STATUS_PAGE_URL=None, + THEME_CUSTOMIZATION_FILE_PATH="", + RAG_FILES_ACCEPTED_FORMATS=["application/pdf"], +) +def test_api_config_albert_status_page_url_none(): + """STATUS_PAGE_URL defaults to None and is included in config.""" + client = APIClient() + response = client.get("/api/v1.0/config/") + assert response.status_code == HTTP_200_OK + assert response.json()["STATUS_PAGE_URL"] is None + + def _set_banner(**fields): config = models.SiteConfiguration.get_solo() for field, value in fields.items(): From 660a380f0c6733528c9120353af92940cfa3f3d5 Mon Sep 17 00:00:00 2001 From: Maxence Haouari Date: Fri, 22 May 2026 11:03:05 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8(frontend)=20display=20specific=20?= =?UTF-8?q?error=20when=20LLM=20provider=20is=20down?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display specific message based on http error code Show link to albert api status page --- docs/errors.md | 4 +- .../pydantic_ai/test_stream_methods.py | 3 +- .../src/core/config/api/useConfig.tsx | 1 + .../src/features/chat/components/Chat.tsx | 23 ++- .../features/chat/components/ChatError.tsx | 62 +++++++- .../features/chat/components/InputChat.tsx | 46 ++++-- .../chat/components/InputChatAction.tsx | 6 +- .../components/__tests__/ChatError.test.tsx | 135 ++++++++++++++++++ .../components/__tests__/InputChat.test.tsx | 4 +- .../app-conversations/left-panel.spec.ts | 4 +- .../app-conversations/projects.spec.ts | 4 +- 11 files changed, 262 insertions(+), 30 deletions(-) create mode 100644 src/frontend/apps/conversations/src/features/chat/components/__tests__/ChatError.test.tsx diff --git a/docs/errors.md b/docs/errors.md index 664e7c61..6e1bbbc8 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -19,7 +19,7 @@ When the LLM provider fails, the backend emits a typed error code in the Vercel Set `STATUS_PAGE_URL` to display a "Check service status" link alongside any non-`generic` provider error (`model_unavailable`, `model_busy`, `model_rate_limited`, `model_not_found`, `model_wrong_type`, `model_connection_error`). ```bash -ALBERT_STATUS_PAGE_URL=https://albert.sites.beta.gouv.fr/about/status/ +STATUS_PAGE_URL=https://albert.sites.beta.gouv.fr/about/status/ ``` The link is omitted when the variable is unset. @@ -28,4 +28,4 @@ The link is omitted when the variable is unset. - **Backend:** `src/backend/chat/clients/pydantic_ai.py` — `_stream_content` catches provider exceptions and emits `ErrorPart` events. - **Frontend:** `src/frontend/apps/conversations/src/features/chat/components/ChatError.tsx` — renders the message based on `errorType` prop. -- **Config:** `src/backend/conversations/settings.py` and `src/backend/core/api/viewsets.py` — `ALBERT_STATUS_PAGE_URL` is exposed via the `config/` API. +- **Config:** `src/backend/conversations/settings.py` and `src/backend/core/api/viewsets.py` — `STATUS_PAGE_URL` is exposed via the `config/` API. diff --git a/src/backend/chat/tests/clients/pydantic_ai/test_stream_methods.py b/src/backend/chat/tests/clients/pydantic_ai/test_stream_methods.py index c9e58a64..1d89bc89 100644 --- a/src/backend/chat/tests/clients/pydantic_ai/test_stream_methods.py +++ b/src/backend/chat/tests/clients/pydantic_ai/test_stream_methods.py @@ -5,8 +5,7 @@ import httpx import pytest from asgiref.sync import sync_to_async -from mistralai.client.errors import HTTPValidationError, SDKError -from mistralai.client.errors.httpvalidationerror import HTTPValidationErrorData +from mistralai.client.errors import HTTPValidationError, HTTPValidationErrorData, SDKError from pydantic_ai.exceptions import ModelAPIError, ModelHTTPError from chat.ai_sdk_types import UIMessage diff --git a/src/frontend/apps/conversations/src/core/config/api/useConfig.tsx b/src/frontend/apps/conversations/src/core/config/api/useConfig.tsx index 0635bb4c..427f4528 100644 --- a/src/frontend/apps/conversations/src/core/config/api/useConfig.tsx +++ b/src/frontend/apps/conversations/src/core/config/api/useConfig.tsx @@ -30,6 +30,7 @@ export interface StatusBanner { export interface ConfigResponse { ACTIVATION_REQUIRED: boolean; + STATUS_PAGE_URL?: string | null; CRISP_WEBSITE_ID?: string; ENVIRONMENT: string; FEATURE_FLAGS: FeatureFlags; diff --git a/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx b/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx index 0ea36506..bded86f6 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx @@ -27,7 +27,7 @@ import { KEY_LIST_PROJECT, ProjectsResponse, } from '@/features/chat/api/useProjects'; -import { ChatError } from '@/features/chat/components/ChatError'; +import { ChatError, ChatErrorType } from '@/features/chat/components/ChatError'; import { InputChat } from '@/features/chat/components/InputChat'; import { MessageItem } from '@/features/chat/components/MessageItem'; import { useClipboard } from '@/hook'; @@ -39,6 +39,15 @@ import { useChatPreferencesStore } from '../stores/useChatPreferencesStore'; import { usePendingChatStore } from '../stores/usePendingChatStore'; import { useScrollStore } from '../stores/useScrollStore'; +const PROVIDER_ERROR_CODES: ChatErrorType[] = [ + 'model_unavailable', + 'model_rate_limited', + 'model_connection_error', + 'model_not_found', + 'model_wrong_type', + 'model_busy', +]; + // Define Attachment type locally (mirroring backend structure) export interface Attachment { name?: string; @@ -122,6 +131,7 @@ export const Chat = ({ title: string; message: string; } | null>(null); + const [chatErrorType, setChatErrorType] = useState('generic'); const { setIsAtTop } = useScrollStore(); @@ -214,7 +224,15 @@ export const Chat = ({ title: t('Attachment summary not supported'), message: t('The summary feature is not supported yet.'), }); + return; + } + + if (PROVIDER_ERROR_CODES.includes(error.message as ChatErrorType)) { + setChatErrorType(error.message as ChatErrorType); + } else { + setChatErrorType('generic'); } + console.error('Chat error:', error); }; @@ -654,6 +672,7 @@ export const Chat = ({ options.experimental_attachments = attachments; } + setChatErrorType('generic'); lastSubmissionRef.current = { input, files, @@ -786,6 +805,7 @@ export const Chat = ({ ) : null} {status === 'error' && !isUploadingFiles && ( @@ -820,6 +840,7 @@ export const Chat = ({ selectedModel={selectedModel} onModelSelect={handleModelSelect} isUploadingFiles={isUploadingFiles} + errorType={status === 'error' ? chatErrorType : undefined} /> void; } -export const ChatError = ({ hasLastSubmission, onRetry }: ChatErrorProps) => { +const ERROR_MESSAGES: Record = { + generic: 'Sorry, an error occurred. Please try again.', + model_unavailable: + 'The assistant is temporarily unavailable. Please try again later.', + model_rate_limited: + 'The assistant is overloaded. Please try again in a few minutes.', + model_connection_error: + 'Unable to reach the assistant. Please check your connection or try again later.', + model_not_found: 'Model not found.', + model_wrong_type: 'Wrong model type.', + model_busy: 'The assistant is too busy. Please try again later.', +}; + +export const ChatError = ({ + errorType, + hasLastSubmission, + onRetry, +}: ChatErrorProps) => { const { t } = useTranslation(); const router = useRouter(); + const { data: config } = useConfig(); + const statusPageUrl = config?.STATUS_PAGE_URL; + const isProviderError = errorType !== 'generic'; return ( { $margin={{ all: 'auto', top: 'base', bottom: 'md' }} $padding={{ left: '13px' }} > - - {t('Sorry, an error occurred. Please try again.')} - + {isProviderError ? ( + + + {t(ERROR_MESSAGES[errorType])} + + {statusPageUrl && ( + + + + )} + + ) : ( + + {t(ERROR_MESSAGES[errorType])} + + )} - {hasLastSubmission ? ( + {!isProviderError && hasLastSubmission && ( - ) : ( + )} + {!isProviderError && !hasLastSubmission && (