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"}