Skip to content

Core

src.core

src.core.api

FastAPI application factory and lifecycle management.

This module provides the core FastAPI application configuration including: - Router registration for all domain endpoints - Exception handler setup for application-level errors - CORS middleware configuration for cross-origin requests - Application lifespan management (startup/shutdown hooks)

The lifespan context manager orchestrates: 1. Engine initialization and selection (MLX/CUDA/CPU) 2. Database table creation and seeding 3. Background cleanup tasks for model memory management

Architecture

Application Bootstrap Flow: ┌─────────────────────────────────────────────────────────────┐ │ lifespan() startup: │ │ 1. Select engine (MLX_Engine/CUDA_Engine/CPU_Engine) │ │ 2. Create SQLAlchemy tables │ │ 3. Seed database with default models │ │ 4. Start cleanup task (30s interval) │ └─────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────┐ │ register_routers(): │ │ - /erudi/llms → Model management │ │ - /erudi/conversations → Chat/streaming │ │ - /erudi/knowledge_base → RAG/vectorization │ │ - /erudi/arena → Model comparison │ │ - /erudi/hardware → System monitoring │ │ - /erudi/health → Health checks │ │ - /erudi/diagnostics → Bug-report environment summary │ │ - /erudi/startup → Initialization state │ └─────────────────────────────────────────────────────────────┘

Example

Create and configure the FastAPI application::

from fastapi import FastAPI
from src.core.api import (
    register_routers,
    add_exception_handlers,
    add_middleware,
    lifespan
)

app = FastAPI(lifespan=lifespan)
register_routers(app)
add_exception_handlers(app)
add_middleware(app)

# Run with: uvicorn src.main:app --reload
Note

The lifespan pattern ensures proper resource cleanup even if the application crashes or is forcefully terminated.

RequestLoggingMiddleware(app)

Pure ASGI middleware: request-id propagation + one access-log line.

Deliberately NOT BaseHTTPMiddleware: that wrapper re-buffers streaming responses through an internal queue (breaking SSE chunk pacing) and adds an extra task per request. Here the per-chunk cost is a single message type comparison — response bodies are never inspected or buffered.

Per request: - Reads X-Request-ID (case-insensitive) or generates be-<8 hex>. - Stores the id in request_id_var so every log line emitted while handling the request is tagged with it (see src.core.logging). - Echoes the id back as the X-Request-ID response header. - When the response body completes (final http.response.body chunk), logs exactly one line: HTTP <METHOD> <path> -> <status> in <ms>ms at INFO — DEBUG for polling endpoints (paths containing /health or ending in /status) to keep the log readable. Logging at body-complete rather than after the ASGI app returns keeps starlette's post-response BackgroundTasks out of the reported duration (#204); a slow background tail (>1s) is noted separately at DEBUG.

Source code in backend/src/core/api.py
def __init__(self, app):
    self.app = app

add_exception_handlers(app)

Attach application-level exception handlers to FastAPI.

Registers custom exception handlers for application-level errors: - AppBaseException and its subclasses (ModelNotFoundException, InvalidInputException, EngineException, ...) - plain Exception as a last-resort fallback, returning the same structured 500 JSON shape and logging the traceback with the request id.

Parameters:

Name Type Description Default
app FastAPI

The FastAPI application instance to attach handlers to.

required

Returns:

Type Description
None

None. Modifies the app instance in-place.

Example

::

from fastapi import FastAPI
from src.core.api import add_exception_handlers

app = FastAPI()
add_exception_handlers(app)
# Exceptions now return structured JSON responses with proper HTTP codes
Note

See src.core.exceptions.app_base_exception_handler for response format.

Source code in backend/src/core/api.py
def add_exception_handlers(app: FastAPI) -> None:
    """Attach application-level exception handlers to FastAPI.

    Registers custom exception handlers for application-level errors:
    - AppBaseException and its subclasses (ModelNotFoundException,
      InvalidInputException, EngineException, ...)
    - plain Exception as a last-resort fallback, returning the same
      structured 500 JSON shape and logging the traceback with the
      request id.

    Args:
        app: The FastAPI application instance to attach handlers to.

    Returns:
        None. Modifies the app instance in-place.

    Example:
        ::

            from fastapi import FastAPI
            from src.core.api import add_exception_handlers

            app = FastAPI()
            add_exception_handlers(app)
            # Exceptions now return structured JSON responses with proper HTTP codes

    Note:
        See src.core.exceptions.app_base_exception_handler for response format.
    """
    app.add_exception_handler(AppBaseException, app_base_exception_handler)
    app.add_exception_handler(Exception, unhandled_exception_handler)

add_middleware(app)

Configure the middleware stack: host pinning + CORS + request logging.

