mirror of
https://github.com/lfnovo/open-notebook.git
synced 2026-04-28 11:30:00 +00:00
Some checks failed
Development Build / extract-version (push) Has been cancelled
Tests / Backend Tests (push) Has been cancelled
Tests / Frontend Tests (push) Has been cancelled
Development Build / build-regular (push) Has been cancelled
Development Build / build-single (push) Has been cancelled
Development Build / summary (push) Has been cancelled
* feat(podcasts): integrate model registry for profiles and credential passthrough Replace loose provider/model string fields with record<model> references in podcast profiles, enabling credential passthrough to podcast-creator. Backend: - EpisodeProfile: outline_llm, transcript_llm (record<model>) replace outline_provider/outline_model strings. New language field (BCP 47). - SpeakerProfile: voice_model (record<model>) replaces tts_provider/ tts_model strings. Per-speaker voice_model override support. - Migration 14: schema changes making legacy fields optional, adding new record<model> fields. - Data migration (migration.py): auto-converts legacy profiles to model registry references on startup. Idempotent. - podcast_commands.py: resolves credentials for ALL profiles before calling podcast-creator. - New /api/languages endpoint (pycountry + babel) with BCP 47 locale codes (pt-BR, en-US, etc.). Frontend: - Episode/speaker profile forms use ModelSelector instead of manual provider/model dropdowns. - Language dropdown with BCP 47 codes in episode profile form. - Per-speaker TTS voice model override in speaker profile form. - "Templates" tab renamed to "Profiles". - Setup required badge on unconfigured profiles. - i18n updated across all 8 locales. Closes #486, closes #552 * fix(i18n): remove unused legacy podcast provider/model keys Remove 10 orphaned i18n keys across all 8 locales that were left behind after replacing manual provider/model dropdowns with ModelSelector. * fix: address review violations in podcast model registry - P1: Remove profiles with failed model resolution from dicts to prevent podcast-creator validation errors on unrelated profiles - P2: Use centralized QUERY_KEYS.languages instead of inline key - P3: Fix ISO 639-1 → BCP 47 in model field description and CLAUDE.md - P3: Update "templates" → "profiles" in locale string values (all 8) * chore: bump version to 1.8.0
292 lines
9.3 KiB
Python
292 lines
9.3 KiB
Python
# Load environment variables
|
|
from dotenv import load_dotenv
|
|
|
|
load_dotenv()
|
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import JSONResponse
|
|
from loguru import logger
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
|
|
from api.auth import PasswordAuthMiddleware
|
|
from open_notebook.exceptions import (
|
|
AuthenticationError,
|
|
ConfigurationError,
|
|
ExternalServiceError,
|
|
InvalidInputError,
|
|
NetworkError,
|
|
NotFoundError,
|
|
OpenNotebookError,
|
|
RateLimitError,
|
|
)
|
|
from api.routers import (
|
|
auth,
|
|
chat,
|
|
config,
|
|
context,
|
|
credentials,
|
|
embedding,
|
|
embedding_rebuild,
|
|
episode_profiles,
|
|
insights,
|
|
languages,
|
|
models,
|
|
notebooks,
|
|
notes,
|
|
podcasts,
|
|
search,
|
|
settings,
|
|
source_chat,
|
|
sources,
|
|
speaker_profiles,
|
|
transformations,
|
|
)
|
|
from api.routers import commands as commands_router
|
|
from open_notebook.database.async_migrate import AsyncMigrationManager
|
|
from open_notebook.utils.encryption import get_secret_from_env
|
|
|
|
# Import commands to register them in the API process
|
|
try:
|
|
logger.info("Commands imported in API process")
|
|
except Exception as e:
|
|
logger.error(f"Failed to import commands in API process: {e}")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
"""
|
|
Lifespan event handler for the FastAPI application.
|
|
Runs database migrations automatically on startup.
|
|
"""
|
|
import os
|
|
|
|
# Startup: Security checks
|
|
logger.info("Starting API initialization...")
|
|
|
|
# Security check: Encryption key
|
|
if not get_secret_from_env("OPEN_NOTEBOOK_ENCRYPTION_KEY"):
|
|
logger.warning(
|
|
"OPEN_NOTEBOOK_ENCRYPTION_KEY not set. "
|
|
"API key encryption will fail until this is configured. "
|
|
"Set OPEN_NOTEBOOK_ENCRYPTION_KEY to any secret string."
|
|
)
|
|
|
|
# Run database migrations
|
|
|
|
try:
|
|
migration_manager = AsyncMigrationManager()
|
|
current_version = await migration_manager.get_current_version()
|
|
logger.info(f"Current database version: {current_version}")
|
|
|
|
if await migration_manager.needs_migration():
|
|
logger.warning("Database migrations are pending. Running migrations...")
|
|
await migration_manager.run_migration_up()
|
|
new_version = await migration_manager.get_current_version()
|
|
logger.success(
|
|
f"Migrations completed successfully. Database is now at version {new_version}"
|
|
)
|
|
else:
|
|
logger.info(
|
|
"Database is already at the latest version. No migrations needed."
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"CRITICAL: Database migration failed: {str(e)}")
|
|
logger.exception(e)
|
|
# Fail fast - don't start the API with an outdated database schema
|
|
raise RuntimeError(f"Failed to run database migrations: {str(e)}") from e
|
|
|
|
# Run podcast profile data migration (legacy strings -> Model registry)
|
|
try:
|
|
from open_notebook.podcasts.migration import migrate_podcast_profiles
|
|
|
|
await migrate_podcast_profiles()
|
|
except Exception as e:
|
|
logger.warning(f"Podcast profile migration encountered errors: {e}")
|
|
# Non-fatal: profiles can be migrated manually via UI
|
|
|
|
logger.success("API initialization completed successfully")
|
|
|
|
# Yield control to the application
|
|
yield
|
|
|
|
# Shutdown: cleanup if needed
|
|
logger.info("API shutdown complete")
|
|
|
|
|
|
app = FastAPI(
|
|
title="Open Notebook API",
|
|
description="API for Open Notebook - Research Assistant",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
# Add password authentication middleware first
|
|
# Exclude /api/auth/status and /api/config from authentication
|
|
app.add_middleware(
|
|
PasswordAuthMiddleware,
|
|
excluded_paths=[
|
|
"/",
|
|
"/health",
|
|
"/docs",
|
|
"/openapi.json",
|
|
"/redoc",
|
|
"/api/auth/status",
|
|
"/api/config",
|
|
],
|
|
)
|
|
|
|
# Add CORS middleware last (so it processes first)
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"], # In production, replace with specific origins
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
# Custom exception handler to ensure CORS headers are included in error responses
|
|
# This helps when errors occur before the CORS middleware can process them
|
|
@app.exception_handler(StarletteHTTPException)
|
|
async def custom_http_exception_handler(request: Request, exc: StarletteHTTPException):
|
|
"""
|
|
Custom exception handler that ensures CORS headers are included in error responses.
|
|
This is particularly important for 413 (Payload Too Large) errors during file uploads.
|
|
|
|
Note: If a reverse proxy (nginx, traefik) returns 413 before the request reaches
|
|
FastAPI, this handler won't be called. In that case, configure your reverse proxy
|
|
to add CORS headers to error responses.
|
|
"""
|
|
# Get the origin from the request
|
|
origin = request.headers.get("origin", "*")
|
|
|
|
return JSONResponse(
|
|
status_code=exc.status_code,
|
|
content={"detail": exc.detail},
|
|
headers={
|
|
**(exc.headers or {}), "Access-Control-Allow-Origin": origin,
|
|
"Access-Control-Allow-Credentials": "true",
|
|
"Access-Control-Allow-Methods": "*",
|
|
"Access-Control-Allow-Headers": "*",
|
|
},
|
|
)
|
|
|
|
|
|
def _cors_headers(request: Request) -> dict[str, str]:
|
|
origin = request.headers.get("origin", "*")
|
|
return {
|
|
"Access-Control-Allow-Origin": origin,
|
|
"Access-Control-Allow-Credentials": "true",
|
|
"Access-Control-Allow-Methods": "*",
|
|
"Access-Control-Allow-Headers": "*",
|
|
}
|
|
|
|
|
|
@app.exception_handler(NotFoundError)
|
|
async def not_found_error_handler(request: Request, exc: NotFoundError):
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"detail": str(exc)},
|
|
headers=_cors_headers(request),
|
|
)
|
|
|
|
|
|
@app.exception_handler(InvalidInputError)
|
|
async def invalid_input_error_handler(request: Request, exc: InvalidInputError):
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={"detail": str(exc)},
|
|
headers=_cors_headers(request),
|
|
)
|
|
|
|
|
|
@app.exception_handler(AuthenticationError)
|
|
async def authentication_error_handler(request: Request, exc: AuthenticationError):
|
|
return JSONResponse(
|
|
status_code=401,
|
|
content={"detail": str(exc)},
|
|
headers=_cors_headers(request),
|
|
)
|
|
|
|
|
|
@app.exception_handler(RateLimitError)
|
|
async def rate_limit_error_handler(request: Request, exc: RateLimitError):
|
|
return JSONResponse(
|
|
status_code=429,
|
|
content={"detail": str(exc)},
|
|
headers=_cors_headers(request),
|
|
)
|
|
|
|
|
|
@app.exception_handler(ConfigurationError)
|
|
async def configuration_error_handler(request: Request, exc: ConfigurationError):
|
|
return JSONResponse(
|
|
status_code=422,
|
|
content={"detail": str(exc)},
|
|
headers=_cors_headers(request),
|
|
)
|
|
|
|
|
|
@app.exception_handler(NetworkError)
|
|
async def network_error_handler(request: Request, exc: NetworkError):
|
|
return JSONResponse(
|
|
status_code=502,
|
|
content={"detail": str(exc)},
|
|
headers=_cors_headers(request),
|
|
)
|
|
|
|
|
|
@app.exception_handler(ExternalServiceError)
|
|
async def external_service_error_handler(request: Request, exc: ExternalServiceError):
|
|
return JSONResponse(
|
|
status_code=502,
|
|
content={"detail": str(exc)},
|
|
headers=_cors_headers(request),
|
|
)
|
|
|
|
|
|
@app.exception_handler(OpenNotebookError)
|
|
async def open_notebook_error_handler(request: Request, exc: OpenNotebookError):
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"detail": str(exc)},
|
|
headers=_cors_headers(request),
|
|
)
|
|
|
|
|
|
# Include routers
|
|
app.include_router(auth.router, prefix="/api", tags=["auth"])
|
|
app.include_router(config.router, prefix="/api", tags=["config"])
|
|
app.include_router(notebooks.router, prefix="/api", tags=["notebooks"])
|
|
app.include_router(search.router, prefix="/api", tags=["search"])
|
|
app.include_router(models.router, prefix="/api", tags=["models"])
|
|
app.include_router(transformations.router, prefix="/api", tags=["transformations"])
|
|
app.include_router(notes.router, prefix="/api", tags=["notes"])
|
|
app.include_router(embedding.router, prefix="/api", tags=["embedding"])
|
|
app.include_router(
|
|
embedding_rebuild.router, prefix="/api/embeddings", tags=["embeddings"]
|
|
)
|
|
app.include_router(settings.router, prefix="/api", tags=["settings"])
|
|
app.include_router(context.router, prefix="/api", tags=["context"])
|
|
app.include_router(sources.router, prefix="/api", tags=["sources"])
|
|
app.include_router(insights.router, prefix="/api", tags=["insights"])
|
|
app.include_router(commands_router.router, prefix="/api", tags=["commands"])
|
|
app.include_router(podcasts.router, prefix="/api", tags=["podcasts"])
|
|
app.include_router(episode_profiles.router, prefix="/api", tags=["episode-profiles"])
|
|
app.include_router(speaker_profiles.router, prefix="/api", tags=["speaker-profiles"])
|
|
app.include_router(chat.router, prefix="/api", tags=["chat"])
|
|
app.include_router(source_chat.router, prefix="/api", tags=["source-chat"])
|
|
app.include_router(credentials.router, prefix="/api", tags=["credentials"])
|
|
app.include_router(languages.router, prefix="/api", tags=["languages"])
|
|
|
|
|
|
@app.get("/")
|
|
async def root():
|
|
return {"message": "Open Notebook API is running"}
|
|
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return {"status": "healthy"}
|