From 20e18fdd0d05e9581bdc4a5f86932f7dbd6c36ba Mon Sep 17 00:00:00 2001 From: Luis Novo Date: Mon, 16 Feb 2026 16:15:46 -0300 Subject: [PATCH] feat: improve error clarity for LLM provider failures (#506) Replace generic "An unexpected error occurred" messages with descriptive, user-friendly error messages when LLM operations fail. Errors like invalid API keys, wrong model names, and rate limits now surface clearly in the UI. Adds error classification utility, global FastAPI exception handlers, and frontend getApiErrorMessage() helper. Bumps version to 1.7.2. --- CHANGELOG.md | 16 ++ CLAUDE.md | 2 +- api/main.py | 92 +++++++++++ api/routers/search.py | 5 +- api/routers/source_chat.py | 5 +- api/routers/transformations.py | 4 +- commands/embedding_commands.py | 9 +- commands/source_commands.py | 5 +- frontend/src/lib/hooks/use-ask.ts | 12 +- frontend/src/lib/hooks/use-sources.ts | 16 +- frontend/src/lib/hooks/use-transformations.ts | 12 +- frontend/src/lib/hooks/useNotebookChat.ts | 12 +- frontend/src/lib/hooks/useSourceChat.ts | 18 ++- frontend/src/lib/utils/error-handler.ts | 25 +++ open_notebook/ai/models.py | 7 +- open_notebook/ai/provision.py | 5 +- open_notebook/graphs/ask.py | 144 ++++++++++-------- open_notebook/graphs/chat.py | 104 +++++++------ open_notebook/graphs/source_chat.py | 14 ++ open_notebook/graphs/transformation.py | 67 ++++---- open_notebook/utils/error_classifier.py | 90 +++++++++++ pyproject.toml | 2 +- 22 files changed, 480 insertions(+), 186 deletions(-) create mode 100644 open_notebook/utils/error_classifier.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b974ff..2e2cf55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.7.2] - 2026-02-16 + +### Added +- Error classification utility that maps LLM provider errors to user-friendly messages (#506) +- Global exception handlers in FastAPI for all custom exception types with proper HTTP status codes +- `getApiErrorMessage()` frontend helper that falls back to backend messages when no i18n mapping exists + +### Fixed +- LLM errors (invalid API key, wrong model, rate limits) now show descriptive messages instead of "An unexpected error occurred" +- SSE streaming error events in source chat and ask hooks were swallowed by inner JSON parse catch blocks +- Transformation execution errors were caught and re-wrapped as generic 500s instead of using proper status codes + +### Changed +- `ValueError` replaced with `ConfigurationError` in model provisioning for proper error classification +- `ConfigurationError` added to command retry `stop_on` lists to avoid retrying permanent config failures + ## [1.7.1] - 2026-02-14 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index bed67c4..8a053da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -218,4 +218,4 @@ See dedicated CLAUDE.md files for detailed guidance: --- -**Last Updated**: February 2026 | **Project Version**: 1.7.1 +**Last Updated**: February 2026 | **Project Version**: 1.7.2 diff --git a/api/main.py b/api/main.py index df93eb8..cf159d4 100644 --- a/api/main.py +++ b/api/main.py @@ -12,6 +12,16 @@ from loguru import logger from starlette.exceptions import HTTPException as StarletteHTTPException from api.auth import PasswordAuthMiddleware +from open_notebook.exceptions import ( + AuthenticationError, + ConfigurationError, + ExternalServiceError, + InvalidInputError, + NetworkError, + NotFoundError, + OpenNotebookError, + RateLimitError, +) from api.routers import ( auth, chat, @@ -154,6 +164,88 @@ async def custom_http_exception_handler(request: Request, exc: StarletteHTTPExce ) +def _cors_headers(request: Request) -> dict[str, str]: + origin = request.headers.get("origin", "*") + return { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", + } + + +@app.exception_handler(NotFoundError) +async def not_found_error_handler(request: Request, exc: NotFoundError): + return JSONResponse( + status_code=404, + content={"detail": str(exc)}, + headers=_cors_headers(request), + ) + + +@app.exception_handler(InvalidInputError) +async def invalid_input_error_handler(request: Request, exc: InvalidInputError): + return JSONResponse( + status_code=400, + content={"detail": str(exc)}, + headers=_cors_headers(request), + ) + + +@app.exception_handler(AuthenticationError) +async def authentication_error_handler(request: Request, exc: AuthenticationError): + return JSONResponse( + status_code=401, + content={"detail": str(exc)}, + headers=_cors_headers(request), + ) + + +@app.exception_handler(RateLimitError) +async def rate_limit_error_handler(request: Request, exc: RateLimitError): + return JSONResponse( + status_code=429, + content={"detail": str(exc)}, + headers=_cors_headers(request), + ) + + +@app.exception_handler(ConfigurationError) +async def configuration_error_handler(request: Request, exc: ConfigurationError): + return JSONResponse( + status_code=422, + content={"detail": str(exc)}, + headers=_cors_headers(request), + ) + + +@app.exception_handler(NetworkError) +async def network_error_handler(request: Request, exc: NetworkError): + return JSONResponse( + status_code=502, + content={"detail": str(exc)}, + headers=_cors_headers(request), + ) + + +@app.exception_handler(ExternalServiceError) +async def external_service_error_handler(request: Request, exc: ExternalServiceError): + return JSONResponse( + status_code=502, + content={"detail": str(exc)}, + headers=_cors_headers(request), + ) + + +@app.exception_handler(OpenNotebookError) +async def open_notebook_error_handler(request: Request, exc: OpenNotebookError): + return JSONResponse( + status_code=500, + content={"detail": str(exc)}, + headers=_cors_headers(request), + ) + + # Include routers app.include_router(auth.router, prefix="/api", tags=["auth"]) app.include_router(config.router, prefix="/api", tags=["config"]) diff --git a/api/routers/search.py b/api/routers/search.py index 1c817be..53c7d76 100644 --- a/api/routers/search.py +++ b/api/routers/search.py @@ -102,8 +102,11 @@ async def stream_ask_response( yield f"data: {json.dumps(completion_data)}\n\n" except Exception as e: + from open_notebook.utils.error_classifier import classify_error + + _, user_message = classify_error(e) logger.error(f"Error in ask streaming: {str(e)}") - error_data = {"type": "error", "message": str(e)} + error_data = {"type": "error", "message": user_message} yield f"data: {json.dumps(error_data)}\n\n" diff --git a/api/routers/source_chat.py b/api/routers/source_chat.py index 9b7fa20..5fdde19 100644 --- a/api/routers/source_chat.py +++ b/api/routers/source_chat.py @@ -470,8 +470,11 @@ async def stream_source_chat_response( yield f"data: {json.dumps(completion_event)}\n\n" except Exception as e: + from open_notebook.utils.error_classifier import classify_error + + _, user_message = classify_error(e) logger.error(f"Error in source chat streaming: {str(e)}") - error_event = {"type": "error", "message": str(e)} + error_event = {"type": "error", "message": user_message} yield f"data: {json.dumps(error_event)}\n\n" diff --git a/api/routers/transformations.py b/api/routers/transformations.py index 88871bb..1848ad5 100644 --- a/api/routers/transformations.py +++ b/api/routers/transformations.py @@ -14,7 +14,7 @@ from api.models import ( ) from open_notebook.ai.models import Model from open_notebook.domain.transformation import DefaultPrompts, Transformation -from open_notebook.exceptions import InvalidInputError +from open_notebook.exceptions import InvalidInputError, OpenNotebookError from open_notebook.graphs.transformation import graph as transformation_graph router = APIRouter() @@ -109,6 +109,8 @@ async def execute_transformation(execute_request: TransformationExecuteRequest): except HTTPException: raise + except OpenNotebookError: + raise # Let global exception handlers return proper status codes except Exception as e: logger.error(f"Error executing transformation: {str(e)}") raise HTTPException( diff --git a/commands/embedding_commands.py b/commands/embedding_commands.py index 6d47cd4..89c03c6 100644 --- a/commands/embedding_commands.py +++ b/commands/embedding_commands.py @@ -7,6 +7,7 @@ from surreal_commands import CommandInput, CommandOutput, command, submit_comman from open_notebook.ai.models import model_manager from open_notebook.database.repository import ensure_record_id, repo_insert, repo_query +from open_notebook.exceptions import ConfigurationError from open_notebook.domain.notebook import Note, Source, SourceInsight from open_notebook.utils.chunking import ContentType, chunk_text, detect_content_type from open_notebook.utils.embedding import generate_embedding, generate_embeddings @@ -125,7 +126,7 @@ class EmbedSourceOutput(CommandOutput): "wait_strategy": "exponential_jitter", "wait_min": 1, "wait_max": 60, - "stop_on": [ValueError], # Don't retry validation errors + "stop_on": [ValueError, ConfigurationError], # Don't retry validation/config errors "retry_log_level": "debug", }, ) @@ -217,7 +218,7 @@ async def embed_note_command(input_data: EmbedNoteInput) -> EmbedNoteOutput: "wait_strategy": "exponential_jitter", "wait_min": 1, "wait_max": 60, - "stop_on": [ValueError], # Don't retry validation errors + "stop_on": [ValueError, ConfigurationError], # Don't retry validation/config errors "retry_log_level": "debug", }, ) @@ -311,7 +312,7 @@ async def embed_insight_command(input_data: EmbedInsightInput) -> EmbedInsightOu "wait_strategy": "exponential_jitter", "wait_min": 1, "wait_max": 60, - "stop_on": [ValueError], # Don't retry validation errors + "stop_on": [ValueError, ConfigurationError], # Don't retry validation/config errors "retry_log_level": "debug", }, ) @@ -447,7 +448,7 @@ async def embed_source_command(input_data: EmbedSourceInput) -> EmbedSourceOutpu "wait_strategy": "exponential_jitter", "wait_min": 1, "wait_max": 60, - "stop_on": [ValueError], # Don't retry validation errors + "stop_on": [ValueError, ConfigurationError], # Don't retry validation/config errors "retry_log_level": "debug", }, ) diff --git a/commands/source_commands.py b/commands/source_commands.py index 02df7de..1c6ef19 100644 --- a/commands/source_commands.py +++ b/commands/source_commands.py @@ -8,6 +8,7 @@ from surreal_commands import CommandInput, CommandOutput, command from open_notebook.database.repository import ensure_record_id from open_notebook.domain.notebook import Source from open_notebook.domain.transformation import Transformation +from open_notebook.exceptions import ConfigurationError try: from open_notebook.graphs.source import source_graph @@ -53,7 +54,7 @@ class SourceProcessingOutput(CommandOutput): "wait_strategy": "exponential_jitter", "wait_min": 1, "wait_max": 120, # Allow queue to drain - "stop_on": [ValueError], # Don't retry validation errors + "stop_on": [ValueError, ConfigurationError], # Don't retry validation/config errors "retry_log_level": "debug", # Avoid log noise during transaction conflicts }, ) @@ -184,7 +185,7 @@ class RunTransformationOutput(CommandOutput): "wait_strategy": "exponential_jitter", "wait_min": 1, "wait_max": 60, - "stop_on": [ValueError], # Don't retry validation errors + "stop_on": [ValueError, ConfigurationError], # Don't retry validation/config errors "retry_log_level": "debug", }, ) diff --git a/frontend/src/lib/hooks/use-ask.ts b/frontend/src/lib/hooks/use-ask.ts index d0a6f27..1945879 100644 --- a/frontend/src/lib/hooks/use-ask.ts +++ b/frontend/src/lib/hooks/use-ask.ts @@ -3,7 +3,7 @@ import { useState, useCallback } from 'react' import { toast } from 'sonner' import { useTranslation } from '@/lib/hooks/use-translation' -import { getApiErrorKey } from '@/lib/utils/error-handler' +import { getApiErrorMessage } from '@/lib/utils/error-handler' import { searchApi } from '@/lib/api/search' import { AskStreamEvent } from '@/lib/types/search' @@ -122,8 +122,12 @@ export function useAsk() { throw new Error(data.message || 'Stream error occurred') } } catch (e) { - console.error('Error parsing SSE data:', e, 'Line:', line) - // Don't throw - continue processing other lines + if (e instanceof SyntaxError) { + console.error('Error parsing SSE data:', e, 'Line:', line) + // Don't throw - continue processing other lines + } else { + throw e + } } } } @@ -144,7 +148,7 @@ export function useAsk() { })) toast.error(t('apiErrors.askFailed'), { - description: t(getApiErrorKey(errorMessage)) + description: getApiErrorMessage(errorMessage, (key) => t(key)) }) } }, [t]) diff --git a/frontend/src/lib/hooks/use-sources.ts b/frontend/src/lib/hooks/use-sources.ts index 354327e..97f1324 100644 --- a/frontend/src/lib/hooks/use-sources.ts +++ b/frontend/src/lib/hooks/use-sources.ts @@ -4,7 +4,7 @@ import { sourcesApi } from '@/lib/api/sources' import { QUERY_KEYS } from '@/lib/api/query-client' import { useToast } from '@/lib/hooks/use-toast' import { useTranslation } from '@/lib/hooks/use-translation' -import { getApiErrorKey } from '@/lib/utils/error-handler' +import { getApiErrorMessage } from '@/lib/utils/error-handler' import { CreateSourceRequest, UpdateSourceRequest, @@ -131,7 +131,7 @@ export function useCreateSource() { onError: (error: unknown) => { toast({ title: t.common.error, - description: t(getApiErrorKey(error, t.sources.failedToAddSource)), + description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToAddSource), variant: 'destructive', }) }, @@ -158,7 +158,7 @@ export function useUpdateSource() { onError: (error: unknown) => { toast({ title: t.common.error, - description: t(getApiErrorKey(error, t.sources.failedToUpdateSource)), + description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToUpdateSource), variant: 'destructive', }) }, @@ -185,7 +185,7 @@ export function useDeleteSource() { onError: (error: unknown) => { toast({ title: t.common.error, - description: t(getApiErrorKey(error, t.sources.failedToDeleteSource)), + description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToDeleteSource), variant: 'destructive', }) }, @@ -212,7 +212,7 @@ export function useFileUpload() { onError: (error: unknown) => { toast({ title: t.common.error, - description: t(getApiErrorKey(error, t.sources.failedToUploadFile)), + description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToUploadFile), variant: 'destructive', }) }, @@ -270,7 +270,7 @@ export function useRetrySource() { onError: (error: unknown) => { toast({ title: t.common.error, - description: t(getApiErrorKey(error, t.sources.failedToRetry)), + description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToRetry), variant: 'destructive', }) }, @@ -332,7 +332,7 @@ export function useAddSourcesToNotebook() { onError: (error: unknown) => { toast({ title: t.common.error, - description: t(getApiErrorKey(error, t.sources.failedToAddSourcesToNotebook)), + description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToAddSourcesToNotebook), variant: 'destructive', }) }, @@ -366,7 +366,7 @@ export function useRemoveSourceFromNotebook() { onError: (error: unknown) => { toast({ title: t.common.error, - description: t(getApiErrorKey(error, t.sources.failedToRemoveSourceFromNotebook)), + description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToRemoveSourceFromNotebook), variant: 'destructive', }) }, diff --git a/frontend/src/lib/hooks/use-transformations.ts b/frontend/src/lib/hooks/use-transformations.ts index de187d6..3022bd4 100644 --- a/frontend/src/lib/hooks/use-transformations.ts +++ b/frontend/src/lib/hooks/use-transformations.ts @@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { transformationsApi } from '@/lib/api/transformations' import { useToast } from '@/lib/hooks/use-toast' import { useTranslation } from '@/lib/hooks/use-translation' -import { getApiErrorKey } from '@/lib/utils/error-handler' +import { getApiErrorMessage } from '@/lib/utils/error-handler' import { CreateTransformationRequest, UpdateTransformationRequest, @@ -49,7 +49,7 @@ export function useCreateTransformation() { onError: (error: unknown) => { toast({ title: t.common.error, - description: t(getApiErrorKey(error, t.common.error)), + description: getApiErrorMessage(error, (key) => t(key)), variant: 'destructive', }) }, @@ -75,7 +75,7 @@ export function useUpdateTransformation() { onError: (error: unknown) => { toast({ title: t.common.error, - description: t(getApiErrorKey(error, t.common.error)), + description: getApiErrorMessage(error, (key) => t(key)), variant: 'destructive', }) }, @@ -99,7 +99,7 @@ export function useDeleteTransformation() { onError: (error: unknown) => { toast({ title: t.common.error, - description: t(getApiErrorKey(error, t.common.error)), + description: getApiErrorMessage(error, (key) => t(key)), variant: 'destructive', }) }, @@ -115,7 +115,7 @@ export function useExecuteTransformation() { onError: (error: unknown) => { toast({ title: t.common.error, - description: t(getApiErrorKey(error, t.common.error)), + description: getApiErrorMessage(error, (key) => t(key)), variant: 'destructive', }) }, @@ -146,7 +146,7 @@ export function useUpdateDefaultPrompt() { onError: (error: unknown) => { toast({ title: t.common.error, - description: t(getApiErrorKey(error, t.common.error)), + description: getApiErrorMessage(error, (key) => t(key)), variant: 'destructive', }) }, diff --git a/frontend/src/lib/hooks/useNotebookChat.ts b/frontend/src/lib/hooks/useNotebookChat.ts index 6a87afa..77f0f00 100644 --- a/frontend/src/lib/hooks/useNotebookChat.ts +++ b/frontend/src/lib/hooks/useNotebookChat.ts @@ -3,7 +3,7 @@ import { useState, useCallback, useEffect } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import { getApiErrorKey } from '@/lib/utils/error-handler' +import { getApiErrorMessage } from '@/lib/utils/error-handler' import { useTranslation } from '@/lib/hooks/use-translation' import { chatApi } from '@/lib/api/chat' import { QUERY_KEYS } from '@/lib/api/query-client' @@ -84,7 +84,7 @@ export function useNotebookChat({ notebookId, sources, notes, contextSelections }, onError: (err: unknown) => { const error = err as { response?: { data?: { detail?: string } }, message?: string }; - toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToCreateSession'))) + toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToCreateSession')) } }) @@ -105,7 +105,7 @@ export function useNotebookChat({ notebookId, sources, notes, contextSelections }, onError: (err: unknown) => { const error = err as { response?: { data?: { detail?: string } }, message?: string }; - toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToUpdateSession'))) + toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToUpdateSession')) } }) @@ -125,7 +125,7 @@ export function useNotebookChat({ notebookId, sources, notes, contextSelections }, onError: (err: unknown) => { const error = err as { response?: { data?: { detail?: string } }, message?: string }; - toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToDeleteSession'))) + toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToDeleteSession')) } }) @@ -197,7 +197,7 @@ export function useNotebookChat({ notebookId, sources, notes, contextSelections }) } catch (err: unknown) { const error = err as { response?: { data?: { detail?: string } }, message?: string }; - toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToCreateSession'))) + toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToCreateSession')) return } } @@ -230,7 +230,7 @@ export function useNotebookChat({ notebookId, sources, notes, contextSelections } catch (err: unknown) { const error = err as { response?: { data?: { detail?: string } }, message?: string }; console.error('Error sending message:', error) - toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToSendMessage'))) + toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToSendMessage')) // Remove optimistic message on error setMessages(prev => prev.filter(msg => !msg.id.startsWith('temp-'))) } finally { diff --git a/frontend/src/lib/hooks/useSourceChat.ts b/frontend/src/lib/hooks/useSourceChat.ts index 5024a87..4087b6b 100644 --- a/frontend/src/lib/hooks/useSourceChat.ts +++ b/frontend/src/lib/hooks/useSourceChat.ts @@ -3,7 +3,7 @@ import { useState, useCallback, useRef, useEffect } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import { getApiErrorKey } from '@/lib/utils/error-handler' +import { getApiErrorMessage } from '@/lib/utils/error-handler' import { useTranslation } from '@/lib/hooks/use-translation' import { sourceChatApi } from '@/lib/api/source-chat' import { @@ -64,7 +64,7 @@ export function useSourceChat(sourceId: string) { }, onError: (err: unknown) => { const error = err as { response?: { data?: { detail?: string } }, message?: string }; - toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToCreateSession'))) + toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToCreateSession')) } }) @@ -79,7 +79,7 @@ export function useSourceChat(sourceId: string) { }, onError: (err: unknown) => { const error = err as { response?: { data?: { detail?: string } }, message?: string }; - toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToUpdateSession'))) + toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToUpdateSession')) } }) @@ -97,7 +97,7 @@ export function useSourceChat(sourceId: string) { }, onError: (err: unknown) => { const error = err as { response?: { data?: { detail?: string } }, message?: string }; - toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToDeleteSession'))) + toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToDeleteSession')) } }) @@ -116,7 +116,7 @@ export function useSourceChat(sourceId: string) { } catch (err: unknown) { const error = err as { response?: { data?: { detail?: string } }, message?: string }; console.error('Failed to create chat session:', error) - toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToCreateSession'))) + toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToCreateSession')) return } } @@ -182,7 +182,11 @@ export function useSourceChat(sourceId: string) { throw new Error(data.message || 'Stream error') } } catch (e) { - console.error('Error parsing SSE data:', e) + if (e instanceof SyntaxError) { + console.error('Error parsing SSE data:', e) + } else { + throw e + } } } } @@ -190,7 +194,7 @@ export function useSourceChat(sourceId: string) { } catch (err: unknown) { const error = err as { response?: { data?: { detail?: string } }, message?: string }; console.error('Error sending message:', error) - toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToSendMessage'))) + toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToSendMessage')) // Remove optimistic messages on error setMessages(prev => prev.filter(msg => !msg.id.startsWith('temp-'))) } finally { diff --git a/frontend/src/lib/utils/error-handler.ts b/frontend/src/lib/utils/error-handler.ts index f9672d0..58de92d 100644 --- a/frontend/src/lib/utils/error-handler.ts +++ b/frontend/src/lib/utils/error-handler.ts @@ -51,6 +51,31 @@ export function getApiErrorKey(errorOrMessage: unknown, fallbackKey?: string): s return fallbackKey || "apiErrors.genericError"; } +/** + * Extracts the error message, looks up i18n mapping, and falls back to the + * backend-provided message when no mapping exists. This ensures user-friendly + * error messages from the backend are displayed directly in the UI. + */ +export function getApiErrorMessage( + errorOrMessage: unknown, + t: (key: string) => string, + fallbackKey?: string +): string { + const message = formatApiError(errorOrMessage); + if (!message) return fallbackKey ? t(fallbackKey) : t("apiErrors.genericError"); + + // Try exact match + if (ERROR_MAP[message]) return t(ERROR_MAP[message]); + + // Try partial match for dynamic messages (e.g., "Strategy model ...") + for (const [key, value] of Object.entries(ERROR_MAP)) { + if (message.startsWith(key)) return t(value); + } + + // No mapping: return backend message directly (backend is responsible for making it user-friendly) + return message; +} + /** * Formats a raw error from the API into a user-friendly (potentially translated) string. */ diff --git a/open_notebook/ai/models.py b/open_notebook/ai/models.py index 0a7ddbb..23b6ea8 100644 --- a/open_notebook/ai/models.py +++ b/open_notebook/ai/models.py @@ -11,6 +11,7 @@ from loguru import logger from open_notebook.database.repository import ensure_record_id, repo_query from open_notebook.domain.base import ObjectModel, RecordModel +from open_notebook.exceptions import ConfigurationError ModelType = Union[LanguageModel, EmbeddingModel, SpeechToTextModel, TextToSpeechModel] @@ -106,7 +107,7 @@ class ModelManager: try: model: Model = await Model.get(model_id) except Exception: - raise ValueError(f"Model with ID {model_id} not found") + raise ConfigurationError(f"Model with ID {model_id} not found") if not model.type or model.type not in [ "language", @@ -114,7 +115,7 @@ class ModelManager: "speech_to_text", "text_to_speech", ]: - raise ValueError(f"Invalid model type: {model.type}") + raise ConfigurationError(f"Invalid model type: {model.type}") # Build config from credential if linked, otherwise fall back to env vars config: dict = {} @@ -172,7 +173,7 @@ class ModelManager: config=config, ) else: - raise ValueError(f"Invalid model type: {model.type}") + raise ConfigurationError(f"Invalid model type: {model.type}") async def get_defaults(self) -> DefaultModels: """Get the default models configuration from database""" diff --git a/open_notebook/ai/provision.py b/open_notebook/ai/provision.py index 762a5b1..f3dc4f6 100644 --- a/open_notebook/ai/provision.py +++ b/open_notebook/ai/provision.py @@ -3,6 +3,7 @@ from langchain_core.language_models.chat_models import BaseChatModel from loguru import logger from open_notebook.ai.models import model_manager +from open_notebook.exceptions import ConfigurationError from open_notebook.utils import token_count @@ -41,7 +42,7 @@ async def provision_langchain_model( f"model_id={model_id}, default_type={default_type}. " f"Please check Settings → Models and ensure a default model is configured for '{default_type}'." ) - raise ValueError( + raise ConfigurationError( f"No model configured for {selection_reason}. " f"Please go to Settings → Models and configure a default model for '{default_type}'." ) @@ -52,7 +53,7 @@ async def provision_langchain_model( f"Selection reason: {selection_reason}. " f"model_id={model_id}, default_type={default_type}." ) - raise ValueError( + raise ConfigurationError( f"Model is not a LanguageModel: {model}. " f"Please check that the model configured for '{default_type}' is a language model, not an embedding or speech model." ) diff --git a/open_notebook/graphs/ask.py b/open_notebook/graphs/ask.py index 2bdfc20..51806ba 100644 --- a/open_notebook/graphs/ask.py +++ b/open_notebook/graphs/ask.py @@ -11,7 +11,9 @@ from typing_extensions import TypedDict from open_notebook.ai.provision import provision_langchain_model from open_notebook.domain.notebook import vector_search +from open_notebook.exceptions import OpenNotebookError from open_notebook.utils import clean_thinking_content +from open_notebook.utils.error_classifier import classify_error class SubGraphState(TypedDict): @@ -46,33 +48,39 @@ class ThreadState(TypedDict): async def call_model_with_messages(state: ThreadState, config: RunnableConfig) -> dict: - parser = PydanticOutputParser(pydantic_object=Strategy) - system_prompt = Prompter(prompt_template="ask/entry", parser=parser).render( # type: ignore[arg-type] - data=state # type: ignore[arg-type] - ) - model = await provision_langchain_model( - system_prompt, - config.get("configurable", {}).get("strategy_model"), - "tools", - max_tokens=2000, - structured=dict(type="json"), - ) - # model = model.bind_tools(tools) - # First get the raw response from the model - ai_message = await model.ainvoke(system_prompt) + try: + parser = PydanticOutputParser(pydantic_object=Strategy) + system_prompt = Prompter(prompt_template="ask/entry", parser=parser).render( # type: ignore[arg-type] + data=state # type: ignore[arg-type] + ) + model = await provision_langchain_model( + system_prompt, + config.get("configurable", {}).get("strategy_model"), + "tools", + max_tokens=2000, + structured=dict(type="json"), + ) + # model = model.bind_tools(tools) + # First get the raw response from the model + ai_message = await model.ainvoke(system_prompt) - # Clean the thinking content from the response - message_content = ( - ai_message.content - if isinstance(ai_message.content, str) - else str(ai_message.content) - ) - cleaned_content = clean_thinking_content(message_content) + # Clean the thinking content from the response + message_content = ( + ai_message.content + if isinstance(ai_message.content, str) + else str(ai_message.content) + ) + cleaned_content = clean_thinking_content(message_content) - # Parse the cleaned JSON content - strategy = parser.parse(cleaned_content) + # Parse the cleaned JSON content + strategy = parser.parse(cleaned_content) - return {"strategy": strategy} + return {"strategy": strategy} + except OpenNotebookError: + raise + except Exception as e: + error_class, user_message = classify_error(e) + raise error_class(user_message) from e async def trigger_queries(state: ThreadState, config: RunnableConfig): @@ -91,47 +99,59 @@ async def trigger_queries(state: ThreadState, config: RunnableConfig): async def provide_answer(state: SubGraphState, config: RunnableConfig) -> dict: - payload = state - # if state["type"] == "text": - # results = text_search(state["term"], 10, True, True) - # else: - results = await vector_search(state["term"], 10, True, True) - if len(results) == 0: - return {"answers": []} - payload["results"] = results - ids = [r["id"] for r in results] - payload["ids"] = ids - system_prompt = Prompter(prompt_template="ask/query_process").render(data=payload) # type: ignore[arg-type] - model = await provision_langchain_model( - system_prompt, - config.get("configurable", {}).get("answer_model"), - "tools", - max_tokens=2000, - ) - ai_message = await model.ainvoke(system_prompt) - ai_content = ( - ai_message.content - if isinstance(ai_message.content, str) - else str(ai_message.content) - ) - return {"answers": [clean_thinking_content(ai_content)]} + try: + payload = state + # if state["type"] == "text": + # results = text_search(state["term"], 10, True, True) + # else: + results = await vector_search(state["term"], 10, True, True) + if len(results) == 0: + return {"answers": []} + payload["results"] = results + ids = [r["id"] for r in results] + payload["ids"] = ids + system_prompt = Prompter(prompt_template="ask/query_process").render(data=payload) # type: ignore[arg-type] + model = await provision_langchain_model( + system_prompt, + config.get("configurable", {}).get("answer_model"), + "tools", + max_tokens=2000, + ) + ai_message = await model.ainvoke(system_prompt) + ai_content = ( + ai_message.content + if isinstance(ai_message.content, str) + else str(ai_message.content) + ) + return {"answers": [clean_thinking_content(ai_content)]} + except OpenNotebookError: + raise + except Exception as e: + error_class, user_message = classify_error(e) + raise error_class(user_message) from e async def write_final_answer(state: ThreadState, config: RunnableConfig) -> dict: - system_prompt = Prompter(prompt_template="ask/final_answer").render(data=state) # type: ignore[arg-type] - model = await provision_langchain_model( - system_prompt, - config.get("configurable", {}).get("final_answer_model"), - "tools", - max_tokens=2000, - ) - ai_message = await model.ainvoke(system_prompt) - final_content = ( - ai_message.content - if isinstance(ai_message.content, str) - else str(ai_message.content) - ) - return {"final_answer": clean_thinking_content(final_content)} + try: + system_prompt = Prompter(prompt_template="ask/final_answer").render(data=state) # type: ignore[arg-type] + model = await provision_langchain_model( + system_prompt, + config.get("configurable", {}).get("final_answer_model"), + "tools", + max_tokens=2000, + ) + ai_message = await model.ainvoke(system_prompt) + final_content = ( + ai_message.content + if isinstance(ai_message.content, str) + else str(ai_message.content) + ) + return {"final_answer": clean_thinking_content(final_content)} + except OpenNotebookError: + raise + except Exception as e: + error_class, user_message = classify_error(e) + raise error_class(user_message) from e agent_state = StateGraph(ThreadState) diff --git a/open_notebook/graphs/chat.py b/open_notebook/graphs/chat.py index 4c32570..34469c4 100644 --- a/open_notebook/graphs/chat.py +++ b/open_notebook/graphs/chat.py @@ -13,7 +13,9 @@ from typing_extensions import TypedDict from open_notebook.ai.provision import provision_langchain_model from open_notebook.config import LANGGRAPH_CHECKPOINT_FILE from open_notebook.domain.notebook import Notebook +from open_notebook.exceptions import OpenNotebookError from open_notebook.utils import clean_thinking_content +from open_notebook.utils.error_classifier import classify_error class ThreadState(TypedDict): @@ -25,59 +27,65 @@ class ThreadState(TypedDict): def call_model_with_messages(state: ThreadState, config: RunnableConfig) -> dict: - system_prompt = Prompter(prompt_template="chat/system").render(data=state) # type: ignore[arg-type] - payload = [SystemMessage(content=system_prompt)] + state.get("messages", []) - model_id = config.get("configurable", {}).get("model_id") or state.get( - "model_override" - ) - - # Handle async model provisioning from sync context - def run_in_new_loop(): - """Run the async function in a new event loop""" - new_loop = asyncio.new_event_loop() - try: - asyncio.set_event_loop(new_loop) - return new_loop.run_until_complete( - provision_langchain_model( - str(payload), model_id, "chat", max_tokens=8192 - ) - ) - finally: - new_loop.close() - asyncio.set_event_loop(None) - try: - # Try to get the current event loop - asyncio.get_running_loop() - # If we're in an event loop, run in a thread with a new loop - import concurrent.futures - - with concurrent.futures.ThreadPoolExecutor() as executor: - future = executor.submit(run_in_new_loop) - model = future.result() - except RuntimeError: - # No event loop running, safe to use asyncio.run() - model = asyncio.run( - provision_langchain_model( - str(payload), - model_id, - "chat", - max_tokens=8192, - ) + system_prompt = Prompter(prompt_template="chat/system").render(data=state) # type: ignore[arg-type] + payload = [SystemMessage(content=system_prompt)] + state.get("messages", []) + model_id = config.get("configurable", {}).get("model_id") or state.get( + "model_override" ) - ai_message = model.invoke(payload) + # Handle async model provisioning from sync context + def run_in_new_loop(): + """Run the async function in a new event loop""" + new_loop = asyncio.new_event_loop() + try: + asyncio.set_event_loop(new_loop) + return new_loop.run_until_complete( + provision_langchain_model( + str(payload), model_id, "chat", max_tokens=8192 + ) + ) + finally: + new_loop.close() + asyncio.set_event_loop(None) - # Clean thinking content from AI response (e.g., ... tags) - content = ( - ai_message.content - if isinstance(ai_message.content, str) - else str(ai_message.content) - ) - cleaned_content = clean_thinking_content(content) - cleaned_message = ai_message.model_copy(update={"content": cleaned_content}) + try: + # Try to get the current event loop + asyncio.get_running_loop() + # If we're in an event loop, run in a thread with a new loop + import concurrent.futures - return {"messages": cleaned_message} + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit(run_in_new_loop) + model = future.result() + except RuntimeError: + # No event loop running, safe to use asyncio.run() + model = asyncio.run( + provision_langchain_model( + str(payload), + model_id, + "chat", + max_tokens=8192, + ) + ) + + ai_message = model.invoke(payload) + + # Clean thinking content from AI response (e.g., ... tags) + content = ( + ai_message.content + if isinstance(ai_message.content, str) + else str(ai_message.content) + ) + cleaned_content = clean_thinking_content(content) + cleaned_message = ai_message.model_copy(update={"content": cleaned_content}) + + return {"messages": cleaned_message} + except OpenNotebookError: + raise + except Exception as e: + error_class, user_message = classify_error(e) + raise error_class(user_message) from e conn = sqlite3.connect( diff --git a/open_notebook/graphs/source_chat.py b/open_notebook/graphs/source_chat.py index 142efa2..843f605 100644 --- a/open_notebook/graphs/source_chat.py +++ b/open_notebook/graphs/source_chat.py @@ -13,8 +13,10 @@ from typing_extensions import TypedDict from open_notebook.ai.provision import provision_langchain_model from open_notebook.config import LANGGRAPH_CHECKPOINT_FILE from open_notebook.domain.notebook import Source, SourceInsight +from open_notebook.exceptions import OpenNotebookError from open_notebook.utils import clean_thinking_content from open_notebook.utils.context_builder import ContextBuilder +from open_notebook.utils.error_classifier import classify_error class SourceChatState(TypedDict): @@ -39,6 +41,18 @@ def call_model_with_source_context( 3. Handles model provisioning with override support 4. Tracks context indicators for referenced insights/content """ + try: + return _call_model_with_source_context_inner(state, config) + except OpenNotebookError: + raise + except Exception as e: + error_class, user_message = classify_error(e) + raise error_class(user_message) from e + + +def _call_model_with_source_context_inner( + state: SourceChatState, config: RunnableConfig +) -> dict: source_id = state.get("source_id") if not source_id: raise ValueError("source_id is required in state") diff --git a/open_notebook/graphs/transformation.py b/open_notebook/graphs/transformation.py index 1dc1f56..8639a37 100644 --- a/open_notebook/graphs/transformation.py +++ b/open_notebook/graphs/transformation.py @@ -7,7 +7,9 @@ from typing_extensions import TypedDict from open_notebook.ai.provision import provision_langchain_model from open_notebook.domain.notebook import Source from open_notebook.domain.transformation import DefaultPrompts, Transformation +from open_notebook.exceptions import OpenNotebookError from open_notebook.utils import clean_thinking_content +from open_notebook.utils.error_classifier import classify_error class TransformationState(TypedDict): @@ -23,41 +25,48 @@ async def run_transformation(state: dict, config: RunnableConfig) -> dict: content = state.get("input_text") assert source or content, "No content to transform" transformation: Transformation = state["transformation"] - if not content: - content = source.full_text - transformation_template_text = transformation.prompt - default_prompts: DefaultPrompts = DefaultPrompts(transformation_instructions=None) - if default_prompts.transformation_instructions: - transformation_template_text = f"{default_prompts.transformation_instructions}\n\n{transformation_template_text}" - transformation_template_text = f"{transformation_template_text}\n\n# INPUT" + try: + if not content: + content = source.full_text + transformation_template_text = transformation.prompt + default_prompts: DefaultPrompts = DefaultPrompts(transformation_instructions=None) + if default_prompts.transformation_instructions: + transformation_template_text = f"{default_prompts.transformation_instructions}\n\n{transformation_template_text}" - system_prompt = Prompter(template_text=transformation_template_text).render( - data=state - ) - content_str = str(content) if content else "" - payload = [SystemMessage(content=system_prompt), HumanMessage(content=content_str)] - chain = await provision_langchain_model( - str(payload), - config.get("configurable", {}).get("model_id"), - "transformation", - max_tokens=8192, - ) + transformation_template_text = f"{transformation_template_text}\n\n# INPUT" - response = await chain.ainvoke(payload) + system_prompt = Prompter(template_text=transformation_template_text).render( + data=state + ) + content_str = str(content) if content else "" + payload = [SystemMessage(content=system_prompt), HumanMessage(content=content_str)] + chain = await provision_langchain_model( + str(payload), + config.get("configurable", {}).get("model_id"), + "transformation", + max_tokens=8192, + ) - # Clean thinking content from the response - response_content = ( - response.content if isinstance(response.content, str) else str(response.content) - ) - cleaned_content = clean_thinking_content(response_content) + response = await chain.ainvoke(payload) - if source: - await source.add_insight(transformation.title, cleaned_content) + # Clean thinking content from the response + response_content = ( + response.content if isinstance(response.content, str) else str(response.content) + ) + cleaned_content = clean_thinking_content(response_content) - return { - "output": cleaned_content, - } + if source: + await source.add_insight(transformation.title, cleaned_content) + + return { + "output": cleaned_content, + } + except OpenNotebookError: + raise + except Exception as e: + error_class, user_message = classify_error(e) + raise error_class(user_message) from e agent_state = StateGraph(TransformationState) diff --git a/open_notebook/utils/error_classifier.py b/open_notebook/utils/error_classifier.py new file mode 100644 index 0000000..1f4fc20 --- /dev/null +++ b/open_notebook/utils/error_classifier.py @@ -0,0 +1,90 @@ +""" +Error classification utility for LLM provider errors. + +Maps raw exceptions from AI providers/Esperanto/LangChain to user-friendly +error messages and appropriate exception types. +""" + +from loguru import logger + +from open_notebook.exceptions import ( + AuthenticationError, + ConfigurationError, + ExternalServiceError, + NetworkError, + OpenNotebookError, + RateLimitError, +) + +# Classification rules: (keywords, exception_class, user_message or None to pass through) +_CLASSIFICATION_RULES: list[tuple[list[str], type[OpenNotebookError], str | None]] = [ + # Authentication errors + ( + ["authentication", "unauthorized", "invalid api key", "invalid_api_key", "401"], + AuthenticationError, + "Authentication failed. Please check your API key in Settings -> Credentials.", + ), + # Rate limit errors + ( + ["rate limit", "rate_limit", "429", "too many requests", "quota exceeded"], + RateLimitError, + "Rate limit exceeded. Please wait a moment and try again.", + ), + # Model not found (pass through original message) + ( + ["model not found", "does not exist", "model_not_found"], + ConfigurationError, + None, + ), + # Configuration errors from provision.py (pass through) + ( + ["no model configured", "please go to settings"], + ConfigurationError, + None, + ), + # Network errors + ( + ["connecterror", "timeoutexception", "connection refused", "connection error", "timed out", "timeout"], + NetworkError, + "Could not connect to the AI provider. Please check your network connection and provider URL.", + ), + # Context length errors + ( + ["context length", "token limit", "maximum context", "context_length_exceeded", "max_tokens"], + ExternalServiceError, + "Content too large for the selected model. Try using a smaller selection or a model with a larger context window.", + ), + # Provider availability errors + ( + ["500", "502", "503", "service unavailable", "overloaded", "internal server error"], + ExternalServiceError, + "The AI provider is temporarily unavailable. Please try again in a few minutes.", + ), +] + + +def classify_error(exception: BaseException) -> tuple[type[OpenNotebookError], str]: + """ + Classify a raw exception into a user-friendly error type and message. + + Args: + exception: Any exception from LLM providers/Esperanto/LangChain + + Returns: + Tuple of (exception_class, user_friendly_message) + """ + error_str = str(exception).lower() + error_type_name = type(exception).__name__.lower() + combined = f"{error_type_name}: {error_str}" + + for keywords, exc_class, message in _CLASSIFICATION_RULES: + for keyword in keywords: + if keyword in combined: + user_message = message if message is not None else str(exception) + return exc_class, user_message + + # Unclassified error - log for future improvement + logger.warning( + f"Unclassified LLM error ({type(exception).__name__}): {exception}" + ) + return ExternalServiceError, f"AI service error: {exception}" diff --git a/pyproject.toml b/pyproject.toml index 1fded1a..caa3ade 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "open-notebook" -version = "1.7.1" +version = "1.7.2" description = "An open source implementation of a research assistant, inspired by Google Notebook LM" authors = [ {name = "Luis Novo", email = "lfnovo@gmail.com"}