The API binds 127.0.0.1 without auth (single-user desktop), so the browser's same-origin machinery is the wall between a malicious website and the user's data (#89): CORS grants are restricted to the two real renderer origins with credentials off, and TrustedHostMiddleware rejects non-local Host headers (DNS rebinding). Request logging is added last so it sits outermost and also covers CORS-short-circuited requests.

Parameters:

Name Type Description Default
app FastAPI

The FastAPI application instance to configure.

required

Returns:

Type Description
None

None. Modifies the app instance in-place.

Source code in backend/src/core/api.py
def add_middleware(app: FastAPI) -> None:
    """Configure the middleware stack: host pinning + CORS + request logging.

    The API binds 127.0.0.1 without auth (single-user desktop), so the
    browser's same-origin machinery is the wall between a malicious website
    and the user's data (#89): CORS grants are restricted to the two real
    renderer origins with credentials off, and TrustedHostMiddleware rejects
    non-local Host headers (DNS rebinding). Request logging is added last so
    it sits outermost and also covers CORS-short-circuited requests.

    Args:
        app: The FastAPI application instance to configure.

    Returns:
        None. Modifies the app instance in-place.
    """
    app.add_middleware(TrustedHostMiddleware, allowed_hosts=LOCAL_HOSTS)
    app.add_middleware(
        CORSMiddleware,
        allow_origins=RENDERER_ORIGINS,
        allow_credentials=False,
        allow_methods=["*"],
        allow_headers=["*"],
        expose_headers=["X-Request-ID"],
    )
    # Added last -> outermost: every request gets an id and one access line.
    app.add_middleware(RequestLoggingMiddleware)

apply_inference_backend_preference()

Replace the auto-detected CUDA engine with CPU when the user asked for it.

Runs AFTER the migrations and the catalog population, which is the earliest point where user_settings.inference_backend is guaranteed to exist and the latest point that still precedes any inference. ERUDI_FORCE_CPU wins over the setting: the developer override already returned CPU_Engine from get_engine() and there is nothing left to swap.

A no-op unless the selected engine is CUDA_Engine and the preference is "cpu" -- MLX has no CPU story on Apple Silicon and an already-CPU machine has nothing to change.

Source code in backend/src/core/api.py
def apply_inference_backend_preference() -> None:
    """Replace the auto-detected CUDA engine with CPU when the user asked for it.

    Runs AFTER the migrations and the catalog population, which is the earliest
    point where ``user_settings.inference_backend`` is guaranteed to exist and
    the latest point that still precedes any inference. ``ERUDI_FORCE_CPU``
    wins over the setting: the developer override already returned
    ``CPU_Engine`` from ``get_engine()`` and there is nothing left to swap.

    A no-op unless the selected engine is ``CUDA_Engine`` and the preference is
    ``"cpu"`` -- MLX has no CPU story on Apple Silicon and an already-CPU
    machine has nothing to change.
    """
    from src.engines.cpu_engine import CPU_Engine
    from src.engines.cuda_engine import CUDA_Engine
    from src.domains.user_settings.repository import read_inference_backend

    if os.environ.get("ERUDI_FORCE_CPU"):
        return
    if config.LLM_Engine is not CUDA_Engine:
        return
    if read_inference_backend() != "cpu":
        return
    logger.info("Inference backend preference is 'cpu': switching CUDA_Engine to CPU_Engine.")
    config.LLM_Engine = CPU_Engine

emit_engine_notice(app)

Emit an engine_notice when the selected GPU cannot run our CUDA build.

Two NVML reads (see src/engines/cuda_compatibility.py); nothing is applied, the frontend renders the verdict and the user decides. Silent on every engine but CUDA, and silent when the machine checks out.

The event goes out on the same newline-JSON stdout channel as the launcher's own lifecycle events, which frontend/src/main.js forwards wholesale to the renderer. app.state.emit_event overrides the emitter (tests capture it there, exactly as they capture phases through app.state.emit_phase).

Source code in backend/src/core/api.py
def emit_engine_notice(app: FastAPI) -> None:
    """Emit an ``engine_notice`` when the selected GPU cannot run our CUDA build.

    Two NVML reads (see ``src/engines/cuda_compatibility.py``); nothing is
    applied, the frontend renders the verdict and the user decides. Silent on
    every engine but CUDA, and silent when the machine checks out.

    The event goes out on the same newline-JSON stdout channel as the launcher's
    own lifecycle events, which ``frontend/src/main.js`` forwards wholesale to
    the renderer. ``app.state.emit_event`` overrides the emitter (tests capture
    it there, exactly as they capture phases through ``app.state.emit_phase``).
    """
    from src.engines.cuda_engine import CUDA_Engine

    if config.LLM_Engine is not CUDA_Engine:
        return
    notice = cuda_preflight_notice()
    if notice is None:
        return
    emit = getattr(app.state, "emit_event", None) or emit_event
    emit(notice)

lifespan(app) async

Manage application lifespan with startup and shutdown hooks.

This async context manager orchestrates the full application lifecycle: - Startup: Engine selection, database initialization, cleanup task scheduling - Shutdown: Task cancellation, engine cleanup, resource deallocation

Parameters:

Name Type Description Default
app FastAPI

The FastAPI application instance (passed by FastAPI framework).

required

Yields:

Type Description

None. Control is yielded to the FastAPI application for request handling.

Raises:

Type Description
Exception

Propagates any exception during startup (database errors, engine initialization failures). Application will not start if startup fails.

Example

::

from fastapi import FastAPI
from src.core.api import lifespan

app = FastAPI(lifespan=lifespan)
# Engine initialized automatically on startup
# Cleanup runs automatically on shutdown
Note

The cleanup task runs every 30 seconds to free a model idle for over 300 seconds. See BaseEngine.start_cleanup_task() for details.

Lifecycle Flow
  1. Start the embedded PostgreSQL cluster (postgres_runtime)
  2. Bind the SQLAlchemy engine/session factory (init_database)
  3. Select engine via platform detection (BaseEngine.get_engine)
  4. Migrate the schema to head (Alembic, forward-only)
  5. Seed database with default models (startup_populate_database)
  6. Apply the persisted inference-backend preference and run the CUDA pre-flight (apply_inference_backend_preference / emit_engine_notice)
  7. Open the LangGraph checkpointer (app.state.checkpointer)
  8. Start the cleanup background task (30s interval) and the memory pressure monitor (60s interval)
  9. [YIELD] → Application handles requests
  10. Shutdown (reverse order): cleanup task → pressure monitor (awaited) → pending prefix-cache reset drained → engine → checkpointer → embedded PostgreSQL cluster last
Source code in backend/src/core/api.py
@asynccontextmanager
async def lifespan(app: FastAPI):
    """Manage application lifespan with startup and shutdown hooks.

    This async context manager orchestrates the full application lifecycle:
    - Startup: Engine selection, database initialization, cleanup task scheduling
    - Shutdown: Task cancellation, engine cleanup, resource deallocation

    Args:
        app: The FastAPI application instance (passed by FastAPI framework).

    Yields:
        None. Control is yielded to the FastAPI application for request handling.

    Raises:
        Exception: Propagates any exception during startup (database errors,
            engine initialization failures). Application will not start if
            startup fails.

    Example:
        ::

            from fastapi import FastAPI
            from src.core.api import lifespan

            app = FastAPI(lifespan=lifespan)
            # Engine initialized automatically on startup
            # Cleanup runs automatically on shutdown

    Note:
        The cleanup task runs every 30 seconds to free a model idle for over 300 seconds.
        See BaseEngine.start_cleanup_task() for details.

    Lifecycle Flow:
        1. Start the embedded PostgreSQL cluster (postgres_runtime)
        2. Bind the SQLAlchemy engine/session factory (init_database)
        3. Select engine via platform detection (BaseEngine.get_engine)
        4. Migrate the schema to head (Alembic, forward-only)
        5. Seed database with default models (startup_populate_database)
        6. Apply the persisted inference-backend preference and run the CUDA
           pre-flight (apply_inference_backend_preference / emit_engine_notice)
        7. Open the LangGraph checkpointer (app.state.checkpointer)
        8. Start the cleanup background task (30s interval) and the memory
           pressure monitor (60s interval)
        9. **[YIELD]** → Application handles requests
        10. Shutdown (reverse order): cleanup task → pressure monitor (awaited)
            → pending prefix-cache reset drained → engine → checkpointer
            → embedded PostgreSQL cluster last
    """
    # Before yield comes the startup code
    logger.info("==== Starting up... ====")
    # Startup-progress phases for the Electron loader. run.py injects the emitter
    # on app.state (same process/stdout); absent (plain uvicorn in dev/tests) this
    # is a no-op. Phases are informational only — readiness is still the `ready`
    # event / a confirming health check, never a phase.
    _emit_phase = getattr(app.state, "emit_phase", None)

    def _phase(name: str) -> None:
        if _emit_phase is not None:
            _emit_phase(name)

    # A startup failure is logged HERE, with its traceback, before it
    # propagates: uvicorn reports "Application startup failed" through its own
    # logger, which writes to stderr and never to backend.log, so without this
    # record the backend log of a backend that would not start says nothing
    # about why. run.py then turns the dead server thread into a
    # CRASH_BEFORE_READY event for Electron.
    try:
        await _start_services(app, _phase)
    except Exception as exc:
        logger.error(f"Startup failed: {exc}", exc_info=exc)
        raise
    yield
    logger.info("==== Shutting down... ====")
    wire_backfill_task = getattr(app.state, "wire_backfill_task", None)
    if wire_backfill_task is not None and not wire_backfill_task.done():
        wire_backfill_task.cancel()
    await stop_watchdog()
    config.LLM_Engine.stop_cleanup_task()
    # The monitor's sample in flight finishes first (it is shielded), and a
    # reset a torn-down turn left behind is drained: the child is never
    # stopped under a request still talking to it.
    await config.LLM_Engine.stop_pressure_task()
    await drain_pending_reset()
    config.LLM_Engine.cleanup()
    await app.state.checkpointer_cm.__aexit__(None, None, None)
    close_kb_store()
    # Cluster stops LAST: every DB consumer above must be closed first.
    stop_postgres(app.state.postgres)

register_routers(app)

Register all domain routers to the FastAPI application.

Attaches endpoint routers from each domain module with the /erudi prefix. Order of registration does not affect routing behavior due to FastAPI's path-matching algorithm.

Parameters:

Name Type Description Default
app FastAPI

The FastAPI application instance to register routers to.

required

Returns:

Type Description
None

None. Modifies the app instance in-place.

Example

::

from fastapi import FastAPI
from src.core.api import register_routers

app = FastAPI()
register_routers(app)
# All domain endpoints now accessible under /erudi/*
Source code in backend/src/core/api.py
def register_routers(app: FastAPI) -> None:
    """Register all domain routers to the FastAPI application.

    Attaches endpoint routers from each domain module with the /erudi prefix.
    Order of registration does not affect routing behavior due to FastAPI's
    path-matching algorithm.

    Args:
        app: The FastAPI application instance to register routers to.

    Returns:
        None. Modifies the app instance in-place.

    Example:
        ::

            from fastapi import FastAPI
            from src.core.api import register_routers

            app = FastAPI()
            register_routers(app)
            # All domain endpoints now accessible under /erudi/*
    """

    app.include_router(llms_router, prefix="/erudi")
    app.include_router(hardware_router, prefix="/erudi")
    app.include_router(arena_router, prefix="/erudi")
    app.include_router(knowledge_base_router, prefix="/erudi")
    app.include_router(conversations_router, prefix="/erudi")
    app.include_router(health_router, prefix="/erudi")
    app.include_router(startup_router, prefix="/erudi")
    app.include_router(user_settings_router, prefix="/erudi")
    app.include_router(diagnostics_router, prefix="/erudi")

src.core.config

Global application configuration and environment management.

This module centralizes configuration constants, environment variables, and directory paths used across Erudi. It ensures consistent path resolution in both editable source checkouts and PyInstaller bundles.

Configuration Categories
  • Authentication: HuggingFace token for model downloads.
  • Directories: Data storage, model cache, logs, vector indexes.
  • Database: embedded PostgreSQL cluster data directory.
  • Engine: Runtime LLM engine instance (MLX/CUDA/CPU).
Directory Structure

Development mode (backend/)::

backend/
├── data/
│   ├── postgres/             # Embedded PostgreSQL cluster (pgserver)
│   ├── models/               # Downloaded models
│   ├── models_cache/         # HuggingFace download cache
│   └── training_datasets/    # Fine-tuning datasets
└── logs/                     # Structured application logs

Production mode (PyInstaller bundle): Runtime directories are resolved dynamically by src.launcher.runtime_paths so packaged builds write to user-writable locations (AppData on Windows, Library folders on macOS, XDG paths on Linux).

Environment Variables

HF_TOKEN (optional): HuggingFace API token for downloading gated models. If not set, only public models are accessible.

Path Resolution

ROOT_DIR: Populated from the runtime path registry. In development it points to the backend/ folder; in bundled builds it matches the PyInstaller resource directory.

Global State

LLM_Engine: Runtime engine instance initialized during FastAPI lifespan startup. Set by BaseEngine.get_engine() based on platform detection. Type is Optional[Type[BaseEngine]] before initialization.

Example

Access configuration in application code::

from src.core import config

# Use paths
model_path = config.LLM_DIR / "Llama-3-8B"

# Check HuggingFace authentication
if config.HF_TOKEN:
    api = config.get_hf_api()
    api.whoami()  # Verify token validity

# Use engine (after lifespan startup): resolve the model server,
# then stream from it via the agent layer (ChatOpenAI(base_url=...)).
model, tokenizer = config.LLM_Engine.get_model_and_tokenizer(llm_id, path)
Note

All directories are created automatically at module import time. Do not manually modify LLM_Engine; it is managed by the lifespan context.

Warning

Changing ROOT_DIR after import will not update derived paths (LLM_DIR, CACHE_DIR, etc.). Ensure paths are configured before first import.

get_hf_api()

Get or initialize HuggingFace API client (lazy-loaded singleton).

Provides thread-safe lazy initialization of HfApi client. The client is created only when first requested, avoiding unnecessary initialization during imports or when working offline.

Returns:

Name Type Description
HfApi HfApi

Configured HuggingFace API client with authentication token.

Note

Thread-safe through Python's GIL. Multiple calls return same instance. Token is loaded from HF_TOKEN environment variable.

Example

from src.core import config api = config.get_hf_api() models = api.list_models(search="gemma")

Source code in backend/src/core/config.py
def get_hf_api() -> HfApi:
    """Get or initialize HuggingFace API client (lazy-loaded singleton).

    Provides thread-safe lazy initialization of HfApi client. The client is
    created only when first requested, avoiding unnecessary initialization
    during imports or when working offline.

    Returns:
        HfApi: Configured HuggingFace API client with authentication token.

    Note:
        Thread-safe through Python's GIL. Multiple calls return same instance.
        Token is loaded from HF_TOKEN environment variable.

    Example:
        >>> from src.core import config
        >>> api = config.get_hf_api()
        >>> models = api.list_models(search="gemma")
    """
    global _HF_API
    if _HF_API is None:
        _HF_API = _RetryingHfApi(token=HF_TOKEN)
    return _HF_API

parse_kb_agentic_flag(raw)

Parse the tri-state ERUDI_KB_AGENTIC value (#298).

Unset/empty/unrecognized -> None (per-model routing); "1"/"true" -> True (force agentic); "0"/"false" -> False (force systematic). Case-insensitive, surrounding whitespace ignored.

Source code in backend/src/core/config.py
def parse_kb_agentic_flag(raw: Optional[str]) -> Optional[bool]:
    """Parse the tri-state ERUDI_KB_AGENTIC value (#298).

    Unset/empty/unrecognized -> None (per-model routing); "1"/"true" -> True
    (force agentic); "0"/"false" -> False (force systematic). Case-insensitive,
    surrounding whitespace ignored.
    """
    if raw is None:
        return None
    value = raw.strip().lower()
    if value in ("1", "true"):
        return True
    if value in ("0", "false"):
        return False
    return None

src.core.exceptions

Core exception handling for Erudi.

This module defines the exception hierarchy used throughout the application. All business exceptions inherit from AppBaseException and provide structured error reporting with HTTP status codes and custom error codes.

Exception Hierarchy

AppBaseException (base class) ├── ModelNotFoundException (404, MODEL_NOT_FOUND) ├── InvalidInputException (422, INVALID_INPUT) ├── StateConflictException (400, STATE_CONFLICT) ├── DatabaseException (500, DATABASE_ERROR) ├── FileSystemException (500, FILESYSTEM_ERROR) ├── HuggingFaceAPIException (503, HUGGINGFACE_API_ERROR) ├── ModelLoadingException (500, MODEL_LOADING_ERROR) ├── GenerationException (500, GENERATION_ERROR) ├── GenerationTimeoutException (504, GENERATION_TIMEOUT) ├── KnowledgeBaseNotFoundException (404, KB_NOT_FOUND) ├── KnowledgeBaseCorruptedException (500, KB_CORRUPTED) ├── ConversationNotFoundException (404, CONVERSATION_NOT_FOUND) ├── MessageNotFoundException (404, MESSAGE_NOT_FOUND) ├── InsufficientMemoryException (507, INSUFFICIENT_MEMORY) ├── UnsupportedPlatformException (501, UNSUPPORTED_PLATFORM) ├── DownloadJobNotFoundException (404, DOWNLOAD_JOB_NOT_FOUND) ├── TokenizationException (500, TOKENIZATION_ERROR) ├── ConfigurationException (500, CONFIGURATION_ERROR) └── EngineException (500, LLM_ENGINE_FAILURE)

Fonctionnalités: - Structured exception hierarchy with status codes - One log record per failed request, written by the handler (INFO below 500, ERROR with traceback at 500 and above -- see docs/logging.md) - FastAPI integration with JSON response handlers - Custom Erudi error codes for client diagnostics - Consistent error messages with remediation hints

Usage Patterns

Creating New Exceptions: All exceptions inherit from AppBaseException and provide: 1. Appropriate HTTP status code (use fastapi.status constants) 2. Custom Erudi error code (e.g., "KB_NOT_FOUND", "GENERATION_ERROR") 3. Clear error messages with remediation hints 4. Optional trace parameter for debugging context

Raising Exceptions: from src.core.exceptions import ModelNotFoundException

llm = db.query(Llm).filter(Llm.id == llm_id).first()
if not llm:
    raise ModelNotFoundException(f"LLM {llm_id}")

Catching Specific Exceptions: from src.core.exceptions import DatabaseException

try:
    db.commit()
except Exception as e:
    raise DatabaseException(f"Failed to persist changes: {e}", trace=str(e))

FastAPI Integration: from fastapi import FastAPI from src.core.exceptions import app_base_exception_handler, AppBaseException

app = FastAPI()
app.add_exception_handler(AppBaseException, app_base_exception_handler)
Best Practices
  • Never use bare except: - always catch specific exception types
  • All custom exceptions must be defined in this module
  • Include clear error messages with remediation hints
  • Use trace parameter to include original error context
  • Let exceptions bubble up to FastAPI exception handler
  • Do not log at the raise site: the handler writes the request's record; an except that catches and recovers logs its own outcome
Exception Categories

Resource Not Found (404): - ModelNotFoundException - KnowledgeBaseNotFoundException - ConversationNotFoundException - MessageNotFoundException - DownloadJobNotFoundException

Client Errors (400, 422): - InvalidInputException (422) - StateConflictException (400)

Server Errors (500): - DatabaseException - FileSystemException - ModelLoadingException - GenerationException - KnowledgeBaseCorruptedException - TokenizationException - ConfigurationException - EngineException

Service Unavailable (503): - HuggingFaceAPIException

Not Implemented (501): - UnsupportedPlatformException

Insufficient Storage (507): - InsufficientMemoryException

See Also
  • src.core.logging: Structured logging for error tracking
  • src.core.api: FastAPI application with exception handlers

AppBaseException(message='An unexpected error occured.', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, erudi_code=None, trace=None, detail=None)

Bases: Exception

Base class for all Erudi business exceptions.

Provides structured error handling with HTTP status codes, custom error codes, and automatic logging. All application-specific exceptions should inherit from this class to ensure consistent error reporting.

Attributes:

Name Type Description
message

Human-readable error description, verbatim as raised.

status_code

HTTP status code for the error response.

erudi_code

Custom error code for client-side diagnostics.

detail

Optional structured payload surfaced verbatim in the JSON response so a client can render a decision UI (e.g. the dependent KB assistants of a base model targeted for deletion). None omits it.

trace

Optional context the raiser attached (the wrapped error's text, a child's captured output). Logged with the record, never sent to the client.

Initialize the exception with error details.

Parameters:

Name Type Description Default
message str

Human-readable error description.

'An unexpected error occured.'
status_code int

HTTP status code (default: 500).

HTTP_500_INTERNAL_SERVER_ERROR
erudi_code Optional[str]

Custom error code for diagnostics (default: "INTERNAL_SERVER_ERROR").

None
trace Optional[str]

Optional stack trace or additional context.

None
detail Optional[Any]

Optional JSON-serializable payload returned in the response body (under error.detail) for the client to act on.

None
Note

The message is exactly what the raiser wrote. Telling the user where to report a bug is the interface's job -- the Diagnostics panel in Settings owns that path -- so no support address or reporting instruction is appended to a payload that domain code also logs, matches on and shows in three other languages.

Constructing an exception logs nothing. The record is written where the exception is handled: by :func:app_base_exception_handler when it ends a request (one record with the method, path, status, code, message and trace), or by the except block that catches and recovers from it elsewhere. See docs/logging.md.

Source code in backend/src/core/exceptions.py
def __init__(
    self,
    message: str = "An unexpected error occured.",
    status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR,
    erudi_code: Optional[str] = None,
    trace: Optional[str] = None,
    detail: Optional[Any] = None,
):
    """Initialize the exception with error details.

    Args:
        message: Human-readable error description.
        status_code: HTTP status code (default: 500).
        erudi_code: Custom error code for diagnostics (default: "INTERNAL_SERVER_ERROR").
        trace: Optional stack trace or additional context.
        detail: Optional JSON-serializable payload returned in the response
            body (under error.detail) for the client to act on.

    Note:
        The message is exactly what the raiser wrote. Telling the user
        where to report a bug is the interface's job -- the Diagnostics
        panel in Settings owns that path -- so no support address or
        reporting instruction is appended to a payload that domain code
        also logs, matches on and shows in three other languages.

        Constructing an exception logs nothing. The record is written
        where the exception is handled: by
        :func:`app_base_exception_handler` when it ends a request (one
        record with the method, path, status, code, message and trace),
        or by the ``except`` block that catches and recovers from it
        elsewhere. See docs/logging.md.

    """
    self.message = message
    self.status_code = status_code
    self.erudi_code = erudi_code or "INTERNAL_SERVER_ERROR"
    self.detail = detail
    self.trace = trace
    super().__init__(message)

ConfigurationException(message, trace=None)

Bases: AppBaseException

Exception raised for configuration errors.

Raised when environment variables are missing, configuration files are invalid, or system is misconfigured in a way that prevents operation.

Examples:

from src.core.exceptions import ConfigurationException if not os.getenv("DATABASE_URL"): raise ConfigurationException("DATABASE_URL environment variable not set")

Initialize configuration exception with error details.

Parameters:

Name Type Description Default
message str

Description of the configuration issue.

required
trace Optional[str]

Optional stack trace or additional context.

None
Source code in backend/src/core/exceptions.py
def __init__(self, message: str, trace: Optional[str] = None):
    """Initialize configuration exception with error details.

    Args:
        message: Description of the configuration issue.
        trace: Optional stack trace or additional context.

    """
    super().__init__(
        message=message,
        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
        erudi_code="CONFIGURATION_ERROR",
        trace=trace,
    )

ConversationNotFoundException(conversation_id, trace=None)

Bases: AppBaseException

Exception raised when a conversation cannot be found.

Raised when attempting to access, update, or delete a conversation that doesn't exist in the database.

Examples:

from src.core.exceptions import ConversationNotFoundException conv = db.query(Conversation).filter(Conversation.id == conv_id).first() if not conv: raise ConversationNotFoundException(conv_id)

Initialize exception for missing conversation.

Parameters:

Name Type Description Default
conversation_id int

ID of the conversation that was not found.

required
trace Optional[str]

Optional stack trace or additional context.

None
Source code in backend/src/core/exceptions.py
def __init__(self, conversation_id: int, trace: Optional[str] = None):
    """Initialize exception for missing conversation.

    Args:
        conversation_id: ID of the conversation that was not found.
        trace: Optional stack trace or additional context.

    """
    super().__init__(
        message=f"Conversation with ID {conversation_id} not found",
        status_code=status.HTTP_404_NOT_FOUND,
        erudi_code="CONVERSATION_NOT_FOUND",
        trace=trace,
    )

DatabaseException(message, trace=None)

Bases: AppBaseException

Exception raised for database operation failures.

Raised when SQLAlchemy operations fail, including connection errors, transaction conflicts, constraint violations, or query execution failures.

Examples:

from src.core.exceptions import DatabaseException try: db.add(entity) db.commit() except SQLAlchemyError as e: raise DatabaseException(f"Failed to save entity: {e}", trace=str(e))

Initialize database exception with error details.

Parameters:

Name Type Description Default
message str

Description of the database failure.

required
trace Optional[str]

Optional stack trace or additional context.

None
Source code in backend/src/core/exceptions.py
def __init__(self, message: str, trace: Optional[str] = None):
    """Initialize database exception with error details.

    Args:
        message: Description of the database failure.
        trace: Optional stack trace or additional context.

    """
    super().__init__(
        message=message,
        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
        erudi_code="DATABASE_ERROR",
        trace=trace,
    )

DownloadJobNotFoundException(job_id, trace=None)

Bases: AppBaseException

Exception raised when a download job cannot be found.

Raised when attempting to access or update a download job that doesn't exist in the database.

Examples:

from src.core.exceptions import DownloadJobNotFoundException job = db.query(DownloadJobModel).filter(DownloadJobModel.id == job_id).first() if not job: raise DownloadJobNotFoundException(job_id)

Initialize exception for missing download job.

Parameters:

Name Type Description Default
job_id int

ID of the download job that was not found.

required
trace Optional[str]

Optional stack trace or additional context.

None
Source code in backend/src/core/exceptions.py
def __init__(self, job_id: int, trace: Optional[str] = None):
    """Initialize exception for missing download job.

    Args:
        job_id: ID of the download job that was not found.
        trace: Optional stack trace or additional context.

    """
    super().__init__(
        message=f"Download job with ID {job_id} not found",
        status_code=status.HTTP_404_NOT_FOUND,
        erudi_code="DOWNLOAD_JOB_NOT_FOUND",
        trace=trace,
    )

EngineException(message, trace=None, engine_code=None)

Bases: AppBaseException

Exception raised for LLM engine failures during inference.

Covers model loading errors, inference failures, out-of-memory conditions, and other runtime issues during model execution. A failure whose cause is identifiable carries an engine_code the UI acts on (a GPU the bundled CUDA build cannot drive, a driver too old to JIT its PTX, VRAM exhaustion); everything else leaves it unset and keeps the generic error turn.

Examples:

from src.core.exceptions import EngineException try: model.generate(prompt) except RuntimeError as e: raise EngineException(f"Inference failed: {e}")

Initialize engine exception with error details.

Parameters:

Name Type Description Default
message str

Description of the engine failure.

required
trace Optional[str]

Optional stack trace or additional context.

None
engine_code Optional[str]

Optional machine-readable cause, from src.engines.cuda_compatibility.CUDA_FAILURE_CODES. It is a SECOND axis, orthogonal to erudi_code (which stays LLM_ENGINE_FAILURE for the HTTP layer): it says WHY the engine failed, so the renderer can offer the matching remedy instead of a generic apology. None -- the default -- is the generic path every other engine failure keeps taking.

None

Attributes:

Name Type Description
engine_code

As above.

engine_trace

The trace argument, kept on the instance so the caller can surface it verbatim (the child's captured output). AppBaseException only logs it.

Source code in backend/src/core/exceptions.py
def __init__(
    self,
    message: str,
    trace: Optional[str] = None,
    engine_code: Optional[str] = None,
):
    """Initialize engine exception with error details.

    Args:
        message: Description of the engine failure.
        trace: Optional stack trace or additional context.
        engine_code: Optional machine-readable cause, from
            ``src.engines.cuda_compatibility.CUDA_FAILURE_CODES``. It is a
            SECOND axis, orthogonal to ``erudi_code`` (which stays
            ``LLM_ENGINE_FAILURE`` for the HTTP layer): it says WHY the
            engine failed, so the renderer can offer the matching remedy
            instead of a generic apology. ``None`` -- the default -- is the
            generic path every other engine failure keeps taking.

    Attributes:
        engine_code: As above.
        engine_trace: The ``trace`` argument, kept on the instance so the
            caller can surface it verbatim (the child's captured output).
            ``AppBaseException`` only logs it.
    """
    super().__init__(
        message=message,
        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
        erudi_code="LLM_ENGINE_FAILURE",
        trace=trace,
    )
    self.engine_code = engine_code
    self.engine_trace = trace

FileSystemException(message, trace=None)

Bases: AppBaseException

Exception raised for file system operation failures.

Covers file not found, permission denied, disk full, I/O errors, and other filesystem-related failures during model loading, KB creation, or index persistence.

Examples:

from src.core.exceptions import FileSystemException import os if not os.path.exists(model_path): raise FileSystemException(f"Model directory not found: {model_path}")

Initialize filesystem exception with error details.

Parameters:

Name Type Description Default
message str

Description of the filesystem failure.

required
trace Optional[str]

Optional stack trace or additional context.

None
Source code in backend/src/core/exceptions.py
def __init__(self, message: str, trace: Optional[str] = None):
    """Initialize filesystem exception with error details.

    Args:
        message: Description of the filesystem failure.
        trace: Optional stack trace or additional context.

    """
    super().__init__(
        message=message,
        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
        erudi_code="FILESYSTEM_ERROR",
        trace=trace,
    )

GenerationException(message, trace=None)

Bases: AppBaseException

Exception raised for text generation failures.

Covers failures during LLM inference including tokenization errors, generation timeouts, context window overflow, and model execution errors.

Examples:

from src.core.exceptions import GenerationException try: async for chunk in chat_model.astream(messages): yield chunk.content except Exception as e: raise GenerationException(f"Generation failed: {e}", trace=str(e))

Initialize generation exception with error details.

Parameters:

Name Type Description Default
message str

Description of the generation failure.

required
trace Optional[str]

Optional stack trace or additional context.

None
Source code in backend/src/core/exceptions.py
def __init__(self, message: str, trace: Optional[str] = None):
    """Initialize generation exception with error details.

    Args:
        message: Description of the generation failure.
        trace: Optional stack trace or additional context.

    """
    super().__init__(
        message=message,
        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
        erudi_code="GENERATION_ERROR",
        trace=trace,
    )

GenerationTimeoutException(message, *, phase, budget_s, estimated_prompt_tokens=0, trace=None)

Bases: AppBaseException

Exception raised when a local model's stream stays silent past its budget.

Raised by the streaming watchdog in src.agents.chat_model (#573), which gives a model call two wall-clock budgets: a prompt-sized one before the FIRST chunk (prefill, which grows with the history) and a fixed one between chunks once tokens flow. The two are not interchangeable, so the phase travels with the exception: the runner turns it into an honest error turn that says WHICH silence was too long.

Examples:

from src.core.exceptions import GenerationTimeoutException raise GenerationTimeoutException( "no first chunk", phase="first-chunk", budget_s=305.0, estimated_prompt_tokens=6878, )

Initialize the timeout with the budget that expired.

Parameters:

Name Type Description Default
message str

Description of the silence that ended the stream.

required
phase str

Which budget expired -- src.agents.chat_model's PHASE_FIRST_CHUNK (nothing arrived, the model was still reading the prompt) or PHASE_INTER_CHUNK (tokens flowed, then stopped).

required
budget_s float

The budget that expired, in seconds.

required
estimated_prompt_tokens int

The prompt size the first-chunk budget was computed from. 0 when it is not known.

0

Attributes:

Name Type Description
phase, (budget_s, estimated_prompt_tokens)

As above, kept on the instance so the handler can log and phrase the turn without parsing the message.

Source code in backend/src/core/exceptions.py
def __init__(
    self,
    message: str,
    *,
    phase: str,
    budget_s: float,
    estimated_prompt_tokens: int = 0,
    trace: Optional[str] = None,
):
    """Initialize the timeout with the budget that expired.

    Args:
        message: Description of the silence that ended the stream.
        phase: Which budget expired -- ``src.agents.chat_model``'s
            ``PHASE_FIRST_CHUNK`` (nothing arrived, the model was still
            reading the prompt) or ``PHASE_INTER_CHUNK`` (tokens flowed,
            then stopped).
        budget_s: The budget that expired, in seconds.
        estimated_prompt_tokens: The prompt size the first-chunk budget was
            computed from. 0 when it is not known.

    Attributes:
        phase, budget_s, estimated_prompt_tokens: As above, kept on the
            instance so the handler can log and phrase the turn without
            parsing the message.
    """
    super().__init__(
        message=message,
        status_code=status.HTTP_504_GATEWAY_TIMEOUT,
        erudi_code="GENERATION_TIMEOUT",
        trace=trace,
    )
    self.phase = phase
    self.budget_s = budget_s
    self.estimated_prompt_tokens = estimated_prompt_tokens

HardwareException(message, trace=None)

Bases: AppBaseException

Exception raised for hardware detection and profiling failures.

Covers GPU detection errors, hardware capability assessment failures, and issues during hardware profile creation or updates.

Examples:

from src.core.exceptions import HardwareException try: profile = detect_hardware() except RuntimeError as e: raise HardwareException(f"Hardware detection failed: {e}")

Initialize hardware exception with error details.

Parameters:

Name Type Description Default
message str

Description of the hardware failure.

required
trace Optional[str]

Optional stack trace or additional context.

None
Source code in backend/src/core/exceptions.py
def __init__(self, message: str, trace: Optional[str] = None):
    """Initialize hardware exception with error details.

    Args:
        message: Description of the hardware failure.
        trace: Optional stack trace or additional context.

    """
    super().__init__(
        message=message,
        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
        erudi_code="HARDWARE_ERROR",
        trace=trace,
    )

HuggingFaceAPIException(message, trace=None)

Bases: AppBaseException

Exception raised for HuggingFace Hub API operation failures.

Covers network errors, authentication failures, rate limiting, model not found on Hub, and other API-related issues during model downloads or metadata fetching.

Examples:

from src.core.exceptions import HuggingFaceAPIException try: model_info = HF_API.model_info(model_id) except Exception as e: raise HuggingFaceAPIException(f"Failed to fetch model info: {e}")

Initialize HuggingFace API exception with error details.

Parameters:

Name Type Description Default
message str

Description of the API failure.

required
trace Optional[str]

Optional stack trace or additional context.

None
Source code in backend/src/core/exceptions.py
def __init__(self, message: str, trace: Optional[str] = None):
    """Initialize HuggingFace API exception with error details.

    Args:
        message: Description of the API failure.
        trace: Optional stack trace or additional context.

    """
    super().__init__(
        message=message,
        status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
        erudi_code="HUGGINGFACE_API_ERROR",
        trace=trace,
    )

InsufficientMemoryException(operation, trace=None)

Bases: AppBaseException

Exception raised when system runs out of memory.

Raised when model loading, inference, or KB creation fails due to insufficient RAM/VRAM. Provides remediation hints for reducing memory usage.

Examples:

from src.core.exceptions import InsufficientMemoryException try: model = load_large_model() except MemoryError as e: raise InsufficientMemoryException("Model loading", trace=str(e))

Initialize exception for memory exhaustion.

Parameters:

Name Type Description Default
operation str

Description of operation that failed (e.g., "model loading").

required
trace Optional[str]

Optional stack trace or additional context.

None
Source code in backend/src/core/exceptions.py
def __init__(self, operation: str, trace: Optional[str] = None):
    """Initialize exception for memory exhaustion.

    Args:
        operation: Description of operation that failed (e.g., "model loading").
        trace: Optional stack trace or additional context.

    """
    message = (
        f"Insufficient memory for {operation}. "
        f"Try: 1) Close other applications, 2) Use smaller model, "
        f"3) Increase system RAM/VRAM."
    )
    super().__init__(
        message=message,
        status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
        erudi_code="INSUFFICIENT_MEMORY",
        trace=trace,
    )

InvalidInputException(field_name, trace=None)

Bases: AppBaseException

Exception raised for invalid user input or request parameters.

Used when request validation fails beyond Pydantic schema validation, such as business logic constraints or cross-field validation.

Examples:

from src.core.exceptions import InvalidInputException if temperature < 0 or temperature > 2: raise InvalidInputException("temperature")

Initialize exception for invalid input field.

Parameters:

Name Type Description Default
field_name str

Name of the field that contains invalid data.

required
trace Optional[str]

Optional stack trace or additional context.

None
Source code in backend/src/core/exceptions.py
def __init__(self, field_name: str, trace: Optional[str] = None):
    """Initialize exception for invalid input field.

    Args:
        field_name: Name of the field that contains invalid data.
        trace: Optional stack trace or additional context.

    """
    super().__init__(
        f"Invalid input for '{field_name}'",
        status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
        erudi_code="INVALID_INPUT",
        trace=trace,
    )

KnowledgeBaseCorruptedException(kb_id, reason, trace=None)

Bases: AppBaseException

Exception raised when Knowledge Base retrieval is in a broken state.

Raised when hybrid retrieval over the KB's chunks fails unexpectedly (vector store unreachable, inconsistent chunk data, …).

Examples:

from src.core.exceptions import KnowledgeBaseCorruptedException raise KnowledgeBaseCorruptedException(kb_id, "retrieval error", trace=str(e))

Initialize exception for corrupted KB.

Parameters:

Name Type Description Default
kb_id int

ID of the corrupted KB.

required
reason str

Description of the corruption issue.

required
trace Optional[str]

Optional stack trace or additional context.

None
Source code in backend/src/core/exceptions.py
def __init__(self, kb_id: int, reason: str, trace: Optional[str] = None):
    """Initialize exception for corrupted KB.

    Args:
        kb_id: ID of the corrupted KB.
        reason: Description of the corruption issue.
        trace: Optional stack trace or additional context.

    """
    super().__init__(
        message=f"Knowledge Base {kb_id} is corrupted: {reason}",
        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
        erudi_code="KB_CORRUPTED",
        trace=trace,
    )

KnowledgeBaseNotFoundException(kb_id, trace=None)

Bases: AppBaseException

Exception raised when a Knowledge Base cannot be found.

Raised when attempting to access a KB that doesn't exist in the database or when associated resources (index, vector store) are missing.

Examples:

from src.core.exceptions import KnowledgeBaseNotFoundException kb = db.query(KnowledgeBase).filter(KnowledgeBase.id == kb_id).first() if not kb: raise KnowledgeBaseNotFoundException(kb_id)

Initialize exception for missing KB.

Parameters:

Name Type Description Default
kb_id int

ID of the KB that was not found.

required
trace Optional[str]

Optional stack trace or additional context.

None
Source code in backend/src/core/exceptions.py
def __init__(self, kb_id: int, trace: Optional[str] = None):
    """Initialize exception for missing KB.

    Args:
        kb_id: ID of the KB that was not found.
        trace: Optional stack trace or additional context.

    """
    super().__init__(
        message=f"Knowledge Base with ID {kb_id} not found",
        status_code=status.HTTP_404_NOT_FOUND,
        erudi_code="KB_NOT_FOUND",
        trace=trace,
    )

MessageNotFoundException(message_id, trace=None)

Bases: AppBaseException

Exception raised when a message cannot be found by ID.

Raised by MessageRepository when attempting to retrieve, update, or delete a message that doesn't exist in the database.

Examples:

from src.core.exceptions import MessageNotFoundException message = db.query(Message).filter(Message.id == msg_id).first() if not message: raise MessageNotFoundException(msg_id)

Initialize exception for missing message.

Parameters:

Name Type Description Default
message_id int

ID of the message that was not found.

required
trace Optional[str]

Optional stack trace or additional context.

None
Source code in backend/src/core/exceptions.py
def __init__(self, message_id: int, trace: Optional[str] = None):
    """Initialize exception for missing message.

    Args:
        message_id: ID of the message that was not found.
        trace: Optional stack trace or additional context.

    """
    super().__init__(
        message=f"Message with ID {message_id} not found",
        status_code=status.HTTP_404_NOT_FOUND,
        erudi_code="MESSAGE_NOT_FOUND",
        trace=trace,
    )

ModelLoadingException(message, trace=None)

Bases: AppBaseException

Exception raised for model loading failures.

Raised when model cannot be loaded into memory due to corrupted weights, incompatible format, insufficient memory, or missing configuration files. Covers failures across all engine backends (MLX, CUDA, CPU).

Examples:

from src.core.exceptions import ModelLoadingException try: model, tokenizer = mlx_vlm.load(model_path) except Exception as e: raise ModelLoadingException(f"Failed to load model: {e}", trace=str(e))

Initialize model loading exception with error details.

Parameters:

Name Type Description Default
message str

Description of the model loading failure.

required
trace Optional[str]

Optional stack trace or additional context.

None
Source code in backend/src/core/exceptions.py
def __init__(self, message: str, trace: Optional[str] = None):
    """Initialize model loading exception with error details.

    Args:
        message: Description of the model loading failure.
        trace: Optional stack trace or additional context.

    """
    super().__init__(
        message=message,
        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
        erudi_code="MODEL_LOADING_ERROR",
        trace=trace,
    )

ModelNotFoundException(model_name, trace=None)

Bases: AppBaseException

Exception raised when a requested model cannot be found.

Raised when attempting to load, access, or operate on a model that doesn't exist in the models directory or database.

Examples:

from src.core.exceptions import ModelNotFoundException raise ModelNotFoundException("llama-2-7b")

Initialize exception for missing model.

Parameters:

Name Type Description Default
model_name str

Name or ID of the model that was not found.

required
trace Optional[str]

Optional stack trace or additional context.

None
Source code in backend/src/core/exceptions.py
def __init__(self, model_name: str, trace: Optional[str] = None):
    """Initialize exception for missing model.

    Args:
        model_name: Name or ID of the model that was not found.
        trace: Optional stack trace or additional context.

    """
    super().__init__(
        message=f"Model '{model_name}' not found",
        status_code=status.HTTP_404_NOT_FOUND,
        erudi_code="MODEL_NOT_FOUND",
        trace=trace,
    )

StateConflictException(message, trace=None, status_code=status.HTTP_400_BAD_REQUEST, detail=None)

Bases: AppBaseException

Exception raised when operation conflicts with current entity state.

Used for operations that cannot proceed due to the current state of a resource, such as attempting to delete a model that is currently downloading, or starting a download when one is already in progress.

Examples:

from src.core.exceptions import StateConflictException if llm.local == 2: # Downloading raise StateConflictException("Cannot delete LLM while downloading")

Initialize state conflict exception.

Parameters:

Name Type Description Default
message str

Description of the state conflict.

required
trace Optional[str]

Optional stack trace or additional context.

None
status_code int

HTTP status (default 400). Pass 409 for a resource conflict the client can resolve by opting in (e.g. deleting a base model that has dependent KB assistants).

HTTP_400_BAD_REQUEST
detail Optional[Any]

Optional structured payload for the client (e.g. the dependents to render in a confirmation dialog).

None
Source code in backend/src/core/exceptions.py
def __init__(
    self,
    message: str,
    trace: Optional[str] = None,
    status_code: int = status.HTTP_400_BAD_REQUEST,
    detail: Optional[Any] = None,
):
    """Initialize state conflict exception.

    Args:
        message: Description of the state conflict.
        trace: Optional stack trace or additional context.
        status_code: HTTP status (default 400). Pass 409 for a resource
            conflict the client can resolve by opting in (e.g. deleting a
            base model that has dependent KB assistants).
        detail: Optional structured payload for the client (e.g. the
            dependents to render in a confirmation dialog).

    """
    super().__init__(
        message=message,
        status_code=status_code,
        erudi_code="STATE_CONFLICT",
        trace=trace,
        detail=detail,
    )

TokenizationException(message, trace=None)

Bases: AppBaseException

Exception raised for tokenization failures.

Raised when tokenizer fails to encode text, apply chat template, or decode tokens. Covers tokenizer loading failures and encoding errors.

Examples:

from src.core.exceptions import TokenizationException try: tokens = tokenizer.encode(text) except Exception as e: raise TokenizationException(f"Failed to tokenize: {e}", trace=str(e))

Initialize tokenization exception with error details.

Parameters:

Name Type Description Default
message str

Description of the tokenization failure.

required
trace Optional[str]

Optional stack trace or additional context.

None
Source code in backend/src/core/exceptions.py
def __init__(self, message: str, trace: Optional[str] = None):
    """Initialize tokenization exception with error details.

    Args:
        message: Description of the tokenization failure.
        trace: Optional stack trace or additional context.

    """
    super().__init__(
        message=message,
        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
        erudi_code="TOKENIZATION_ERROR",
        trace=trace,
    )

UnsupportedPlatformException(feature, reason, trace=None)

Bases: AppBaseException

Exception raised when operation is not supported on current platform.

Raised when attempting to use engine-specific features on incompatible hardware (e.g., MLX on non-Mac, CUDA without NVIDIA GPU).

Examples:

from src.core.exceptions import UnsupportedPlatformException if not torch.cuda.is_available(): raise UnsupportedPlatformException("CUDA", "No NVIDIA GPU detected")

Initialize exception for unsupported platform.

Parameters:

Name Type Description Default
feature str

Feature that is not supported (e.g., "MLX", "CUDA").

required
reason str

Explanation of why it's not supported.

required
trace Optional[str]

Optional stack trace or additional context.

None
Source code in backend/src/core/exceptions.py
def __init__(self, feature: str, reason: str, trace: Optional[str] = None):
    """Initialize exception for unsupported platform.

    Args:
        feature: Feature that is not supported (e.g., "MLX", "CUDA").
        reason: Explanation of why it's not supported.
        trace: Optional stack trace or additional context.

    """
    super().__init__(
        message=f"{feature} not supported on this platform: {reason}",
        status_code=status.HTTP_501_NOT_IMPLEMENTED,
        erudi_code="UNSUPPORTED_PLATFORM",
        trace=trace,
    )

app_base_exception_handler(request, exc) async

Global exception handler for AppBaseException and subclasses.

Converts application exceptions into structured JSON responses with appropriate HTTP status codes, and writes the request's single log record -- method, path, status, Erudi code, message, and the trace the raiser attached -- at the level :func:log_level_for_status gives. A 5xx record also carries the traceback (exc_info), which includes the wrapped exception when the raiser chained one. The query string is left out: it can carry a search term, and the path is what identifies the route.

Parameters:

Name Type Description Default
request Request

Incoming FastAPI request object.

required
exc AppBaseException

The raised AppBaseException or subclass instance.

required

Returns:

Type Description

JSONResponse with structured error information.

Examples:

Register in FastAPI application: from fastapi import FastAPI from src.core.exceptions import app_base_exception_handler, AppBaseException

app = FastAPI()
app.add_exception_handler(AppBaseException, app_base_exception_handler)
Source code in backend/src/core/exceptions.py
async def app_base_exception_handler(request: Request, exc: AppBaseException):
    """Global exception handler for AppBaseException and subclasses.

    Converts application exceptions into structured JSON responses with
    appropriate HTTP status codes, and writes the request's single log
    record -- method, path, status, Erudi code, message, and the trace the
    raiser attached -- at the level :func:`log_level_for_status` gives. A
    5xx record also carries the traceback (``exc_info``), which includes the
    wrapped exception when the raiser chained one. The query string is left
    out: it can carry a search term, and the path is what identifies the
    route.

    Args:
        request: Incoming FastAPI request object.
        exc: The raised AppBaseException or subclass instance.

    Returns:
        JSONResponse with structured error information.

    Examples:
        Register in FastAPI application:
            from fastapi import FastAPI
            from src.core.exceptions import app_base_exception_handler, AppBaseException

            app = FastAPI()
            app.add_exception_handler(AppBaseException, app_base_exception_handler)

    """
    level = log_level_for_status(exc.status_code)
    record = (
        f"{request.method} {request.url.path} -> {exc.status_code} {exc.erudi_code}: {exc.message}"
    )
    if exc.trace:
        record = f"{record}\n- Trace: {exc.trace}"
    logger.log(level, record, exc_info=exc if level >= logging.ERROR else None)
    error: dict = {
        "type": exc.erudi_code,
        "message": exc.message,
    }
    # Structured payload for clients to act on (e.g. the choice dialog on a
    # guarded base-model delete). Omitted entirely when unset.
    if getattr(exc, "detail", None) is not None:
        error["detail"] = exc.detail
    return JSONResponse(
        status_code=exc.status_code,
        content={
            "success": False,
            "error": error,
        },
    )

log_level_for_status(status_code)

The log level of a request that ends with status_code.

ERROR at 500 and above: the operation the user asked for did not happen and a maintainer needs the record. INFO below 500: a 404 for a model deleted a moment ago, a 409 on a guarded delete or a 422 on a rejected input is the expected outcome of asking for something that legitimately is not there or not allowed, not a defect. The Diagnostics panel shows WARNING and above, so a client error logged any higher would fill a page meant for bug reports with normal traffic.

Source code in backend/src/core/exceptions.py
def log_level_for_status(status_code: int) -> int:
    """The log level of a request that ends with ``status_code``.

    ``ERROR`` at 500 and above: the operation the user asked for did not
    happen and a maintainer needs the record. ``INFO`` below 500: a 404 for a
    model deleted a moment ago, a 409 on a guarded delete or a 422 on a
    rejected input is the expected outcome of asking for something that
    legitimately is not there or not allowed, not a defect. The Diagnostics
    panel shows WARNING and above, so a client error logged any higher would
    fill a page meant for bug reports with normal traffic.
    """
    return logging.ERROR if status_code >= 500 else logging.INFO

unhandled_exception_handler(request, exc) async

Fallback handler for exceptions that escaped every domain handler.

Logs the full traceback tagged with the current request id and returns a 500 JSON body shaped exactly like app_base_exception_handler responses. Registered for plain Exception: Starlette wires it into ServerErrorMiddleware, which sits OUTSIDE the request-logging middleware, so this response must carry the X-Request-ID header itself.

Parameters:

Name Type Description Default
request Request

Incoming FastAPI request object.

required
exc Exception

The unhandled exception instance.

required

Returns:

Type Description
JSONResponse

JSONResponse with structured error information (HTTP 500).

Source code in backend/src/core/exceptions.py
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
    """Fallback handler for exceptions that escaped every domain handler.

    Logs the full traceback tagged with the current request id and returns a
    500 JSON body shaped exactly like app_base_exception_handler responses.
    Registered for plain ``Exception``: Starlette wires it into
    ServerErrorMiddleware, which sits OUTSIDE the request-logging middleware,
    so this response must carry the X-Request-ID header itself.

    Args:
        request: Incoming FastAPI request object.
        exc: The unhandled exception instance.

    Returns:
        JSONResponse with structured error information (HTTP 500).
    """
    request_id = get_request_id()
    logger.error(
        f"Unhandled exception on {request.method} {request.url.path}: {exc}",
        exc_info=exc,
    )
    return JSONResponse(
        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
        content={
            "success": False,
            "error": {
                "type": "INTERNAL_SERVER_ERROR",
                "message": f"Unexpected server error: {exc}",
            },
        },
        headers={"X-Request-ID": request_id},
    )

src.core.health

Health check endpoint for service monitoring and uptime verification.

This module provides a simple HTTP GET endpoint to verify the backend is running and responsive. Used by: - Load balancers for health checks - Monitoring tools (Prometheus, Datadog) - Frontend to verify backend availability before making API calls

Endpoint

GET /erudi/health/

Response (200 OK)::

{
  "status": "ok",
  "message": "Backend is running"
}
Example

Check backend health from command line::

curl http://127.0.0.1:27182/erudi/health/
# {"status": "ok", "message": "Backend is running"}

Use in frontend before API calls::

const response = await fetch('http://127.0.0.1:27182/erudi/health/');
if (response.ok) {
    // Backend available, proceed with API calls
}
Note

This endpoint does NOT check: - Database connectivity - Engine status (model loaded/available) - Disk space or memory usage

For detailed system metrics, use /erudi/hardware/static endpoints.

health() async

Health check endpoint returning basic service status.

The HTTP status is ALWAYS 200 (Electron's boot readiness must not confuse a dead database with a dead backend); the db field carries the truth -- "ok" | "recovering" | "failed" as seen by the DB watchdog (#162).

Returns:

Name Type Description
dict

Status object with "ok" indicator, descriptive message, and the

current database state.

Example

::

import requests

response = requests.get("http://127.0.0.1:27182/erudi/health/")
assert response.json()["status"] == "ok"
Source code in backend/src/core/health.py
@router.get("/")
async def health():
    """Health check endpoint returning basic service status.

    The HTTP status is ALWAYS 200 (Electron's boot readiness must not confuse
    a dead database with a dead backend); the ``db`` field carries the truth --
    ``"ok" | "recovering" | "failed"`` as seen by the DB watchdog (#162).

    Returns:
        dict: Status object with "ok" indicator, descriptive message, and the
        current database state.

    Example:
        ::

            import requests

            response = requests.get("http://127.0.0.1:27182/erudi/health/")
            assert response.json()["status"] == "ok"
    """
    return {"status": "ok", "message": "Backend is running", "db": _db_state()}

src.core.logging

Structured logging configuration with console and file output.

This module provides a centralized logging system with: - Color-coded console output for development. - Rotating file handlers to prevent log bloat. - UTC ISO-8601 timestamps with milliseconds (Z suffix) on both handlers, matching the Electron log's new Date().toISOString() so the two logs can be correlated on a single timeline. - A per-request id ([be-xxxxxxxx]) injected into every line via src.core.request_context (set by the request-logging middleware). - Other libraries' records (pgserver, alembic, uvicorn.error, huggingface_hub, ...) bridged from the root logger into the same file at WARNING and above, in the same format -- see :class:RootFileBridge. - Log level driven by the ERUDI_LOG_LEVEL environment variable (default INFO; invalid values fall back to INFO).

Log Levels (the rules are in docs/logging.md, "Logging rules"): - DEBUG: Internal diagnostics (token generation, model loading steps). - INFO: Lifecycle transitions (startup, shutdown, model switched) and the expected outcomes of user actions, a 4xx the client asked for included. - WARNING: The app recovered or degraded on its own (fallback to CPU, a skipped file, an answer without its KB excerpts). - ERROR: The operation did not happen (5xx, a dead inference child, a failed download or ingestion, a background task that died), always with the exception attached. - CRITICAL: System failures (database corruption, engine crash).

Log Format

Console::

[INFO]  2026-07-02T09:15:32.123Z [be-1f2e3d4c] - erudi - backend/src/main.py:app:l42 - Server started

File::

[INFO] 2026-07-02T09:15:32.123Z [be-1f2e3d4c] - erudi - main.py:42 - Server started
File Management
  • Logs written to backend.log in the runtime log directory configured by src.launcher.runtime_paths (typically backend/logs in dev). The filename is stable (no date suffix): a date computed at import time froze at the process start, so a process crossing midnight kept writing to the previous day's file. Rotation preserves history instead.
  • Rotation: 10 MB max file size.
  • Retention: 10 backup files (backend.log.1, backend.log.2, ...).
  • Encoding: UTF-8 for multilingual support.
Example

Use the global logger instance::

from src.core.logging import logger

logger.debug("Loading model weights...")
logger.info("Model successfully loaded")
logger.warning("GPU memory low, reducing batch size")
logger.error("Failed to load model", exc_info=True)

Create a custom logger for a specific module::

from src.core.logging import get_logger

module_logger = get_logger("erudi.domains.llms")
module_logger.info("LLM service initialized")
Note

The logger is configured as a singleton. Repeated calls to get_logger() with the same name will return the existing logger instance. Use configure_logger() to force a reconfiguration (e.g. after changing ERUDI_LOG_LEVEL in tests).

Warning

Never log sensitive data (HF tokens, user prompts, file paths with PII). Use structured logging: logger.info("Model loaded", extra={"model_id": 123})

CustomFormatter()

Bases: Formatter

Custom formatter to add colors for console output and shorten pathname.

Source code in backend/src/core/logging.py
def __init__(self):
    super().__init__(datefmt=LOG_DATEFMT)
    # Pre-build one UTC formatter per level instead of one per record.
    self._formatters = {level: utc_formatter(fmt) for level, fmt in self.FORMATS.items()}

format(record)

Format log record with color codes and shortened paths.

Parameters:

Name Type Description Default
record

LogRecord instance containing log event information.

required

Returns:

Name Type Description
str

Formatted log message with ANSI color codes.

Note

The shortened path (from "backend/") is computed into a dedicated short_path attribute — mutating record.pathname in place would leak the truncation to every other handler formatting the same record.

Source code in backend/src/core/logging.py
def format(self, record):
    """Format log record with color codes and shortened paths.

    Args:
        record: LogRecord instance containing log event information.

    Returns:
        str: Formatted log message with ANSI color codes.

    Note:
        The shortened path (from "backend/") is computed into a dedicated
        ``short_path`` attribute — mutating ``record.pathname`` in place
        would leak the truncation to every other handler formatting the
        same record.
    """
    backend_idx = record.pathname.find("backend/")
    record.short_path = record.pathname[backend_idx:] if backend_idx != -1 else record.pathname
    formatter = self._formatters.get(record.levelno) or self._formatters[logging.INFO]
    return formatter.format(record)

RequestIdFilter

Bases: Filter

Inject the current request id into every record (- outside requests).

Attached to both the logger and its handlers so records logged through child loggers (which bypass ancestor logger filters) are still tagged.

RootFileBridge(target, app_logger_name=APP_LOGGER_NAME)

Bases: Handler

Forward records logged by other libraries into backend.log.

Handlers hang off the erudi logger, so a record that does not carry that name used to reach no file at all: pgserver quoting the postmaster's log after a failed start, Alembic explaining a refused migration, uvicorn's Application startup failed, huggingface_hub's warnings -- all of it went to the stdlib's lastResort handler on stderr. That reaches the app log at best, never backend.log, and so never the Diagnostics page.

This handler sits on the ROOT logger, which every library propagates to, and hands what reaches it to the file handler the app already owns. Forwarding rather than a second RotatingFileHandler on the same path: two rotating handlers over one file rotate over each other -- one renames the file the other is still writing into -- and a log that loses records while rotating is exactly what this is meant to prevent. It also means one formatter, so a third-party record is written in the same shape as ours and the Diagnostics reader parses it like any other.

The console handler is deliberately NOT bridged: the backend's stdout is the newline-delimited JSON channel run.py and the Electron main process parse, and a library writing into it would break the launcher.

Records already carrying the app's own name are dropped here: they reached the file through the erudi logger's own handlers before propagating to root, and a record must land exactly once.

A record matching _INFO_DEMOTED_LOGGER_FUNCS (logger name AND function name) is written at INFO instead of its own level: it stays in backend.log but off the Diagnostics page's error list.

Source code in backend/src/core/logging.py
def __init__(self, target: logging.Handler, app_logger_name: str = APP_LOGGER_NAME) -> None:
    super().__init__(level=ROOT_BRIDGE_LEVEL)
    self._target = target
    self._app_name = app_logger_name
    self._app_prefix = f"{app_logger_name}."

emit(record)

Hand the record to the file handler, unless the app already wrote it.

Source code in backend/src/core/logging.py
def emit(self, record: logging.LogRecord) -> None:
    """Hand the record to the file handler, unless the app already wrote it."""
    if record.name == self._app_name or record.name.startswith(self._app_prefix):
        return
    if (record.name, record.funcName) in _INFO_DEMOTED_LOGGER_FUNCS:
        # Never mutate the incoming record: other handlers on root still
        # see it unchanged. `Handler.handle` applies filters but not the
        # target's own level (only `Logger.callHandlers` does), so that
        # check is repeated here for the demoted copy.
        demoted = logging.makeLogRecord(record.__dict__)
        demoted.levelno = logging.INFO
        demoted.levelname = "INFO"
        if demoted.levelno >= self._target.level:
            self._target.handle(demoted)
        return
    # `handle` (not `emit`) so the target's own level, filters -- the
    # request-id injection among them -- and formatter all apply.
    self._target.handle(record)

configure_logger(name=APP_LOGGER_NAME)

(Re)apply handlers, level, formatters and filters from the environment.

Reads ERUDI_LOG_LEVEL (default INFO) and applies it to the logger and to both handlers. Idempotent: handlers previously attached to this logger are removed and closed first, so it is safe to call again (e.g. from tests after monkeypatching the environment).

Parameters:

Name Type Description Default
name str

Logger name to configure. Defaults to "erudi".

APP_LOGGER_NAME

Returns:

Type Description
Logger

logging.Logger: The configured logger instance.

Source code in backend/src/core/logging.py
def configure_logger(name: str = APP_LOGGER_NAME) -> logging.Logger:
    """(Re)apply handlers, level, formatters and filters from the environment.

    Reads ERUDI_LOG_LEVEL (default INFO) and applies it to the logger and to
    both handlers. Idempotent: handlers previously attached to this logger
    are removed and closed first, so it is safe to call again (e.g. from
    tests after monkeypatching the environment).

    Args:
        name: Logger name to configure. Defaults to "erudi".

    Returns:
        logging.Logger: The configured logger instance.
    """
    runtime_paths = ensure_runtime_paths_initialized()
    log_dir = runtime_paths.log_dir
    log_dir.mkdir(parents=True, exist_ok=True)
    log_file = log_dir / LOG_FILE_NAME

    level = resolve_log_level(os.environ.get("ERUDI_LOG_LEVEL"))

    logger = logging.getLogger(name)
    for handler in list(logger.handlers):
        logger.removeHandler(handler)
        handler.close()

    logger.setLevel(level)
    if _REQUEST_ID_FILTER not in logger.filters:
        logger.addFilter(_REQUEST_ID_FILTER)

    # Console handler (for development)
    ch = logging.StreamHandler(sys.stdout)
    ch.setLevel(level)
    ch.setFormatter(CustomFormatter())
    ch.addFilter(_REQUEST_ID_FILTER)
    logger.addHandler(ch)

    # File handler (rotating)
    fh = RotatingFileHandler(
        filename=log_file,
        maxBytes=MAX_LOG_FILE_BYTES,
        backupCount=LOG_BACKUP_COUNT,
        encoding="utf-8",
    )
    fh.setLevel(level)
    fh.setFormatter(utc_formatter(FILE_LOG_FORMAT))
    fh.addFilter(_REQUEST_ID_FILTER)
    logger.addHandler(fh)

    # Other libraries log to their own names, which reach the root logger and
    # nothing else. Bridge root to this file handler -- and only to it -- so
    # their warnings and failures land in backend.log too.
    if name == APP_LOGGER_NAME:
        _attach_root_file_bridge(fh)

    return logger

get_logger(name='erudi')

Create or retrieve a configured logger instance.

If a logger with the given name (or one of its ancestors) already has handlers, returns the existing instance to prevent duplicate handler registration — child loggers such as "erudi.domains.llms" propagate to the "erudi" handlers. Otherwise the logger is configured from scratch via configure_logger().

Parameters:

Name Type Description Default
name str

Logger name, typically "erudi" or "erudi.". Defaults to "erudi".

'erudi'

Returns:

Type Description
Logger

logging.Logger: Configured logger instance with console and file handlers.

Source code in backend/src/core/logging.py
def get_logger(name: str = "erudi") -> logging.Logger:
    """Create or retrieve a configured logger instance.

    If a logger with the given name (or one of its ancestors) already has
    handlers, returns the existing instance to prevent duplicate handler
    registration — child loggers such as "erudi.domains.llms" propagate to
    the "erudi" handlers. Otherwise the logger is configured from scratch
    via configure_logger().

    Args:
        name: Logger name, typically "erudi" or "erudi.<module>".
            Defaults to "erudi".

    Returns:
        logging.Logger: Configured logger instance with console and file handlers.
    """
    logger = logging.getLogger(name)
    if logger.hasHandlers():
        return logger  # Avoid duplicate handlers
    return configure_logger(name)

resolve_log_level(raw)

Map an ERUDI_LOG_LEVEL value to a logging level, defaulting to INFO.

Parameters:

Name Type Description Default
raw str | None

Raw environment value ("DEBUG", "info", ...) or None.

required

Returns:

Name Type Description
int int

A stdlib logging level. Unknown/empty values fall back to INFO.

Source code in backend/src/core/logging.py
def resolve_log_level(raw: str | None) -> int:
    """Map an ERUDI_LOG_LEVEL value to a logging level, defaulting to INFO.

    Args:
        raw: Raw environment value ("DEBUG", "info", ...) or None.

    Returns:
        int: A stdlib logging level. Unknown/empty values fall back to INFO.
    """
    if not raw:
        return DEFAULT_LOG_LEVEL
    level = logging.getLevelName(raw.strip().upper())
    return level if isinstance(level, int) else DEFAULT_LOG_LEVEL

utc_formatter(fmt)

Build a formatter emitting UTC ISO-8601 timestamps with ms and Z suffix.

Source code in backend/src/core/logging.py
def utc_formatter(fmt: str) -> logging.Formatter:
    """Build a formatter emitting UTC ISO-8601 timestamps with ms and Z suffix."""
    formatter = logging.Formatter(fmt, datefmt=LOG_DATEFMT)
    formatter.converter = time.gmtime
    return formatter