Merge pull request #590 from lfnovo/feat/error-clarity-handling

feat: improve error clarity for LLM provider failures
This commit is contained in:
Luis Novo 2026-02-16 16:30:51 -03:00 committed by GitHub
commit 7070568941
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 489 additions and 188 deletions

View file

@ -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

View file

@ -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

View file

@ -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"])

View file

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

View file

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

View file

@ -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(

View file

@ -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",
},
)

View file

@ -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",
},
)

View file

@ -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])

View file

@ -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',
})
},

View file

@ -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',
})
},

View file

@ -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 {

View file

@ -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 {

View file

@ -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.
*/

View file

@ -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. "

View file

@ -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."
)

View file

@ -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)

View file

@ -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., <think>...</think> 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., <think>...</think> 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(

View file

@ -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")

View file

@ -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)

View file

@ -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] + "..."

View file

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

View file

@ -2095,7 +2095,7 @@ wheels = [
[[package]]
name = "open-notebook"
version = "1.7.1"
version = "1.7.2"
source = { editable = "." }
dependencies = [
{ name = "ai-prompter" },