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.
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
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
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
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
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
- Start the embedded PostgreSQL cluster (postgres_runtime)
- Bind the SQLAlchemy engine/session factory (init_database)
- Select engine via platform detection (BaseEngine.get_engine)
- Migrate the schema to head (Alembic, forward-only)
- Seed database with default models (startup_populate_database)
- Apply the persisted inference-backend preference and run the CUDA pre-flight (apply_inference_backend_preference / emit_engine_notice)
- Open the LangGraph checkpointer (app.state.checkpointer)
- Start the cleanup background task (30s interval) and the memory pressure monitor (60s interval)
- [YIELD] → Application handles requests
- 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
466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 | |
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
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
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
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
exceptthat 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
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
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
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
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
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
|
None
|
Attributes:
| Name | Type | Description |
|---|---|---|
engine_code |
As above. |
|
engine_trace |
The |
Source code in backend/src/core/exceptions.py
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
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
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 -- |
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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.login the runtime log directory configured bysrc.launcher.runtime_paths(typicallybackend/logsin 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
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
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
emit(record)
¶
Hand the record to the file handler, unless the app already wrote it.
Source code in backend/src/core/logging.py
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
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. |
'erudi'
|
Returns:
| Type | Description |
|---|---|
Logger
|
logging.Logger: Configured logger instance with console and file handlers. |
Source code in backend/src/core/logging.py
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
utc_formatter(fmt)
¶
Build a formatter emitting UTC ISO-8601 timestamps with ms and Z suffix.