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..076340b 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""" @@ -254,7 +255,7 @@ class ModelManager: try: return await self.get_model(model_id, **kwargs) - except ValueError as e: + except (ValueError, ConfigurationError) as e: logger.error( f"Failed to load default model for type '{model_type}': {e}. " f"The configured model_id '{model_id}' may have been deleted or misconfigured. " 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..8d1e319 --- /dev/null +++ b/open_notebook/utils/error_classifier.py @@ -0,0 +1,97 @@ +""" +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 _truncate(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: {_truncate(str(exception))}" + + +def _truncate(text: str, max_length: int = 200) -> str: + """Truncate text to max_length to avoid leaking verbose internal details.""" + if len(text) <= max_length: + return text + return text[:max_length] + "..." 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"} diff --git a/uv.lock b/uv.lock index 06cf623..ef336aa 100644 --- a/uv.lock +++ b/uv.lock @@ -2095,7 +2095,7 @@ wheels = [ [[package]] name = "open-notebook" -version = "1.7.1" +version = "1.7.2" source = { editable = "." } dependencies = [ { name = "ai-prompter" },