chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:10:23 +08:00
commit fbab2c6005
567 changed files with 114434 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
@../open_notebook/AGENTS.md
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
View File
+82
View File
@@ -0,0 +1,82 @@
import secrets
from typing import Optional
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import JSONResponse, Response
from starlette.types import ASGIApp
from open_notebook.utils.encryption import get_secret_from_env
class PasswordAuthMiddleware(BaseHTTPMiddleware):
"""
Middleware to check password authentication for all API requests.
Auth is fully disabled (no hardcoded default password) if
OPEN_NOTEBOOK_PASSWORD is not set.
Supports Docker secrets via OPEN_NOTEBOOK_PASSWORD_FILE.
"""
def __init__(
self, app: ASGIApp, excluded_paths: Optional[list[str]] = None
) -> None:
super().__init__(app)
self.password = get_secret_from_env("OPEN_NOTEBOOK_PASSWORD")
self.excluded_paths: list[str] = excluded_paths or [
"/",
"/health",
"/docs",
"/openapi.json",
"/redoc",
]
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
# Skip authentication if no password is set
if not self.password:
return await call_next(request)
# Skip authentication for excluded paths
if request.url.path in self.excluded_paths:
return await call_next(request)
# Skip authentication for CORS preflight requests (OPTIONS)
if request.method == "OPTIONS":
return await call_next(request)
# Check authorization header
auth_header = request.headers.get("Authorization")
if not auth_header:
return JSONResponse(
status_code=401,
content={"detail": "Missing authorization header"},
headers={"WWW-Authenticate": "Bearer"},
)
# Expected format: "Bearer {password}"
try:
scheme, credentials = auth_header.split(" ", 1)
if scheme.lower() != "bearer":
raise ValueError("Invalid authentication scheme")
except ValueError:
return JSONResponse(
status_code=401,
content={"detail": "Invalid authorization header format"},
headers={"WWW-Authenticate": "Bearer"},
)
# Check password (constant-time to avoid a timing side-channel)
if not secrets.compare_digest(
credentials.encode("utf-8"), self.password.encode("utf-8")
):
return JSONResponse(
status_code=401,
content={"detail": "Invalid password"},
headers={"WWW-Authenticate": "Bearer"},
)
# Password is correct, proceed with the request
response = await call_next(request)
return response
+92
View File
@@ -0,0 +1,92 @@
from typing import Any, Dict, List, Optional
from loguru import logger
from surreal_commands import get_command_status, submit_command
class CommandService:
"""Generic service layer for command operations"""
@staticmethod
async def submit_command_job(
module_name: str, # Actually app_name for surreal-commands
command_name: str,
command_args: Dict[str, Any],
context: Optional[Dict[str, Any]] = None,
) -> str:
"""Submit a generic command job for background processing"""
try:
# Ensure command modules are imported before submitting
# This is needed because submit_command validates against local registry
try:
import commands.podcast_commands # noqa: F401
except ImportError as import_err:
logger.error(f"Failed to import command modules: {import_err}")
raise ValueError("Command modules not available")
# surreal-commands expects: submit_command(app_name, command_name, args)
cmd_id = submit_command(
module_name, # This is actually the app name (e.g., "open_notebook")
command_name, # Command name (e.g., "generate_podcast")
command_args, # Input data
)
# Convert RecordID to string if needed
if not cmd_id:
raise ValueError("Failed to get cmd_id from submit_command")
cmd_id_str = str(cmd_id)
logger.info(
f"Submitted command job: {cmd_id_str} for {module_name}.{command_name}"
)
return cmd_id_str
except Exception as e:
logger.error(f"Failed to submit command job: {e}")
raise
@staticmethod
async def get_command_status(job_id: str) -> Dict[str, Any]:
"""Get status of any command job"""
try:
status = await get_command_status(job_id)
return {
"job_id": job_id,
"status": status.status if status else "unknown",
"result": status.result if status else None,
"error_message": getattr(status, "error_message", None)
if status
else None,
"created": str(status.created)
if status and hasattr(status, "created") and status.created
else None,
"updated": str(status.updated)
if status and hasattr(status, "updated") and status.updated
else None,
"progress": getattr(status, "progress", None) if status else None,
}
except Exception as e:
logger.error(f"Failed to get command status: {e}")
raise
@staticmethod
async def list_command_jobs(
module_filter: Optional[str] = None,
command_filter: Optional[str] = None,
status_filter: Optional[str] = None,
limit: int = 50,
) -> List[Dict[str, Any]]:
"""List command jobs with optional filtering"""
# This will be implemented with proper SurrealDB queries
# For now, return empty list as this is foundation phase
return []
@staticmethod
async def cancel_command_job(job_id: str) -> bool:
"""Cancel a running command job"""
try:
# Implementation depends on surreal-commands cancellation support
# For now, just log the attempt
logger.info(f"Attempting to cancel job: {job_id}")
return True
except Exception as e:
logger.error(f"Failed to cancel command job: {e}")
raise
+774
View File
@@ -0,0 +1,774 @@
"""
Credentials Service
Business logic for managing AI provider credentials.
Extracted from the credentials router to follow the service layer pattern.
All functions raise ValueError for business errors (router converts to HTTPException).
"""
import os
from typing import Dict, List
import httpx
from loguru import logger
from pydantic import SecretStr
from api.models import CredentialResponse
from open_notebook.ai.model_discovery import (
ANTHROPIC_FALLBACK_MODELS,
classify_model_type,
fetch_anthropic_model_ids,
)
from open_notebook.ai.provider_registry import PROVIDERS
from open_notebook.domain.credential import Credential
from open_notebook.utils.encryption import get_secret_from_env
from open_notebook.utils.url_validation import validate_url
# =============================================================================
# Constants
# =============================================================================
# Provider environment variable configuration, derived from the provider
# registry (open_notebook/ai/provider_registry.py — the source of truth).
# - "required": ALL listed env vars must be set for the provider to be considered configured.
# - "required_any": at least ONE of the listed env vars must be set.
# - "optional": additional env vars used during migration but not required.
PROVIDER_ENV_CONFIG: Dict[str, dict] = {
name: spec.env_config() for name, spec in PROVIDERS.items()
}
PROVIDER_MODALITIES: Dict[str, List[str]] = {
name: list(spec.modalities) for name, spec in PROVIDERS.items()
}
# =============================================================================
# Helpers
# =============================================================================
def require_encryption_key() -> None:
"""Raise ValueError if encryption key is not configured."""
if not get_secret_from_env("OPEN_NOTEBOOK_ENCRYPTION_KEY"):
raise ValueError(
"Encryption key not configured. "
"Set OPEN_NOTEBOOK_ENCRYPTION_KEY to enable storing API keys."
)
def credential_to_response(cred: Credential, model_count: int = 0) -> CredentialResponse:
"""Convert a Credential domain object to API response."""
return CredentialResponse(
id=cred.id or "",
name=cred.name,
provider=cred.provider,
modalities=cred.modalities,
base_url=cred.base_url,
endpoint=cred.endpoint,
api_version=cred.api_version,
endpoint_llm=cred.endpoint_llm,
endpoint_embedding=cred.endpoint_embedding,
endpoint_stt=cred.endpoint_stt,
endpoint_tts=cred.endpoint_tts,
project=cred.project,
location=cred.location,
credentials_path=cred.credentials_path,
num_ctx=cred.num_ctx,
has_api_key=cred.api_key is not None,
created=str(cred.created) if cred.created else "",
updated=str(cred.updated) if cred.updated else "",
model_count=model_count,
decryption_error=cred.decryption_error,
)
def check_env_configured(provider: str) -> bool:
"""Check if a provider has sufficient env vars configured for migration."""
config = PROVIDER_ENV_CONFIG.get(provider)
if not config:
return False
if "required_any" in config:
return any(bool(os.environ.get(v, "").strip()) for v in config["required_any"])
elif "required" in config:
return all(bool(os.environ.get(v, "").strip()) for v in config["required"])
return False
def get_default_modalities(provider: str) -> List[str]:
"""Get default modalities for a provider."""
return PROVIDER_MODALITIES.get(provider.lower(), ["language"])
def create_credential_from_env(provider: str) -> Credential:
"""Create a Credential from environment variables for a given provider."""
modalities = get_default_modalities(provider)
name = "Default (Migrated from env)"
if provider == "ollama":
return Credential(
name=name,
provider=provider,
modalities=modalities,
base_url=os.environ.get("OLLAMA_API_BASE"),
)
elif provider == "vertex":
return Credential(
name=name,
provider=provider,
modalities=modalities,
project=os.environ.get("VERTEX_PROJECT"),
location=os.environ.get("VERTEX_LOCATION"),
credentials_path=os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"),
)
elif provider == "azure":
return Credential(
name=name,
provider=provider,
modalities=modalities,
api_key=SecretStr(os.environ["AZURE_OPENAI_API_KEY"]),
endpoint=os.environ.get("AZURE_OPENAI_ENDPOINT"),
api_version=os.environ.get("AZURE_OPENAI_API_VERSION"),
endpoint_llm=os.environ.get("AZURE_OPENAI_ENDPOINT_LLM"),
endpoint_embedding=os.environ.get("AZURE_OPENAI_ENDPOINT_EMBEDDING"),
endpoint_stt=os.environ.get("AZURE_OPENAI_ENDPOINT_STT"),
endpoint_tts=os.environ.get("AZURE_OPENAI_ENDPOINT_TTS"),
)
elif provider == "openai_compatible":
api_key = os.environ.get("OPENAI_COMPATIBLE_API_KEY")
return Credential(
name=name,
provider=provider,
modalities=modalities,
api_key=SecretStr(api_key) if api_key else None,
base_url=os.environ.get("OPENAI_COMPATIBLE_BASE_URL"),
)
elif provider == "google":
# Support both GOOGLE_API_KEY and GEMINI_API_KEY (fallback)
api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY")
return Credential(
name=name,
provider=provider,
modalities=modalities,
api_key=SecretStr(api_key) if api_key else None,
)
else:
# Simple API key providers
config = PROVIDER_ENV_CONFIG.get(provider, {})
required = config.get("required", [])
env_var = required[0] if required else None
api_key = os.environ.get(env_var) if env_var else None
return Credential(
name=name,
provider=provider,
modalities=modalities,
api_key=SecretStr(api_key) if api_key else None,
)
# =============================================================================
# Service Functions
# =============================================================================
async def get_provider_status() -> dict:
"""
Get configuration status: encryption key status, and per-provider
configured/source information.
"""
encryption_configured = bool(get_secret_from_env("OPEN_NOTEBOOK_ENCRYPTION_KEY"))
configured: Dict[str, bool] = {}
source: Dict[str, str] = {}
for provider in PROVIDER_ENV_CONFIG:
env_configured = check_env_configured(provider)
try:
db_credentials = await Credential.get_by_provider(provider)
db_configured = len(db_credentials) > 0
except Exception:
db_configured = False
configured[provider] = db_configured or env_configured
if db_configured:
source[provider] = "database"
elif env_configured:
source[provider] = "environment"
else:
source[provider] = "none"
return {
"configured": configured,
"source": source,
"encryption_configured": encryption_configured,
}
async def get_env_status() -> Dict[str, bool]:
"""Check what's configured via environment variables."""
env_status: Dict[str, bool] = {}
for provider in PROVIDER_ENV_CONFIG:
env_status[provider] = check_env_configured(provider)
return env_status
async def test_credential(credential_id: str) -> dict:
"""
Test connection using a credential's configuration.
Returns dict with provider, success, message keys.
"""
provider = "unknown"
try:
cred = await Credential.get(credential_id)
config = cred.to_esperanto_config()
from open_notebook.ai.connection_tester import (
_is_vertex_credentials_file_error,
_test_azure_connection,
_test_ollama_connection,
_test_openai_compatible_connection,
classify_provider_test_error,
)
provider = cred.provider.lower()
# Handle special providers
if provider == "ollama":
base_url = config.get("base_url", "http://localhost:11434")
success, message = await _test_ollama_connection(base_url)
return {"provider": provider, "success": success, "message": message}
if provider == "openai_compatible":
base_url = config.get("base_url")
api_key = config.get("api_key")
if not base_url:
return {
"provider": provider,
"success": False,
"message": "No base URL configured",
}
success, message = await _test_openai_compatible_connection(
base_url, api_key
)
return {"provider": provider, "success": success, "message": message}
if provider == "azure":
success, message = await _test_azure_connection(
endpoint=config.get("endpoint"),
api_key=config.get("api_key"),
api_version=config.get("api_version"),
)
return {"provider": provider, "success": success, "message": message}
# Standard provider: use Esperanto to create and test
from esperanto.factory import AIFactory
from open_notebook.ai.connection_tester import TEST_MODELS
if provider not in TEST_MODELS:
return {
"provider": provider,
"success": False,
"message": f"Unknown provider: {provider}",
}
test_model, test_type = TEST_MODELS[provider]
if not test_model:
return {
"provider": provider,
"success": False,
"message": f"No test model configured for {provider}",
}
if test_type == "language":
model = AIFactory.create_language(
model_name=test_model, provider=provider, config=config
)
lc_model = model.to_langchain()
await lc_model.ainvoke("Hi")
return {"provider": provider, "success": True, "message": "Connection successful"}
elif test_type == "embedding":
embedding_model = AIFactory.create_embedding(
model_name=test_model, provider=provider, config=config
)
await embedding_model.aembed(["test"])
return {"provider": provider, "success": True, "message": "Connection successful"}
elif test_type == "text_to_speech":
AIFactory.create_text_to_speech(model_name=test_model, provider=provider, config=config)
return {
"provider": provider,
"success": True,
"message": "Connection successful (key format valid)",
}
return {
"provider": provider,
"success": False,
"message": f"Unsupported test type: {test_type}",
}
except Exception as e:
if provider == "vertex" and _is_vertex_credentials_file_error(e):
logger.debug(f"Vertex credentials file error for credential {credential_id}: {e}")
return {
"provider": provider,
"success": False,
"message": "Invalid or inaccessible credentials file",
}
error_msg = str(e)
success, message = classify_provider_test_error(error_msg)
if not success:
logger.debug(f"Test connection error for credential {credential_id}: {e}")
return {"provider": provider, "success": success, "message": message}
async def discover_with_config(provider: str, config: dict) -> List[dict]:
"""
Discover models using explicit config instead of env vars.
Returns model names only — no type classification.
The user chooses the model type when registering.
"""
api_key = config.get("api_key")
base_url = config.get("base_url")
def models_endpoint(url: str) -> str:
trimmed = url.rstrip("/")
if trimmed.endswith("/models"):
return trimmed
return f"{trimmed}/models"
# Static model lists for providers without a listing API
STATIC_MODELS: Dict[str, List[str]] = {
"voyage": [
"voyage-3", "voyage-3-lite", "voyage-code-3",
"voyage-finance-2", "voyage-law-2", "voyage-multilingual-2",
],
"elevenlabs": [
"eleven_multilingual_v2", "eleven_turbo_v2_5",
"eleven_turbo_v2", "eleven_monolingual_v1",
"scribe_v1", # speech-to-text
],
"deepgram": [
"aura-2-thalia-en", "aura-2-andromeda-en", "aura-2-helena-en",
"aura-2-apollo-en", "aura-2-arcas-en", "aura-2-asteria-en",
"aura-2-athena-en", "aura-2-hera-en", "aura-2-hermes-en",
"aura-2-atlas-en",
],
}
if provider in STATIC_MODELS:
if not api_key and provider != "ollama":
return []
return [
{"name": m, "provider": provider}
for m in STATIC_MODELS[provider]
]
if provider == "anthropic":
if not api_key:
return []
try:
model_names = await fetch_anthropic_model_ids(api_key)
except Exception as e:
logger.warning(
f"Failed to discover Anthropic models, using static fallback: {e}"
)
model_names = list(ANTHROPIC_FALLBACK_MODELS)
return [{"name": m, "provider": "anthropic"} for m in model_names]
# API-based discovery URLs (OpenAI-style /models endpoints), from the registry
url_map = {
name: spec.openai_compat_discovery_url
for name, spec in PROVIDERS.items()
if spec.openai_compat_discovery_url
}
if provider == "ollama":
ollama_url = base_url or "http://localhost:11434"
try:
# Re-validate at request time: the base_url may have been saved
# against a hostname that only later resolved to an internal
# address (DNS rebinding).
await validate_url(ollama_url, "ollama")
async with httpx.AsyncClient() as client:
response = await client.get(f"{ollama_url}/api/tags", timeout=10.0)
response.raise_for_status()
data = response.json()
return [
{
"name": m.get("name", ""),
"provider": "ollama",
"model_type": classify_model_type(m.get("name", ""), "ollama"),
}
for m in data.get("models", [])
if m.get("name")
]
except Exception as e:
logger.warning(f"Failed to discover Ollama models: {e}")
return []
if provider == "openai_compatible":
if not base_url:
return []
try:
# Re-validate at request time (see ollama branch above).
await validate_url(base_url, "openai_compatible")
headers = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
async with httpx.AsyncClient() as client:
response = await client.get(
models_endpoint(base_url),
headers=headers,
timeout=30.0,
)
response.raise_for_status()
data = response.json()
return [
{"name": m.get("id", ""), "provider": "openai_compatible"}
for m in data.get("data", [])
if m.get("id")
]
except Exception as e:
logger.warning(f"Failed to discover openai_compatible models: {e}")
return []
if provider == "azure":
endpoint = config.get("endpoint")
api_version = config.get("api_version", "2024-10-21")
if not endpoint or not api_key:
return []
try:
# Re-validate at request time (see ollama branch above).
await validate_url(endpoint, "azure")
url = f"{endpoint.rstrip('/')}/openai/models?api-version={api_version}"
headers = {"api-key": api_key}
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=headers, timeout=30.0)
response.raise_for_status()
data = response.json()
return [
{"name": m.get("id", ""), "provider": "azure"}
for m in data.get("data", [])
if m.get("id")
]
except Exception as e:
logger.warning(f"Failed to discover Azure models: {e}")
return []
if provider == "vertex":
# Vertex AI requires service-account OAuth2 for model listing.
# Return a curated static list of well-known Vertex models instead.
VERTEX_MODELS = [
"gemini-3.5-flash",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"text-embedding-005",
]
return [{"name": m, "provider": "vertex"} for m in VERTEX_MODELS]
if provider == "google":
try:
headers = {"X-Goog-Api-Key": api_key} if api_key else {}
async with httpx.AsyncClient() as client:
response = await client.get(
"https://generativelanguage.googleapis.com/v1/models",
headers=headers,
timeout=30.0,
)
response.raise_for_status()
data = response.json()
return [
{
"name": model.get("name", "").replace("models/", ""),
"provider": "google",
"description": model.get("displayName"),
}
for model in data.get("models", [])
if model.get("name")
]
except Exception as e:
logger.warning(f"Failed to discover Google models: {e}")
return []
# Standard OpenAI-style API discovery
discovery_url = url_map.get(provider)
if provider == "openai" and base_url:
discovery_url = models_endpoint(base_url)
if not discovery_url or not api_key:
return []
try:
async with httpx.AsyncClient() as client:
response = await client.get(
discovery_url,
headers={"Authorization": f"Bearer {api_key}"},
timeout=30.0,
)
response.raise_for_status()
data = response.json()
return [
{
"name": m.get("id", ""),
"provider": provider,
"description": m.get("name"),
}
for m in data.get("data", [])
if m.get("id")
]
except Exception as e:
logger.warning(f"Failed to discover {provider} models: {e}")
return []
async def register_models(credential_id: str, models_data: list) -> dict:
"""
Register discovered models and link them to a credential.
Args:
credential_id: The credential ID to link models to
models_data: List of dicts with name, provider, model_type
Returns:
dict with created and existing counts
"""
cred = await Credential.get(credential_id)
from open_notebook.ai.models import Model
from open_notebook.database.repository import repo_query
# Batch fetch existing models for this provider
existing_models = await repo_query(
"SELECT string::lowercase(name) as name, string::lowercase(type) as type FROM model "
"WHERE string::lowercase(provider) = $provider",
{"provider": cred.provider.lower()},
)
existing_keys = {(m["name"], m["type"]) for m in existing_models}
created = 0
existing = 0
for model_data in models_data:
key = (model_data.name.lower(), model_data.model_type.lower())
if key in existing_keys:
existing += 1
continue
new_model = Model(
name=model_data.name,
provider=model_data.provider or cred.provider,
type=model_data.model_type,
credential=cred.id,
)
await new_model.save()
created += 1
return {"created": created, "existing": existing}
async def migrate_from_provider_config() -> dict:
"""
Migrate existing ProviderConfig data to individual credential records.
Returns dict with message, migrated, skipped, errors.
"""
logger.info("=== Starting ProviderConfig migration ===")
require_encryption_key()
logger.info("Encryption key verified")
from open_notebook.domain.provider_config import ProviderConfig
config = await ProviderConfig.get_instance()
logger.info(
f"Found ProviderConfig with {len(config.credentials)} provider(s): "
f"{', '.join(config.credentials.keys())}"
)
migrated = []
skipped = []
errors = []
for provider, credentials_list in config.credentials.items():
for old_cred in credentials_list:
try:
# Check if a credential already exists for this provider with same name
existing = await Credential.get_by_provider(provider)
names = [c.name for c in existing]
if old_cred.name in names:
logger.info(
f"[{provider}/{old_cred.name}] Already exists in DB, skipping"
)
skipped.append(f"{provider}/{old_cred.name}")
continue
# Determine modalities from the provider type
modalities = get_default_modalities(provider)
logger.info(f"[{provider}/{old_cred.name}] Creating credential")
new_cred = Credential(
name=old_cred.name,
provider=provider,
modalities=modalities,
api_key=old_cred.api_key,
base_url=old_cred.base_url,
endpoint=old_cred.endpoint,
api_version=old_cred.api_version,
endpoint_llm=old_cred.endpoint_llm,
endpoint_embedding=old_cred.endpoint_embedding,
endpoint_stt=old_cred.endpoint_stt,
endpoint_tts=old_cred.endpoint_tts,
project=old_cred.project,
location=old_cred.location,
credentials_path=old_cred.credentials_path,
)
await new_cred.save()
logger.info(
f"[{provider}/{old_cred.name}] Credential saved (id={new_cred.id})"
)
# Link existing models for this provider to the new credential
from open_notebook.ai.models import Model
from open_notebook.database.repository import repo_query
provider_models = await repo_query(
"SELECT * FROM model WHERE string::lowercase(provider) = $provider AND credential IS NONE",
{"provider": provider.lower()},
)
if provider_models:
logger.info(
f"[{provider}/{old_cred.name}] Linking {len(provider_models)} "
f"unassigned model(s)"
)
for model_data in provider_models:
model = Model(**model_data)
model.credential = new_cred.id
await model.save()
migrated.append(f"{provider}/{old_cred.name}")
except Exception as e:
logger.error(
f"[{provider}/{old_cred.name}] Migration FAILED: "
f"{type(e).__name__}: {e}",
exc_info=True,
)
errors.append(f"{provider}/{old_cred.name}: {e}")
logger.info(
f"=== ProviderConfig migration complete === "
f"migrated={len(migrated)} skipped={len(skipped)} errors={len(errors)}"
)
if migrated:
logger.info(f" Migrated: {', '.join(migrated)}")
if skipped:
logger.info(f" Skipped: {', '.join(skipped)}")
if errors:
logger.error(f" Errors: {'; '.join(errors)}")
return {
"message": f"Migration complete. Migrated {len(migrated)} credentials.",
"migrated": migrated,
"skipped": skipped,
"errors": errors,
}
async def migrate_from_env() -> dict:
"""
Migrate API keys from environment variables to credential records.
Returns dict with message, migrated, skipped, not_configured, errors.
"""
logger.info("=== Starting environment variable migration ===")
logger.info(
f"Checking {len(PROVIDER_ENV_CONFIG)} providers: "
f"{', '.join(PROVIDER_ENV_CONFIG.keys())}"
)
require_encryption_key()
logger.info("Encryption key verified")
from open_notebook.ai.models import Model
from open_notebook.database.repository import repo_query
migrated = []
skipped = []
not_configured = []
errors = []
for provider in PROVIDER_ENV_CONFIG:
try:
if not check_env_configured(provider):
logger.debug(f"[{provider}] No env vars configured, skipping")
not_configured.append(provider)
continue
logger.info(f"[{provider}] Env vars detected, checking for existing credentials")
existing = await Credential.get_by_provider(provider)
if existing:
logger.info(
f"[{provider}] Already has {len(existing)} credential(s) in DB, skipping"
)
skipped.append(provider)
continue
logger.info(f"[{provider}] Creating credential from env vars")
cred = create_credential_from_env(provider)
await cred.save()
logger.info(f"[{provider}] Credential saved successfully (id={cred.id})")
# Link unassigned models to this credential
provider_models = await repo_query(
"SELECT * FROM model WHERE string::lowercase(provider) = $provider AND credential IS NONE",
{"provider": provider.lower()},
)
if provider_models:
logger.info(
f"[{provider}] Linking {len(provider_models)} unassigned model(s) "
f"to credential {cred.id}"
)
for model_data in provider_models:
model = Model(**model_data)
model.credential = cred.id
await model.save()
else:
logger.info(f"[{provider}] No unassigned models to link")
migrated.append(provider)
except Exception as e:
logger.error(
f"[{provider}] Migration FAILED: {type(e).__name__}: {e}",
exc_info=True,
)
errors.append(f"{provider}: {e}")
logger.info(
f"=== Environment variable migration complete === "
f"migrated={len(migrated)} skipped={len(skipped)} "
f"not_configured={len(not_configured)} errors={len(errors)}"
)
if migrated:
logger.info(f" Migrated: {', '.join(migrated)}")
if skipped:
logger.info(f" Skipped (already in DB): {', '.join(skipped)}")
if errors:
logger.error(f" Errors: {'; '.join(errors)}")
return {
"message": f"Migration complete. Migrated {len(migrated)} providers.",
"migrated": migrated,
"skipped": skipped,
"not_configured": not_configured,
"errors": errors,
}
+407
View File
@@ -0,0 +1,407 @@
# Load environment variables
from dotenv import load_dotenv
load_dotenv()
import asyncio
import os
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 api.middleware import MaxBodySizeMiddleware, get_max_upload_size_bytes
from api.routers import (
auth,
chat,
config,
credentials,
embedding,
embedding_rebuild,
episode_profiles,
insights,
languages,
models,
notebooks,
notes,
podcasts,
providers,
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.exceptions import (
AuthenticationError,
ConfigurationError,
ExternalServiceError,
InvalidInputError,
NetworkError,
NotFoundError,
OpenNotebookError,
RateLimitError,
UnsupportedTypeException,
)
from open_notebook.utils.encryption import get_secret_from_env
def _parse_cors_origins(raw: str) -> list[str]:
"""Parse CORS_ORIGINS env value into a list of origins."""
value = raw.strip()
if value == "*":
return ["*"]
return [origin.strip() for origin in value.split(",") if origin.strip()]
# Parsed once at module load; CORS_ORIGINS changes require a restart.
_cors_origins_raw = os.getenv("CORS_ORIGINS")
CORS_ALLOWED_ORIGINS = _parse_cors_origins(_cors_origins_raw or "*")
CORS_IS_DEFAULT_WILDCARD = _cors_origins_raw is None
# Keyed on the parsed list, not on whether the env var was set: an operator
# who explicitly sets CORS_ORIGINS=* must get the same wildcard treatment as
# the default, or credentials would combine with a wildcard origin - the
# exact reflect-any-Origin behavior this flag exists to prevent.
CORS_ALLOW_CREDENTIALS = "*" not in CORS_ALLOWED_ORIGINS
# Parsed once at module load; OPEN_NOTEBOOK_MAX_UPLOAD_SIZE_MB changes require a restart.
MAX_UPLOAD_SIZE_BYTES = get_max_upload_size_bytes()
DATABASE_STARTUP_RETRY_ATTEMPTS = 12
DATABASE_STARTUP_RETRY_INITIAL_DELAY_SECONDS = 1
DATABASE_STARTUP_RETRY_MAX_DELAY_SECONDS = 5
# Per-probe ceiling so a hung connection cannot exceed the retry budget or
# block startup indefinitely. A probe that exceeds this is treated as a
# transient failure and retried like any other unreachable-database attempt.
DATABASE_STARTUP_RETRY_PROBE_TIMEOUT_SECONDS = 5
def _cors_headers(request: Request) -> dict[str, str]:
"""
Build CORS headers for error responses.
Mirrors Starlette CORSMiddleware behavior: reflects the request Origin
when the origin is allowed (or when wildcard is configured, since
browsers reject `Access-Control-Allow-Origin: *` combined with
credentials). Omits `Access-Control-Allow-Origin` for disallowed
origins so the browser blocks the error body from leaking cross-origin.
Only claims Access-Control-Allow-Credentials when the real CORSMiddleware
would (see its allow_credentials comment above) - otherwise error
responses would grant credentialed access the success path doesn't.
"""
origin = request.headers.get("origin")
headers: dict[str, str] = {
"Access-Control-Allow-Methods": "*",
"Access-Control-Allow-Headers": "*",
}
if CORS_ALLOW_CREDENTIALS:
headers["Access-Control-Allow-Credentials"] = "true"
if origin and ("*" in CORS_ALLOWED_ORIGINS or origin in CORS_ALLOWED_ORIGINS):
headers["Access-Control-Allow-Origin"] = origin
headers["Vary"] = "Origin"
return headers
# 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}")
async def _wait_for_database(migration_manager: AsyncMigrationManager) -> None:
"""
Wait for SurrealDB to accept connections before running migrations.
Docker Compose can start the API before the database name is resolvable. Keep
migration errors fail-fast by only retrying this lightweight readiness probe.
"""
attempts = max(1, DATABASE_STARTUP_RETRY_ATTEMPTS)
delay = DATABASE_STARTUP_RETRY_INITIAL_DELAY_SECONDS
for attempt in range(1, attempts + 1):
try:
await asyncio.wait_for(
migration_manager.ping(),
timeout=DATABASE_STARTUP_RETRY_PROBE_TIMEOUT_SECONDS,
)
if attempt > 1:
logger.info(f"Database became reachable on attempt {attempt}")
return
except Exception as e:
if attempt == attempts:
logger.error(
f"Database did not become reachable after {attempts} attempts"
)
raise
logger.warning(
"Database is not reachable yet "
f"(attempt {attempt}/{attempts}): {str(e)}. "
f"Retrying in {delay:g} seconds..."
)
await asyncio.sleep(delay)
delay = min(delay * 2, DATABASE_STARTUP_RETRY_MAX_DELAY_SECONDS)
async def _run_database_migrations() -> None:
"""Run startup database migrations after SurrealDB is reachable."""
migration_manager = AsyncMigrationManager()
await _wait_for_database(migration_manager)
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.")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Lifespan event handler for the FastAPI application.
Runs database migrations automatically on startup.
"""
# 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:
await _run_database_migrations()
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
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,
)
if CORS_IS_DEFAULT_WILDCARD:
logger.warning(
"CORS_ORIGINS is not set — API accepts cross-origin requests from any "
"origin (default: '*'). For production deployments, set CORS_ORIGINS to "
"your frontend origin(s), e.g. "
"CORS_ORIGINS=https://notebook.example.com"
)
else:
logger.info(f"CORS allowed origins: {CORS_ALLOWED_ORIGINS}")
# 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",
],
)
# Reject oversized request bodies before they reach auth or routing - added
# after PasswordAuthMiddleware (so it wraps around it) so a too-large request
# is rejected before spending any work checking credentials.
logger.info(
f"Max request body size: {MAX_UPLOAD_SIZE_BYTES / (1024 * 1024):g}MB "
"(set OPEN_NOTEBOOK_MAX_UPLOAD_SIZE_MB to change)"
)
app.add_middleware(MaxBodySizeMiddleware, max_body_size=MAX_UPLOAD_SIZE_BYTES)
# Add CORS middleware last (so it processes first, and so it can attach
# CORS headers to a 413 raised by MaxBodySizeMiddleware)
#
# allow_credentials is tied to whether CORS_ORIGINS resolves to specific
# origins: combining allow_origins=["*"] with allow_credentials=True makes
# Starlette reflect the request's Origin header verbatim (browsers reject a
# literal "*" alongside credentials), which defeats the origin allowlist.
# The frontend never sends credentialed requests (withCredentials: false)
# and auth is a Bearer header, not a cookie, so this isn't independently
# exploitable today - but there's no reason to allow it for any wildcard
# case. Once an operator explicitly scopes CORS_ORIGINS to real origins,
# credentialed cross-origin requests to those origins are safe to allow.
app.add_middleware(
CORSMiddleware,
allow_origins=CORS_ALLOWED_ORIGINS,
allow_credentials=CORS_ALLOW_CREDENTIALS,
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.
"""
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
headers={**(exc.headers or {}), **_cors_headers(request)},
)
@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(UnsupportedTypeException)
async def unsupported_type_error_handler(
request: Request, exc: UnsupportedTypeException
):
return JSONResponse(
status_code=415,
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(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(providers.router, prefix="/api", tags=["providers"])
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"}
+122
View File
@@ -0,0 +1,122 @@
import os
from loguru import logger
from starlette.datastructures import Headers
from starlette.types import ASGIApp, Message, Receive, Scope, Send
# Matches the file-size guidance already documented in
# docs/3-USER-GUIDE/adding-sources.md ("Very large files (>100MB) - Timeout").
DEFAULT_MAX_UPLOAD_SIZE_MB = 100
def get_max_upload_size_bytes() -> int:
"""Read the configured max request body size, in bytes.
Configurable via OPEN_NOTEBOOK_MAX_UPLOAD_SIZE_MB for deployments that
need larger audio/video uploads; falls back to the default on unset,
malformed, or non-positive values (a zero/negative limit would reject
every request that has a body).
"""
raw = os.environ.get("OPEN_NOTEBOOK_MAX_UPLOAD_SIZE_MB", "").strip()
try:
mb = float(raw) if raw else DEFAULT_MAX_UPLOAD_SIZE_MB
except ValueError:
mb = DEFAULT_MAX_UPLOAD_SIZE_MB
if mb <= 0:
logger.warning(
f"OPEN_NOTEBOOK_MAX_UPLOAD_SIZE_MB={raw!r} is not a positive size; "
f"using the default of {DEFAULT_MAX_UPLOAD_SIZE_MB}MB"
)
mb = DEFAULT_MAX_UPLOAD_SIZE_MB
return int(mb * 1024 * 1024)
class _RequestBodyTooLarge(Exception):
pass
class MaxBodySizeMiddleware:
"""
Raw ASGI middleware rejecting requests whose body exceeds a configured
maximum, so a large upload can't exhaust memory/disk on a deployment
with no fronting proxy enforcing its own limit (e.g. the shipped
docker-compose.yml, which exposes the API directly).
Implemented at the raw ASGI level (not BaseHTTPMiddleware) so the check
can run ahead of FastAPI's own body/form parsing instead of after it.
Rejects on the `Content-Length` header up front when present (the common
case, and cheap), and also counts bytes as the body streams in - a
client can lie about Content-Length or omit it entirely with chunked
transfer-encoding.
"""
def __init__(self, app: ASGIApp, max_body_size: int) -> None:
self.app = app
self.max_body_size = max_body_size
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
headers = Headers(scope=scope)
content_length = headers.get("content-length")
if content_length is not None:
try:
if int(content_length) > self.max_body_size:
logger.warning(
f"Rejected {scope.get('method', '?')} {scope.get('path', '?')}: "
f"declared body of {content_length} bytes exceeds the "
f"{self.max_body_size}-byte limit"
)
await _send_413(send)
return
except ValueError:
pass # malformed header - fall through to streaming enforcement
total_size = 0
response_started = False
async def send_wrapper(message: Message) -> None:
nonlocal response_started
if message["type"] == "http.response.start":
response_started = True
await send(message)
async def receive_wrapper() -> Message:
nonlocal total_size
message = await receive()
if message["type"] == "http.request":
total_size += len(message.get("body") or b"")
if total_size > self.max_body_size:
raise _RequestBodyTooLarge()
return message
try:
await self.app(scope, receive_wrapper, send_wrapper)
except _RequestBodyTooLarge:
logger.warning(
f"Rejected {scope.get('method', '?')} {scope.get('path', '?')}: "
f"streamed body exceeded the {self.max_body_size}-byte limit"
)
if not response_started:
await _send_413(send)
# Else the app already started responding - nothing safe to send;
# let the connection drop rather than violate the ASGI protocol
# with a second response.start.
async def _send_413(send: Send) -> None:
await send(
{
"type": "http.response.start",
"status": 413,
"headers": [(b"content-type", b"application/json")],
}
)
await send(
{
"type": "http.response.body",
"body": b'{"detail":"Request body exceeds the maximum allowed upload size"}',
}
)
+734
View File
@@ -0,0 +1,734 @@
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
# Notebook models
class NotebookCreate(BaseModel):
name: str = Field(..., description="Name of the notebook")
description: str = Field(default="", description="Description of the notebook")
class NotebookUpdate(BaseModel):
name: Optional[str] = Field(None, description="Name of the notebook")
description: Optional[str] = Field(None, description="Description of the notebook")
archived: Optional[bool] = Field(
None, description="Whether the notebook is archived"
)
class NotebookResponse(BaseModel):
id: str
name: str
description: str
archived: bool
created: str
updated: str
source_count: int
note_count: int
class RecentlyViewedResponse(BaseModel):
type: Literal["notebook", "source"]
id: str
title: str
last_viewed_at: str
# Search models
class SearchRequest(BaseModel):
query: str = Field(..., description="Search query")
type: Literal["text", "vector"] = Field("text", description="Search type")
limit: int = Field(100, description="Maximum number of results", ge=1, le=1000)
search_sources: bool = Field(True, description="Include sources in search")
search_notes: bool = Field(True, description="Include notes in search")
minimum_score: float = Field(
0.2, description="Minimum score for vector search", ge=0, le=1
)
class SearchResponse(BaseModel):
results: List[Dict[str, Any]] = Field(..., description="Search results")
total_count: int = Field(..., description="Total number of results")
search_type: str = Field(..., description="Type of search performed")
class AskRequest(BaseModel):
question: str = Field(..., description="Question to ask the knowledge base")
strategy_model: str = Field(..., description="Model ID for query strategy")
answer_model: str = Field(..., description="Model ID for individual answers")
final_answer_model: str = Field(..., description="Model ID for final answer")
class AskResponse(BaseModel):
answer: str = Field(..., description="Final answer from the knowledge base")
question: str = Field(..., description="Original question")
# Models API models
class ModelCreate(BaseModel):
name: str = Field(..., description="Model name (e.g., gpt-5-mini, claude, gemini)")
provider: str = Field(
..., description="Provider name (e.g., openai, anthropic, gemini)"
)
type: str = Field(
...,
description="Model type (language, embedding, text_to_speech, speech_to_text)",
)
credential: Optional[str] = Field(
None, description="Credential ID to link this model to"
)
class ModelResponse(BaseModel):
id: str
name: str
provider: str
type: str
credential: Optional[str] = None
created: str
updated: str
class DefaultModelsResponse(BaseModel):
default_chat_model: Optional[str] = None
default_transformation_model: Optional[str] = None
large_context_model: Optional[str] = None
default_text_to_speech_model: Optional[str] = None
default_speech_to_text_model: Optional[str] = None
default_embedding_model: Optional[str] = None
default_tools_model: Optional[str] = None
class ProviderAvailabilityResponse(BaseModel):
available: List[str] = Field(..., description="List of available providers")
unavailable: List[str] = Field(..., description="List of unavailable providers")
supported_types: Dict[str, List[str]] = Field(
..., description="Provider to supported model types mapping"
)
# Transformations API models
class TransformationCreate(BaseModel):
name: str = Field(..., description="Transformation name")
title: str = Field(..., description="Display title for the transformation")
description: str = Field(
..., description="Description of what this transformation does"
)
prompt: str = Field(..., description="The transformation prompt")
apply_default: bool = Field(
False, description="Whether to apply this transformation by default"
)
model_id: Optional[str] = Field(
None, description="Model ID to use by default for this transformation"
)
class TransformationUpdate(BaseModel):
name: Optional[str] = Field(None, description="Transformation name")
title: Optional[str] = Field(
None, description="Display title for the transformation"
)
description: Optional[str] = Field(
None, description="Description of what this transformation does"
)
prompt: Optional[str] = Field(None, description="The transformation prompt")
apply_default: Optional[bool] = Field(
None, description="Whether to apply this transformation by default"
)
model_id: Optional[str] = Field(
None, description="Model ID to use by default for this transformation"
)
class TransformationResponse(BaseModel):
id: str
name: str
title: str
description: str
prompt: str
apply_default: bool
model_id: Optional[str] = None
created: str
updated: str
class TransformationExecuteRequest(BaseModel):
model_config = ConfigDict(protected_namespaces=())
transformation_id: str = Field(
..., description="ID of the transformation to execute"
)
input_text: str = Field(..., description="Text to transform")
model_id: Optional[str] = Field(
None, description="Model ID to use for this transformation run"
)
class TransformationExecuteResponse(BaseModel):
model_config = ConfigDict(protected_namespaces=())
output: str = Field(..., description="Transformed text")
transformation_id: str = Field(..., description="ID of the transformation used")
model_id: Optional[str] = Field(None, description="Model ID used")
# Default Prompt API models
class DefaultPromptResponse(BaseModel):
transformation_instructions: str = Field(
..., description="Default transformation instructions"
)
class DefaultPromptUpdate(BaseModel):
transformation_instructions: str = Field(
..., description="Default transformation instructions"
)
# Notes API models
class NoteCreate(BaseModel):
title: Optional[str] = Field(None, description="Note title")
content: str = Field(..., description="Note content")
note_type: Optional[str] = Field("human", description="Type of note (human, ai)")
notebook_id: Optional[str] = Field(
None, description="Notebook ID to add the note to"
)
class NoteUpdate(BaseModel):
title: Optional[str] = Field(None, description="Note title")
content: Optional[str] = Field(None, description="Note content")
note_type: Optional[str] = Field(None, description="Type of note (human, ai)")
class NoteResponse(BaseModel):
id: str
title: Optional[str]
content: Optional[str]
note_type: Optional[str]
created: str
updated: str
command_id: Optional[str] = None
# Embedding API models
class EmbedRequest(BaseModel):
item_id: str = Field(..., description="ID of the item to embed")
item_type: str = Field(..., description="Type of item (source, note)")
async_processing: bool = Field(
False, description="Process asynchronously in background"
)
class EmbedResponse(BaseModel):
success: bool = Field(..., description="Whether embedding was successful")
message: str = Field(..., description="Result message")
item_id: str = Field(..., description="ID of the item that was embedded")
item_type: str = Field(..., description="Type of item that was embedded")
command_id: Optional[str] = Field(
None, description="Command ID for async processing"
)
# Rebuild request/response models
class RebuildRequest(BaseModel):
mode: Literal["existing", "all"] = Field(
...,
description="Rebuild mode: 'existing' only re-embeds items with embeddings, 'all' embeds everything",
)
include_sources: bool = Field(True, description="Include sources in rebuild")
include_notes: bool = Field(True, description="Include notes in rebuild")
include_insights: bool = Field(True, description="Include insights in rebuild")
class RebuildResponse(BaseModel):
command_id: str = Field(..., description="Command ID to track progress")
total_items: int = Field(..., description="Estimated number of items to process")
message: str = Field(..., description="Status message")
class RebuildProgress(BaseModel):
processed: int = Field(..., description="Number of items processed")
total: int = Field(..., description="Total items to process")
percentage: float = Field(..., description="Progress percentage")
class RebuildStats(BaseModel):
sources: int = Field(0, description="Sources processed")
notes: int = Field(0, description="Notes processed")
insights: int = Field(0, description="Insights processed")
failed: int = Field(0, description="Failed items")
class RebuildStatusResponse(BaseModel):
command_id: str = Field(..., description="Command ID")
status: str = Field(..., description="Status: queued, running, completed, failed")
progress: Optional[RebuildProgress] = None
stats: Optional[RebuildStats] = None
started_at: Optional[str] = None
completed_at: Optional[str] = None
error_message: Optional[str] = None
# Settings API models
class SettingsResponse(BaseModel):
default_content_processing_engine_doc: Optional[str] = None
default_content_processing_engine_url: Optional[str] = None
default_embedding_option: Optional[str] = None
auto_delete_files: Optional[str] = None
docling_ocr: Optional[bool] = None
youtube_preferred_languages: Optional[List[str]] = None
class SettingsUpdate(BaseModel):
default_content_processing_engine_doc: Optional[str] = None
default_content_processing_engine_url: Optional[str] = None
default_embedding_option: Optional[str] = None
auto_delete_files: Optional[str] = None
docling_ocr: Optional[bool] = None
youtube_preferred_languages: Optional[List[str]] = None
# Sources API models
class AssetModel(BaseModel):
file_path: Optional[str] = None
url: Optional[str] = None
class SourceCreate(BaseModel):
# Backward compatibility: support old single notebook_id
notebook_id: Optional[str] = Field(
None, description="Notebook ID to add the source to (deprecated, use notebooks)"
)
# New multi-notebook support
notebooks: Optional[List[str]] = Field(
None,
max_length=50,
description="List of notebook IDs to add the source to (max 50)",
)
# Required fields
type: str = Field(..., description="Source type: link, upload, or text")
url: Optional[str] = Field(None, description="URL for link type")
file_path: Optional[str] = Field(None, description="File path for upload type")
content: Optional[str] = Field(None, description="Text content for text type")
title: Optional[str] = Field(None, description="Source title")
transformations: Optional[List[str]] = Field(
default_factory=list,
max_length=50,
description="Transformation IDs to apply (max 50)",
)
embed: bool = Field(False, description="Whether to embed content for vector search")
delete_source: bool = Field(
False, description="Whether to delete uploaded file after processing"
)
# New async processing support
async_processing: bool = Field(
False, description="Whether to process source asynchronously"
)
@model_validator(mode="after")
def validate_notebook_fields(self):
# Ensure only one of notebook_id or notebooks is provided
if self.notebook_id is not None and self.notebooks is not None:
raise ValueError(
"Cannot specify both 'notebook_id' and 'notebooks'. Use 'notebooks' for multi-notebook support."
)
# Convert single notebook_id to notebooks array for internal processing
if self.notebook_id is not None:
self.notebooks = [self.notebook_id]
# Keep notebook_id for backward compatibility in response
# Set empty array if no notebooks specified (allow sources without notebooks)
if self.notebooks is None:
self.notebooks = []
return self
class SourceUpdate(BaseModel):
title: Optional[str] = Field(None, description="Source title")
topics: Optional[List[str]] = Field(None, description="Source topics")
class SourceResponse(BaseModel):
id: str
title: Optional[str]
topics: Optional[List[str]]
asset: Optional[AssetModel]
full_text: Optional[str]
embedded: bool
embedded_chunks: int
file_available: Optional[bool] = None
created: str
updated: str
# New fields for async processing
command_id: Optional[str] = None
status: Optional[str] = None
processing_info: Optional[Dict] = None
# Notebook associations
notebooks: Optional[List[str]] = None
class SourceListResponse(BaseModel):
id: str
title: Optional[str]
topics: Optional[List[str]]
asset: Optional[AssetModel]
embedded: bool # Boolean flag indicating if source has embeddings
embedded_chunks: int # Number of embedded chunks
insights_count: int
created: str
updated: str
file_available: Optional[bool] = None
# Status fields for async processing
command_id: Optional[str] = None
status: Optional[str] = None
processing_info: Optional[Dict[str, Any]] = None
# Insights API models
class SourceInsightResponse(BaseModel):
id: str
source_id: str
insight_type: str
content: str
# Optional: insights created before migration 19 have no timestamps,
# and the API must return null for them (never the string "None").
created: Optional[str] = None
updated: Optional[str] = None
class InsightCreationResponse(BaseModel):
"""Response for async insight creation."""
status: Literal["pending"] = "pending"
message: str = "Insight generation started"
source_id: str
transformation_id: str
command_id: Optional[str] = None
class SaveAsNoteRequest(BaseModel):
notebook_id: Optional[str] = Field(None, description="Notebook ID to add note to")
class CreateSourceInsightRequest(BaseModel):
model_config = ConfigDict(protected_namespaces=())
transformation_id: str = Field(..., description="ID of transformation to apply")
model_id: Optional[str] = Field(
None, description="Model ID (uses default if not provided)"
)
# Source status response
class SourceStatusResponse(BaseModel):
status: Optional[str] = Field(None, description="Processing status")
message: str = Field(..., description="Descriptive message about the status")
processing_info: Optional[Dict[str, Any]] = Field(
None, description="Detailed processing information"
)
command_id: Optional[str] = Field(None, description="Command ID if available")
# Error response
class ErrorResponse(BaseModel):
error: str
message: str
# API Key Configuration models
class SetApiKeyRequest(BaseModel):
"""Request to set an API key for a provider."""
api_key: Optional[str] = Field(None, description="API key for the provider")
base_url: Optional[str] = Field(
None, description="Base URL for URL-based providers (Ollama, OpenAI-compatible)"
)
endpoint: Optional[str] = Field(None, description="Endpoint URL for Azure OpenAI")
api_version: Optional[str] = Field(None, description="API version for Azure OpenAI")
endpoint_llm: Optional[str] = Field(
None, description="Service-specific endpoint for LLM (Azure)"
)
endpoint_embedding: Optional[str] = Field(
None, description="Service-specific endpoint for embedding (Azure)"
)
endpoint_stt: Optional[str] = Field(
None, description="Service-specific endpoint for STT (Azure)"
)
endpoint_tts: Optional[str] = Field(
None, description="Service-specific endpoint for TTS (Azure)"
)
service_type: Optional[Literal["llm", "embedding", "stt", "tts"]] = Field(
None,
description="Service type for OpenAI-compatible providers (llm, embedding, stt, tts)",
)
# Vertex AI specific fields
vertex_project: Optional[str] = Field(
None, description="Google Cloud Project ID for Vertex AI"
)
vertex_location: Optional[str] = Field(
None, description="Google Cloud Region for Vertex AI (e.g., us-central1)"
)
vertex_credentials_path: Optional[str] = Field(
None, description="Path to Google Cloud service account JSON file"
)
@field_validator(
"api_key",
"base_url",
"endpoint",
"api_version",
"endpoint_llm",
"endpoint_embedding",
"endpoint_stt",
"endpoint_tts",
"vertex_project",
"vertex_location",
"vertex_credentials_path",
mode="before",
)
@classmethod
def validate_not_empty_string(cls, v: Optional[str]) -> Optional[str]:
"""Reject empty strings - convert to None or raise error."""
if v is not None:
stripped = v.strip()
if not stripped:
return None # Treat empty/whitespace-only as None
return stripped
return v
class ApiKeyStatusResponse(BaseModel):
"""Response showing which providers are configured and their source."""
configured: Dict[str, bool] = Field(
..., description="Map of provider name to whether it is configured"
)
source: Dict[str, Literal["database", "environment", "none"]] = Field(
...,
description="Map of provider name to configuration source (database, environment, or none)",
)
encryption_configured: bool = Field(
...,
description="Whether OPEN_NOTEBOOK_ENCRYPTION_KEY is set (required to store keys in database)",
)
class TestConnectionResponse(BaseModel):
"""Response from testing a provider connection."""
provider: str = Field(..., description="Provider name that was tested")
success: bool = Field(..., description="Whether connection test succeeded")
message: str = Field(..., description="Result message with details")
class MigrateFromEnvRequest(BaseModel):
"""Request to migrate API keys from environment variables to database."""
force: bool = Field(
False, description="Force overwrite existing database configurations"
)
class MigrationResult(BaseModel):
"""Response from migrating API keys from environment to database."""
message: str = Field(..., description="Summary message")
migrated: List[str] = Field(
default_factory=list, description="Providers successfully migrated"
)
skipped: List[str] = Field(
default_factory=list, description="Providers skipped (already in DB)"
)
errors: List[str] = Field(
default_factory=list, description="Migration errors by provider"
)
# Notebook delete cascade models
# Credential models
# Kept in sync with the provider registry
# (open_notebook/ai/provider_registry.py PROVIDERS — the backend source of
# truth). A Literal can't be built at runtime, so this is the one remaining
# manual copy; tests/test_credential_provider_validation.py enforces the sync.
# The frontend consumes GET /api/providers at runtime and needs no edit.
SupportedProvider = Literal[
"openai",
"anthropic",
"google",
"groq",
"mistral",
"deepseek",
"xai",
"openrouter",
"dashscope",
"minimax",
"voyage",
"elevenlabs",
"deepgram",
"ollama",
"azure",
"vertex",
"openai_compatible",
]
class ProviderInfoResponse(BaseModel):
"""Provider metadata from the provider registry."""
name: str = Field(..., description="Provider identifier (e.g. openai)")
display_name: str = Field(..., description="Human-friendly provider name")
modalities: List[str] = Field(
..., description="Default modalities supported by the provider"
)
docs_url: Optional[str] = Field(
None, description="Where to get an API key / set the provider up"
)
env_configured: bool = Field(
..., description="Whether the provider is configured via environment variables"
)
class CreateCredentialRequest(BaseModel):
"""Request to create a new credential."""
name: str = Field(..., description="Credential name")
provider: SupportedProvider = Field(
..., description="Provider name (openai, anthropic, etc.)"
)
modalities: List[str] = Field(
default_factory=list,
description="Supported modalities (language, embedding, text_to_speech, speech_to_text)",
)
api_key: Optional[str] = Field(None, description="API key (stored encrypted)")
base_url: Optional[str] = Field(None, description="Base URL")
endpoint: Optional[str] = Field(None, description="Endpoint URL (Azure)")
api_version: Optional[str] = Field(None, description="API version (Azure)")
endpoint_llm: Optional[str] = Field(None, description="LLM endpoint")
endpoint_embedding: Optional[str] = Field(None, description="Embedding endpoint")
endpoint_stt: Optional[str] = Field(None, description="STT endpoint")
endpoint_tts: Optional[str] = Field(None, description="TTS endpoint")
project: Optional[str] = Field(None, description="Project ID (Vertex)")
location: Optional[str] = Field(None, description="Location (Vertex)")
credentials_path: Optional[str] = Field(
None, description="Credentials file path (Vertex)"
)
num_ctx: Optional[int] = Field(
None, description="Context window size (Ollama only; defaults to 8192)"
)
class UpdateCredentialRequest(BaseModel):
"""Request to update an existing credential."""
name: Optional[str] = Field(None, description="Credential name")
modalities: Optional[List[str]] = Field(None, description="Supported modalities")
api_key: Optional[str] = Field(None, description="API key (stored encrypted)")
base_url: Optional[str] = Field(None, description="Base URL")
endpoint: Optional[str] = Field(None, description="Endpoint URL")
api_version: Optional[str] = Field(None, description="API version")
endpoint_llm: Optional[str] = Field(None, description="LLM endpoint")
endpoint_embedding: Optional[str] = Field(None, description="Embedding endpoint")
endpoint_stt: Optional[str] = Field(None, description="STT endpoint")
endpoint_tts: Optional[str] = Field(None, description="TTS endpoint")
project: Optional[str] = Field(None, description="Project ID")
location: Optional[str] = Field(None, description="Location")
credentials_path: Optional[str] = Field(None, description="Credentials path")
num_ctx: Optional[int] = Field(
None, description="Context window size (Ollama only; defaults to 8192)"
)
class CredentialResponse(BaseModel):
"""Response for a credential (never includes api_key)."""
id: str
name: str
provider: str
modalities: List[str]
base_url: Optional[str] = None
endpoint: Optional[str] = None
api_version: Optional[str] = None
endpoint_llm: Optional[str] = None
endpoint_embedding: Optional[str] = None
endpoint_stt: Optional[str] = None
endpoint_tts: Optional[str] = None
project: Optional[str] = None
location: Optional[str] = None
credentials_path: Optional[str] = None
num_ctx: Optional[int] = None
has_api_key: bool = False
created: str
updated: str
model_count: int = 0
decryption_error: Optional[str] = None
class CredentialDeleteResponse(BaseModel):
"""Response for credential deletion."""
message: str
deleted_models: int = 0
class DiscoveredModelResponse(BaseModel):
"""A model discovered from a provider."""
name: str
provider: str
model_type: Optional[str] = None
description: Optional[str] = None
class DiscoverModelsResponse(BaseModel):
"""Response from model discovery."""
credential_id: str
provider: str
discovered: List[DiscoveredModelResponse]
class RegisterModelData(BaseModel):
"""A model to register with user-specified type."""
name: str
provider: str
model_type: str # Required: user specifies the type
class RegisterModelsRequest(BaseModel):
"""Request to register discovered models."""
models: List[RegisterModelData]
class RegisterModelsResponse(BaseModel):
"""Response from model registration."""
created: int
existing: int
class NotebookDeletePreview(BaseModel):
notebook_id: str = Field(..., description="ID of the notebook")
notebook_name: str = Field(..., description="Name of the notebook")
note_count: int = Field(..., description="Number of notes that will be deleted")
exclusive_source_count: int = Field(
..., description="Number of sources only in this notebook"
)
shared_source_count: int = Field(
..., description="Number of sources shared with other notebooks"
)
class NotebookDeleteResponse(BaseModel):
message: str = Field(..., description="Success message")
deleted_notes: int = Field(..., description="Number of notes deleted")
deleted_sources: int = Field(..., description="Number of exclusive sources deleted")
unlinked_sources: int = Field(
..., description="Number of sources unlinked from notebook"
)
+203
View File
@@ -0,0 +1,203 @@
from typing import Any, Dict, Optional
from fastapi import HTTPException
from loguru import logger
from pydantic import BaseModel
from surreal_commands import get_command_status, submit_command
from open_notebook.domain.notebook import Notebook
from open_notebook.podcasts.models import EpisodeProfile, PodcastEpisode, SpeakerProfile
class PodcastGenerationRequest(BaseModel):
"""Request model for podcast generation"""
episode_profile: str
speaker_profile: str
episode_name: str
content: Optional[str] = None
notebook_id: Optional[str] = None
briefing_suffix: Optional[str] = None
class PodcastGenerationResponse(BaseModel):
"""Response model for podcast generation"""
job_id: str
status: str
message: str
episode_profile: str
episode_name: str
class PodcastService:
"""Service layer for podcast operations"""
@staticmethod
async def submit_generation_job(
episode_profile_name: str,
speaker_profile_name: str,
episode_name: str,
notebook_id: Optional[str] = None,
content: Optional[str] = None,
briefing_suffix: Optional[str] = None,
) -> str:
"""Submit a podcast generation job for background processing"""
try:
# Validate episode profile exists
episode_profile = await EpisodeProfile.get_by_name(episode_profile_name)
if not episode_profile:
raise ValueError(f"Episode profile '{episode_profile_name}' not found")
# Resolve the user-facing speaker profile name to a record ID at
# the API boundary (#630) - everything downstream works with IDs.
speaker_profile = await SpeakerProfile.resolve(speaker_profile_name)
if not speaker_profile:
raise ValueError(f"Speaker profile '{speaker_profile_name}' not found")
# Get content from notebook if not provided directly
if not content and notebook_id:
try:
notebook = await Notebook.get(notebook_id)
# Get notebook context (this may need to be adjusted based on actual Notebook implementation)
content = (
await notebook.get_context()
if hasattr(notebook, "get_context")
else str(notebook)
)
except Exception as e:
logger.warning(
f"Failed to get notebook content, using notebook_id as content: {e}"
)
content = f"Notebook ID: {notebook_id}"
if not content:
raise ValueError(
"Content is required - provide either content or notebook_id"
)
# Prepare command arguments (speaker profile as record ID)
command_args = {
"episode_profile": episode_profile_name,
"speaker_profile": str(speaker_profile.id),
"episode_name": episode_name,
"content": str(content),
"briefing_suffix": briefing_suffix,
}
# Ensure command modules are imported before submitting
# This is needed because submit_command validates against local registry
try:
import commands.podcast_commands # noqa: F401
except ImportError as import_err:
logger.error(f"Failed to import podcast commands: {import_err}")
raise ValueError("Podcast commands not available")
# Submit command to surreal-commands
job_id = submit_command("open_notebook", "generate_podcast", command_args)
# Convert RecordID to string if needed
if not job_id:
raise ValueError("Failed to get job_id from submit_command")
job_id_str = str(job_id)
logger.info(
f"Submitted podcast generation job: {job_id_str} for episode '{episode_name}'"
)
return job_id_str
except Exception as e:
logger.error(f"Failed to submit podcast generation job: {e}")
raise HTTPException(
status_code=500,
detail="Failed to submit podcast generation job",
)
@staticmethod
async def get_job_status(job_id: str) -> Dict[str, Any]:
"""Get status of a podcast generation job"""
try:
status = await get_command_status(job_id)
return {
"job_id": job_id,
"status": status.status if status else "unknown",
"result": status.result if status else None,
"error_message": getattr(status, "error_message", None)
if status
else None,
"created": str(status.created)
if status and hasattr(status, "created") and status.created
else None,
"updated": str(status.updated)
if status and hasattr(status, "updated") and status.updated
else None,
"progress": getattr(status, "progress", None) if status else None,
}
except Exception as e:
logger.error(f"Failed to get podcast job status: {e}")
raise HTTPException(status_code=500, detail="Failed to get job status")
@staticmethod
async def list_episodes() -> list:
"""List all podcast episodes"""
try:
episodes = await PodcastEpisode.get_all(order_by="created desc")
return episodes
except Exception as e:
logger.error(f"Failed to list podcast episodes: {e}")
raise HTTPException(status_code=500, detail="Failed to list episodes")
@staticmethod
async def get_episode(episode_id: str) -> PodcastEpisode:
"""Get a specific podcast episode"""
try:
episode = await PodcastEpisode.get(episode_id)
return episode
except Exception as e:
logger.error(f"Failed to get podcast episode {episode_id}: {e}")
raise HTTPException(status_code=404, detail="Episode not found")
class DefaultProfiles:
"""Utility class for creating default profiles (if needed beyond migration data)"""
@staticmethod
async def create_default_episode_profiles():
"""Create default episode profiles if they don't exist"""
try:
# Check if profiles already exist
existing = await EpisodeProfile.get_all()
if existing:
logger.info(f"Episode profiles already exist: {len(existing)} found")
return existing
# This would create profiles, but since we have migration data,
# this is mainly for future extensibility
logger.info(
"Default episode profiles should be created via database migration"
)
return []
except Exception as e:
logger.error(f"Failed to create default episode profiles: {e}")
raise
@staticmethod
async def create_default_speaker_profiles():
"""Create default speaker profiles if they don't exist"""
try:
# Check if profiles already exist
existing = await SpeakerProfile.get_all()
if existing:
logger.info(f"Speaker profiles already exist: {len(existing)} found")
return existing
# This would create profiles, but since we have migration data,
# this is mainly for future extensibility
logger.info(
"Default speaker profiles should be created via database migration"
)
return []
except Exception as e:
logger.error(f"Failed to create default speaker profiles: {e}")
raise
View File
+94
View File
@@ -0,0 +1,94 @@
"""Shared helpers for the chat and source-chat routers.
Both `api/routers/chat.py` and `api/routers/source_chat.py` operate on
`chat_session` records linked to their parent (notebook or source) via the
`refers_to` relation, and both convert LangGraph state messages into API
response models. This module holds the single definition of those pieces.
Behavior notes:
- The helpers raise exactly what the previously inlined blocks raised
(`NotFoundError` propagates from `ObjectModel.get`, `HTTPException(404)` for
a missing relation), so each router's existing try/except arms keep mapping
them to the same status codes and messages as before.
"""
from typing import Any, Iterable, List, Optional, Tuple
from fastapi import HTTPException
from pydantic import BaseModel, Field
from open_notebook.database.repository import ensure_record_id, repo_query
from open_notebook.domain.notebook import ChatSession, Source
# Shared response models
class ChatMessage(BaseModel):
id: str = Field(..., description="Message ID")
type: str = Field(..., description="Message type (human|ai)")
content: str = Field(..., description="Message content")
timestamp: Optional[str] = Field(None, description="Message timestamp")
class SuccessResponse(BaseModel):
success: bool = Field(True, description="Operation success status")
message: str = Field(..., description="Success message")
def normalize_record_id(table: str, record_id: str) -> str:
"""Ensure a record ID carries its table prefix (`table:id`)."""
prefix = f"{table}:"
return record_id if record_id.startswith(prefix) else f"{prefix}{record_id}"
async def get_source_or_404(source_id: str) -> Tuple[str, Source]:
"""Normalize a source ID and fetch the source, 404 if missing."""
full_source_id = normalize_record_id("source", source_id)
source = await Source.get(full_source_id)
if not source:
raise HTTPException(status_code=404, detail="Source not found")
return full_source_id, source
async def get_session_or_404(session_id: str) -> Tuple[str, ChatSession]:
"""Normalize a session ID and fetch the chat session, 404 if missing."""
full_session_id = normalize_record_id("chat_session", session_id)
session = await ChatSession.get(full_session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return full_session_id, session
async def get_verified_source_session(
source_id: str, session_id: str
) -> Tuple[str, Source, str, ChatSession]:
"""Verify the source exists, the session exists, and the session refers to
the source. Returns the normalized IDs plus both records."""
full_source_id, source = await get_source_or_404(source_id)
full_session_id, session = await get_session_or_404(session_id)
relation_query = await repo_query(
"SELECT * FROM refers_to WHERE in = $session_id AND out = $source_id",
{
"session_id": ensure_record_id(full_session_id),
"source_id": ensure_record_id(full_source_id),
},
)
if not relation_query:
raise HTTPException(status_code=404, detail="Session not found for this source")
return full_source_id, source, full_session_id, session
def extract_chat_messages(raw_messages: Iterable[Any]) -> List[ChatMessage]:
"""Convert LangGraph/LangChain state messages into `ChatMessage` models."""
messages: List[ChatMessage] = []
for msg in raw_messages:
messages.append(
ChatMessage(
id=getattr(msg, "id", f"msg_{len(messages)}"),
type=msg.type if hasattr(msg, "type") else "unknown",
content=msg.content if hasattr(msg, "content") else str(msg),
timestamp=None, # LangChain messages don't have timestamps by default
)
)
return messages
+27
View File
@@ -0,0 +1,27 @@
"""
Authentication router for Open Notebook API.
Provides endpoints to check authentication status.
"""
from fastapi import APIRouter
from open_notebook.utils.encryption import get_secret_from_env
router = APIRouter(prefix="/auth", tags=["auth"])
@router.get("/status")
async def get_auth_status():
"""
Check if authentication is enabled.
Returns whether a password is required to access the API.
Supports Docker secrets via OPEN_NOTEBOOK_PASSWORD_FILE.
"""
auth_enabled = bool(get_secret_from_env("OPEN_NOTEBOOK_PASSWORD"))
return {
"auth_enabled": auth_enabled,
"message": "Authentication is required"
if auth_enabled
else "Authentication is disabled",
}
+416
View File
@@ -0,0 +1,416 @@
import asyncio
import traceback
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query
from langchain_core.runnables import RunnableConfig
from loguru import logger
from pydantic import BaseModel, Field
from api.routers._chat_shared import (
ChatMessage,
SuccessResponse,
extract_chat_messages,
get_session_or_404,
)
from open_notebook.database.repository import ensure_record_id, repo_query
from open_notebook.domain.notebook import ChatSession, Notebook
from open_notebook.exceptions import (
NotFoundError,
OpenNotebookError,
)
from open_notebook.graphs.chat import graph as chat_graph
from open_notebook.utils import token_count
from open_notebook.utils.context_builder import build_notebook_context
from open_notebook.utils.graph_utils import get_session_message_count
router = APIRouter()
# Request/Response models
class CreateSessionRequest(BaseModel):
notebook_id: str = Field(..., description="Notebook ID to create session for")
title: Optional[str] = Field(None, description="Optional session title")
model_override: Optional[str] = Field(
None, description="Optional model override for this session"
)
class UpdateSessionRequest(BaseModel):
title: Optional[str] = Field(None, description="New session title")
model_override: Optional[str] = Field(
None, description="Model override for this session"
)
class ChatSessionResponse(BaseModel):
id: str = Field(..., description="Session ID")
title: str = Field(..., description="Session title")
notebook_id: Optional[str] = Field(None, description="Notebook ID")
created: str = Field(..., description="Creation timestamp")
updated: str = Field(..., description="Last update timestamp")
message_count: Optional[int] = Field(
None, description="Number of messages in session"
)
model_override: Optional[str] = Field(
None, description="Model override for this session"
)
class ChatSessionWithMessagesResponse(ChatSessionResponse):
messages: List[ChatMessage] = Field(
default_factory=list, description="Session messages"
)
class ExecuteChatRequest(BaseModel):
session_id: str = Field(..., description="Chat session ID")
message: str = Field(..., description="User message content")
context: Dict[str, Any] = Field(
..., description="Chat context with sources and notes"
)
model_override: Optional[str] = Field(
None, description="Optional model override for this message"
)
class ExecuteChatResponse(BaseModel):
session_id: str = Field(..., description="Session ID")
messages: List[ChatMessage] = Field(..., description="Updated message list")
class BuildContextRequest(BaseModel):
notebook_id: str = Field(..., description="Notebook ID")
context_config: Dict[str, Any] = Field(..., description="Context configuration")
class BuildContextResponse(BaseModel):
context: Dict[str, Any] = Field(..., description="Built context data")
token_count: int = Field(..., description="Estimated token count")
char_count: int = Field(..., description="Character count")
@router.get("/chat/sessions", response_model=List[ChatSessionResponse])
async def get_sessions(notebook_id: str = Query(..., description="Notebook ID")):
"""Get all chat sessions for a notebook."""
try:
# Get notebook to verify it exists
notebook = await Notebook.get(notebook_id)
if not notebook:
raise HTTPException(status_code=404, detail="Notebook not found")
# Get sessions for this notebook
sessions_list = await notebook.get_chat_sessions()
results = []
for session in sessions_list:
session_id = str(session.id)
# Get message count from LangGraph state
msg_count = await get_session_message_count(chat_graph, session_id)
results.append(
ChatSessionResponse(
id=session.id or "",
title=session.title or "Untitled Session",
notebook_id=notebook_id,
created=str(session.created),
updated=str(session.updated),
message_count=msg_count,
model_override=getattr(session, "model_override", None),
)
)
return results
except NotFoundError:
raise HTTPException(status_code=404, detail="Notebook not found")
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error fetching chat sessions: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error fetching chat sessions: {str(e)}"
)
@router.post("/chat/sessions", response_model=ChatSessionResponse)
async def create_session(request: CreateSessionRequest):
"""Create a new chat session."""
try:
# Verify notebook exists
notebook = await Notebook.get(request.notebook_id)
if not notebook:
raise HTTPException(status_code=404, detail="Notebook not found")
# Create new session
session = ChatSession(
title=request.title
or f"Chat Session {asyncio.get_event_loop().time():.0f}",
model_override=request.model_override,
)
await session.save()
# Relate session to notebook
await session.relate_to_notebook(request.notebook_id)
return ChatSessionResponse(
id=session.id or "",
title=session.title or "",
notebook_id=request.notebook_id,
created=str(session.created),
updated=str(session.updated),
message_count=0,
model_override=session.model_override,
)
except NotFoundError:
raise HTTPException(status_code=404, detail="Notebook not found")
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error creating chat session: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error creating chat session: {str(e)}"
)
@router.get(
"/chat/sessions/{session_id}", response_model=ChatSessionWithMessagesResponse
)
async def get_session(session_id: str):
"""Get a specific session with its messages."""
try:
# Get session (normalizes the ID and 404s if missing)
full_session_id, session = await get_session_or_404(session_id)
# Get session state from LangGraph to retrieve messages
# Use sync get_state() in a thread since SqliteSaver doesn't support async
thread_state = await asyncio.to_thread(
chat_graph.get_state,
config=RunnableConfig(configurable={"thread_id": full_session_id}),
)
# Extract messages from state
messages: list[ChatMessage] = []
if thread_state and thread_state.values and "messages" in thread_state.values:
messages = extract_chat_messages(thread_state.values["messages"])
# Find notebook_id (we need to query the relationship)
notebook_query = await repo_query(
"SELECT out FROM refers_to WHERE in = $session_id",
{"session_id": ensure_record_id(full_session_id)},
)
notebook_id = notebook_query[0]["out"] if notebook_query else None
if not notebook_id:
# This might be an old session created before API migration
logger.warning(
f"No notebook relationship found for session {session_id} - may be an orphaned session"
)
return ChatSessionWithMessagesResponse(
id=session.id or "",
title=session.title or "Untitled Session",
notebook_id=notebook_id,
created=str(session.created),
updated=str(session.updated),
message_count=len(messages),
messages=messages,
model_override=getattr(session, "model_override", None),
)
except NotFoundError:
raise HTTPException(status_code=404, detail="Session not found")
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error fetching session: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error fetching session: {str(e)}")
@router.put("/chat/sessions/{session_id}", response_model=ChatSessionResponse)
async def update_session(session_id: str, request: UpdateSessionRequest):
"""Update session title."""
try:
# Get session (normalizes the ID and 404s if missing)
full_session_id, session = await get_session_or_404(session_id)
update_data = request.model_dump(exclude_unset=True)
if "title" in update_data:
session.title = update_data["title"]
if "model_override" in update_data:
session.model_override = update_data["model_override"]
await session.save()
# Find notebook_id
notebook_query = await repo_query(
"SELECT out FROM refers_to WHERE in = $session_id",
{"session_id": ensure_record_id(full_session_id)},
)
notebook_id = notebook_query[0]["out"] if notebook_query else None
# Get message count from LangGraph state
msg_count = await get_session_message_count(chat_graph, full_session_id)
return ChatSessionResponse(
id=session.id or "",
title=session.title or "",
notebook_id=notebook_id,
created=str(session.created),
updated=str(session.updated),
message_count=msg_count,
model_override=session.model_override,
)
except NotFoundError:
raise HTTPException(status_code=404, detail="Session not found")
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error updating session: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error updating session: {str(e)}")
@router.delete("/chat/sessions/{session_id}", response_model=SuccessResponse)
async def delete_session(session_id: str):
"""Delete a chat session."""
try:
# Get session (normalizes the ID and 404s if missing)
_full_session_id, session = await get_session_or_404(session_id)
await session.delete()
return SuccessResponse(success=True, message="Session deleted successfully")
except NotFoundError:
raise HTTPException(status_code=404, detail="Session not found")
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error deleting session: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error deleting session: {str(e)}")
@router.post("/chat/execute", response_model=ExecuteChatResponse)
async def execute_chat(request: ExecuteChatRequest):
"""Execute a chat request and get AI response."""
try:
# Verify session exists (normalizes the ID and 404s if missing)
full_session_id, session = await get_session_or_404(request.session_id)
# Fetch notebook linked to this session
notebook_query = await repo_query(
"SELECT out FROM refers_to WHERE in = $session_id",
{"session_id": ensure_record_id(full_session_id)},
)
notebook = None
if notebook_query:
notebook = await Notebook.get(notebook_query[0]["out"])
# Determine model override (per-request override takes precedence over session-level)
model_override = (
request.model_override
if request.model_override is not None
else getattr(session, "model_override", None)
)
# Get current state
# Use sync get_state() in a thread since SqliteSaver doesn't support async
current_state = await asyncio.to_thread(
chat_graph.get_state,
config=RunnableConfig(configurable={"thread_id": full_session_id}),
)
# Prepare state for execution
state_values = current_state.values if current_state else {}
state_values["messages"] = state_values.get("messages", [])
state_values["context"] = request.context
state_values["notebook"] = notebook
state_values["model_override"] = model_override
# Add user message to state
from langchain_core.messages import HumanMessage
user_message = HumanMessage(content=request.message)
state_values["messages"].append(user_message)
# Execute chat graph in a thread so the synchronous LangGraph invoke
# (SqliteSaver checkpoints are sync) doesn't block the event loop and
# freeze the rest of the API while the LLM responds. Mirrors the
# get_state() calls above.
# The lambda pins down which `invoke` overload is used; asyncio.to_thread
# can't resolve overloaded callables on its own. The ignore is a langgraph
# typing limitation: it accepts a partial state dict at runtime, but the
# signature requires the full state type.
result = await asyncio.to_thread(
lambda: chat_graph.invoke(
input=state_values, # type: ignore[arg-type]
config=RunnableConfig(
configurable={
"thread_id": full_session_id,
"model_id": model_override,
}
),
)
)
# Update session timestamp
await session.save()
# Convert messages to response format
messages = extract_chat_messages(result.get("messages", []))
return ExecuteChatResponse(session_id=request.session_id, messages=messages)
except NotFoundError:
raise HTTPException(status_code=404, detail="Session not found")
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
# Log detailed error with context for debugging
logger.error(
f"Error executing chat: {str(e)}\n"
f" Session ID: {request.session_id}\n"
f" Model override: {request.model_override}\n"
f" Traceback:\n{traceback.format_exc()}"
)
raise HTTPException(status_code=500, detail=f"Error executing chat: {str(e)}")
@router.post("/chat/context", response_model=BuildContextResponse)
async def build_context(request: BuildContextRequest):
"""Build context for a notebook based on context configuration."""
try:
# Verify notebook exists
notebook = await Notebook.get(request.notebook_id)
if not notebook:
raise HTTPException(status_code=404, detail="Notebook not found")
context_data, total_content = await build_notebook_context(
notebook, request.context_config
)
char_count = len(total_content)
estimated_tokens = token_count(total_content) if total_content else 0
return BuildContextResponse(
context=context_data, token_count=estimated_tokens, char_count=char_count
)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error building context: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error building context: {str(e)}")
+185
View File
@@ -0,0 +1,185 @@
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query
from loguru import logger
from pydantic import BaseModel, Field
from surreal_commands import registry
from api.command_service import CommandService
from open_notebook.exceptions import OpenNotebookError
router = APIRouter()
class CommandExecutionRequest(BaseModel):
command: str = Field(
..., description="Command function name (e.g., 'generate_podcast')"
)
app: str = Field(..., description="Application name (e.g., 'open_notebook')")
input: Dict[str, Any] = Field(..., description="Arguments to pass to the command")
class CommandJobResponse(BaseModel):
job_id: str
status: str
message: str
class CommandJobStatusResponse(BaseModel):
job_id: str
status: str
result: Optional[Dict[str, Any]] = None
error_message: Optional[str] = None
created: Optional[str] = None
updated: Optional[str] = None
progress: Optional[Dict[str, Any]] = None
@router.post("/commands/jobs", response_model=CommandJobResponse)
async def execute_command(request: CommandExecutionRequest):
"""
Submit a command for background processing.
Returns immediately with job ID for status tracking.
Example request:
{
"command": "generate_podcast",
"app": "open_notebook",
"input": {
"episode_profile": "tech_experts",
"speaker_profile": "tech_experts",
"episode_name": "My Episode",
"content": "Content to discuss"
}
}
"""
try:
# Submit command using app name (not module name)
job_id = await CommandService.submit_command_job(
module_name=request.app, # This should be "open_notebook"
command_name=request.command,
command_args=request.input,
)
return CommandJobResponse(
job_id=job_id,
status="submitted",
message=f"Command '{request.command}' submitted successfully",
)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error submitting command: {str(e)}")
raise HTTPException(
status_code=500, detail="Failed to submit command"
)
@router.get("/commands/jobs/{job_id}", response_model=CommandJobStatusResponse)
async def get_command_job_status(job_id: str):
"""Get the status of a specific command job"""
try:
status_data = await CommandService.get_command_status(job_id)
return CommandJobStatusResponse(**status_data)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error fetching job status: {str(e)}")
raise HTTPException(
status_code=500, detail="Failed to fetch job status"
)
@router.get("/commands/jobs", response_model=List[Dict[str, Any]])
async def list_command_jobs(
command_filter: Optional[str] = Query(None, description="Filter by command name"),
status_filter: Optional[str] = Query(None, description="Filter by status"),
limit: int = Query(50, description="Maximum number of jobs to return"),
):
"""List command jobs with optional filtering"""
try:
jobs = await CommandService.list_command_jobs(
command_filter=command_filter, status_filter=status_filter, limit=limit
)
return jobs
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error listing command jobs: {str(e)}")
raise HTTPException(
status_code=500, detail="Failed to list command jobs"
)
@router.delete("/commands/jobs/{job_id}")
async def cancel_command_job(job_id: str):
"""Cancel a running command job"""
try:
success = await CommandService.cancel_command_job(job_id)
return {"job_id": job_id, "cancelled": success}
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error cancelling command job: {str(e)}")
raise HTTPException(
status_code=500, detail="Failed to cancel command job"
)
@router.get("/commands/registry/debug")
async def debug_registry():
"""Debug endpoint to see what commands are registered"""
try:
# Get all registered commands
all_items = registry.get_all_commands()
# Create JSON-serializable data
command_items = []
for item in all_items:
try:
command_items.append(
{
"app_id": item.app_id,
"name": item.name,
"full_id": f"{item.app_id}.{item.name}",
}
)
except Exception as item_error:
logger.error(f"Error processing item: {item_error}")
# Get the basic command structure
try:
commands_dict: dict[str, list[str]] = {}
for item in all_items:
if item.app_id not in commands_dict:
commands_dict[item.app_id] = []
commands_dict[item.app_id].append(item.name)
except Exception:
commands_dict = {}
return {
"total_commands": len(all_items),
"commands_by_app": commands_dict,
"command_items": command_items,
}
except Exception as e:
logger.error(f"Error debugging registry: {str(e)}")
return {
"error": str(e),
"total_commands": 0,
"commands_by_app": {},
"command_items": [],
}
+159
View File
@@ -0,0 +1,159 @@
import asyncio
import time
import tomllib
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Request
from loguru import logger
from open_notebook.database.repository import repo_query
from open_notebook.utils.version_utils import (
compare_versions,
get_version_from_github_async,
)
router = APIRouter()
# In-memory cache for version check results
_version_cache: dict = {
"latest_version": None,
"has_update": False,
"timestamp": 0,
"check_failed": False,
}
# Cache TTL in seconds (24 hours)
VERSION_CACHE_TTL = 24 * 60 * 60
def get_version() -> str:
"""Read version from pyproject.toml"""
try:
pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
with open(pyproject_path, "rb") as f:
pyproject = tomllib.load(f)
return pyproject.get("project", {}).get("version", "unknown")
except Exception as e:
logger.warning(f"Could not read version from pyproject.toml: {e}")
return "unknown"
async def get_latest_version_cached(current_version: str) -> tuple[Optional[str], bool]:
"""
Check for the latest version from GitHub with caching.
Returns:
tuple: (latest_version, has_update)
- latest_version: str or None if check failed
- has_update: bool indicating if update is available
"""
global _version_cache
# Check if cache is still valid (within TTL)
cache_age = time.time() - _version_cache["timestamp"]
if _version_cache["timestamp"] > 0 and cache_age < VERSION_CACHE_TTL:
logger.debug(f"Using cached version check result (age: {cache_age:.0f}s)")
return _version_cache["latest_version"], _version_cache["has_update"]
# Cache expired or not yet set
if _version_cache["timestamp"] > 0:
logger.info(f"Version cache expired (age: {cache_age:.0f}s), refreshing...")
# Perform version check with strict error handling
try:
logger.info("Checking for latest version from GitHub...")
# Fetch latest version from GitHub with 10-second timeout
latest_version = await get_version_from_github_async(
"https://github.com/lfnovo/open-notebook", "main"
)
logger.info(
f"Latest version from GitHub: {latest_version}, Current version: {current_version}"
)
# Compare versions
has_update = compare_versions(current_version, latest_version) < 0
# Cache the result
_version_cache["latest_version"] = latest_version
_version_cache["has_update"] = has_update
_version_cache["timestamp"] = time.time()
_version_cache["check_failed"] = False
logger.info(f"Version check complete. Update available: {has_update}")
return latest_version, has_update
except Exception as e:
logger.warning(f"Version check failed: {e}")
# Cache the failure to avoid repeated attempts
_version_cache["latest_version"] = None
_version_cache["has_update"] = False
_version_cache["timestamp"] = time.time()
_version_cache["check_failed"] = True
return None, False
async def check_database_health() -> dict:
"""
Check if database is reachable using a lightweight query.
Returns:
dict with 'status' ("online" | "offline") and optional 'error'
"""
try:
# 2-second timeout for database health check
result = await asyncio.wait_for(repo_query("RETURN 1"), timeout=2.0)
if result:
return {"status": "online"}
return {"status": "offline", "error": "Empty result"}
except asyncio.TimeoutError:
logger.warning("Database health check timed out after 2 seconds")
return {"status": "offline", "error": "Health check timeout"}
except Exception as e:
logger.warning(f"Database health check failed: {e}")
return {"status": "offline", "error": str(e)}
@router.get("/config")
async def get_config(request: Request):
"""
Get frontend configuration.
Returns version information and health status.
Note: The frontend determines the API URL via its own runtime-config endpoint,
so this endpoint no longer returns apiUrl.
Also checks for version updates from GitHub (with caching and error handling).
"""
# Get current version
current_version = get_version()
# Check for updates (with caching and error handling)
# This MUST NOT break the endpoint - wrapped in try-except as extra safety
latest_version = None
has_update = False
try:
latest_version, has_update = await get_latest_version_cached(current_version)
except Exception as e:
# Extra safety: ensure version check never breaks the config endpoint
logger.error(f"Unexpected error during version check: {e}")
# Check database health
db_health = await check_database_health()
db_status = db_health["status"]
if db_status == "offline":
logger.warning(f"Database offline: {db_health.get('error', 'Unknown error')}")
return {
"version": current_version,
"latestVersion": latest_version,
"hasUpdate": has_update,
"dbStatus": db_status,
}
+493
View File
@@ -0,0 +1,493 @@
"""
Credentials Router
Thin HTTP layer for managing individual AI provider credentials.
Business logic lives in api.credentials_service.
Endpoints:
- GET /credentials - List all credentials
- GET /credentials/by-provider/{provider} - List credentials for a provider
- POST /credentials - Create a new credential
- GET /credentials/{credential_id} - Get a specific credential
- PUT /credentials/{credential_id} - Update a credential
- DELETE /credentials/{credential_id} - Delete a credential
- POST /credentials/{credential_id}/test - Test connection
- POST /credentials/{credential_id}/discover - Discover models
- POST /credentials/{credential_id}/register-models - Register models
NEVER returns actual API key values - only metadata.
"""
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query
from loguru import logger
from pydantic import SecretStr
from api.credentials_service import (
credential_to_response,
discover_with_config,
get_provider_status,
register_models,
require_encryption_key,
validate_url,
)
from api.credentials_service import (
get_env_status as svc_get_env_status,
)
from api.credentials_service import (
migrate_from_env as svc_migrate_from_env,
)
from api.credentials_service import (
migrate_from_provider_config as svc_migrate_from_provider_config,
)
from api.credentials_service import (
test_credential as svc_test_credential,
)
from api.models import (
CreateCredentialRequest,
CredentialDeleteResponse,
CredentialResponse,
DiscoveredModelResponse,
DiscoverModelsResponse,
RegisterModelsRequest,
RegisterModelsResponse,
UpdateCredentialRequest,
)
from open_notebook.database.repository import ensure_record_id, repo_delete, repo_query
from open_notebook.domain.credential import Credential
from open_notebook.exceptions import (
NotFoundError,
OpenNotebookError,
)
router = APIRouter(prefix="/credentials", tags=["credentials"])
def _handle_value_error(e: ValueError, status_code: int = 400) -> HTTPException:
"""Convert a ValueError from the service layer to an HTTPException."""
return HTTPException(status_code=status_code, detail=str(e))
# =============================================================================
# Status endpoints
# =============================================================================
@router.get("/status")
async def get_status():
"""
Get configuration status: encryption key status, and per-provider
configured/source information.
"""
try:
return await get_provider_status()
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error fetching status: {e}")
raise HTTPException(status_code=500, detail="Failed to fetch credential status")
@router.get("/env-status")
async def get_env_status():
"""Check what's configured via environment variables."""
try:
return await svc_get_env_status()
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error checking env status: {e}")
raise HTTPException(status_code=500, detail="Failed to check environment status")
# =============================================================================
# CRUD endpoints
# =============================================================================
@router.get("", response_model=List[CredentialResponse])
async def list_credentials(
provider: Optional[str] = Query(None, description="Filter by provider"),
):
"""List all credentials, optionally filtered by provider."""
try:
if provider:
credentials = await Credential.get_by_provider(provider)
else:
credentials = await Credential.get_all(order_by="provider, created")
result = []
for cred in credentials:
models = await cred.get_linked_models()
result.append(credential_to_response(cred, len(models)))
return result
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error listing credentials: {e}")
raise HTTPException(status_code=500, detail="Failed to list credentials")
@router.get("/by-provider/{provider}", response_model=List[CredentialResponse])
async def list_credentials_by_provider(provider: str):
"""List all credentials for a specific provider."""
try:
credentials = await Credential.get_by_provider(provider.lower())
result = []
for cred in credentials:
models = await cred.get_linked_models()
result.append(credential_to_response(cred, len(models)))
return result
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error listing credentials for {provider}: {e}")
raise HTTPException(status_code=500, detail="Failed to list credentials for provider")
@router.post("", response_model=CredentialResponse, status_code=201)
async def create_credential(request: CreateCredentialRequest):
"""Create a new credential."""
try:
require_encryption_key()
except ValueError as e:
raise _handle_value_error(e)
# Validate all URL fields
for url_field in [
request.base_url, request.endpoint, request.endpoint_llm,
request.endpoint_embedding, request.endpoint_stt, request.endpoint_tts,
]:
if url_field:
try:
await validate_url(url_field, request.provider)
except ValueError as e:
raise _handle_value_error(e)
try:
cred = Credential(
name=request.name,
provider=request.provider.lower(),
modalities=request.modalities,
api_key=SecretStr(request.api_key) if request.api_key else None,
base_url=request.base_url,
endpoint=request.endpoint,
api_version=request.api_version,
endpoint_llm=request.endpoint_llm,
endpoint_embedding=request.endpoint_embedding,
endpoint_stt=request.endpoint_stt,
endpoint_tts=request.endpoint_tts,
project=request.project,
location=request.location,
credentials_path=request.credentials_path,
num_ctx=request.num_ctx,
)
await cred.save()
return credential_to_response(cred, 0)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error creating credential: {e}")
raise HTTPException(status_code=500, detail="Failed to create credential")
@router.get("/{credential_id}", response_model=CredentialResponse)
async def get_credential(credential_id: str):
"""Get a specific credential by ID. Never returns api_key."""
try:
cred = await Credential.get(credential_id)
models = await cred.get_linked_models()
return credential_to_response(cred, len(models))
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error fetching credential {credential_id}: {e}")
raise HTTPException(status_code=404, detail="Credential not found")
@router.put("/{credential_id}", response_model=CredentialResponse)
async def update_credential(credential_id: str, request: UpdateCredentialRequest):
"""Update an existing credential."""
try:
require_encryption_key()
except ValueError as e:
raise _handle_value_error(e)
# Validate all URL fields being updated
for url_field in [
request.base_url, request.endpoint, request.endpoint_llm,
request.endpoint_embedding, request.endpoint_stt, request.endpoint_tts,
]:
if url_field:
try:
await validate_url(url_field, "update")
except ValueError as e:
raise _handle_value_error(e)
try:
cred = await Credential.get(credential_id)
# Partial-update semantics keyed on field PRESENCE, not value:
# a field absent from the payload is left untouched, while an explicit
# null (or "") clears it. `is not None` checks would silently ignore
# a null sent to clear a field — the old value survived while the
# client saw success.
sent = request.model_fields_set
if request.name is not None:
cred.name = request.name
if request.modalities is not None:
cred.modalities = request.modalities
if request.api_key is not None:
cred.api_key = SecretStr(request.api_key)
if "base_url" in sent:
cred.base_url = request.base_url or None
if "endpoint" in sent:
cred.endpoint = request.endpoint or None
if "api_version" in sent:
cred.api_version = request.api_version or None
if "endpoint_llm" in sent:
cred.endpoint_llm = request.endpoint_llm or None
if "endpoint_embedding" in sent:
cred.endpoint_embedding = request.endpoint_embedding or None
if "endpoint_stt" in sent:
cred.endpoint_stt = request.endpoint_stt or None
if "endpoint_tts" in sent:
cred.endpoint_tts = request.endpoint_tts or None
if "project" in sent:
cred.project = request.project or None
if "location" in sent:
cred.location = request.location or None
if "credentials_path" in sent:
cred.credentials_path = request.credentials_path or None
if "num_ctx" in sent:
# 0/null/falsy clears the override and falls back to esperanto's default
cred.num_ctx = request.num_ctx or None
await cred.save()
models = await cred.get_linked_models()
return credential_to_response(cred, len(models))
except HTTPException:
raise
except NotFoundError:
raise HTTPException(status_code=404, detail="Credential not found")
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error updating credential {credential_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to update credential")
@router.delete("/{credential_id}", response_model=CredentialDeleteResponse)
async def delete_credential(
credential_id: str,
migrate_to: Optional[str] = Query(
None, description="Migrate linked models to this credential ID"
),
):
"""
Delete a credential.
If the credential has linked models:
- Pass migrate_to=<credential_id> to reassign them to another credential
- Otherwise, linked models are cascade-deleted automatically
"""
try:
try:
cred = await Credential.get(credential_id)
except ValueError as decrypt_err:
# Credential exists but can't be decrypted (wrong encryption key).
# Fall back to direct DB operations for deletion.
logger.warning(
f"Cannot decrypt credential {credential_id}, "
f"falling back to direct delete: {decrypt_err}"
)
# Query linked models
linked = await repo_query(
"SELECT * FROM model WHERE credential = $cred_id",
{"cred_id": ensure_record_id(credential_id)},
)
deleted_models = 0
if linked and migrate_to:
# Migrate models to another credential
target_cred = await Credential.get(migrate_to)
for model_row in linked:
model_id = str(model_row.get("id", ""))
if model_id:
await repo_query(
"UPDATE $model_id SET credential = $target_id",
{
"model_id": ensure_record_id(model_id),
# A fetched credential always has an id; fall
# back to the requested id for the type checker.
"target_id": ensure_record_id(
target_cred.id or migrate_to
),
},
)
elif linked:
# Cascade-delete linked models
for model_row in linked:
model_id = str(model_row.get("id", ""))
if model_id:
await repo_delete(model_id)
deleted_models += 1
# Delete the credential itself
await repo_delete(credential_id)
return CredentialDeleteResponse(
message="Credential deleted successfully",
deleted_models=deleted_models,
)
linked_models = await cred.get_linked_models()
deleted_models = 0
if linked_models and migrate_to:
# Migrate models to another credential
target_cred = await Credential.get(migrate_to)
for model in linked_models:
model.credential = target_cred.id
await model.save()
elif linked_models:
# Cascade-delete linked models (default behavior when no migrate_to)
for model in linked_models:
await model.delete()
deleted_models += 1
# Delete the credential
await cred.delete()
return CredentialDeleteResponse(
message="Credential deleted successfully",
deleted_models=deleted_models,
)
except HTTPException:
raise
except NotFoundError:
raise HTTPException(status_code=404, detail="Credential not found")
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error deleting credential {credential_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to delete credential")
# =============================================================================
# Test / Discover / Register endpoints
# =============================================================================
@router.post("/{credential_id}/test")
async def test_credential(credential_id: str):
"""Test connection using this credential's configuration."""
return await svc_test_credential(credential_id)
@router.post("/{credential_id}/discover", response_model=DiscoverModelsResponse)
async def discover_models_for_credential(credential_id: str):
"""Discover available models using this credential's API key."""
try:
cred = await Credential.get(credential_id)
config = cred.to_esperanto_config()
provider = cred.provider.lower()
discovered = await discover_with_config(provider, config)
return DiscoverModelsResponse(
credential_id=cred.id or "",
provider=provider,
discovered=[
DiscoveredModelResponse(
name=d["name"],
provider=d["provider"],
description=d.get("description"),
)
for d in discovered
],
)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error discovering models for credential {credential_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to discover models")
@router.post("/{credential_id}/register-models", response_model=RegisterModelsResponse)
async def register_models_for_credential(
credential_id: str, request: RegisterModelsRequest
):
"""Register discovered models and link them to this credential."""
try:
result = await register_models(credential_id, request.models)
return RegisterModelsResponse(**result)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error registering models for credential {credential_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to register models")
# =============================================================================
# Migration endpoints
# =============================================================================
@router.post("/migrate-from-provider-config")
async def migrate_from_provider_config():
"""Migrate existing ProviderConfig data to individual credential records."""
try:
return await svc_migrate_from_provider_config()
except ValueError as e:
raise _handle_value_error(e)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"ProviderConfig migration FAILED: {type(e).__name__}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Migration from provider config failed")
@router.post("/migrate-from-env")
async def migrate_from_env():
"""Migrate API keys from environment variables to credential records."""
try:
return await svc_migrate_from_env()
except ValueError as e:
raise _handle_value_error(e)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Env migration FAILED: {type(e).__name__}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Migration from environment variables failed")
+129
View File
@@ -0,0 +1,129 @@
from fastapi import APIRouter, HTTPException
from loguru import logger
from api.command_service import CommandService
from api.models import EmbedRequest, EmbedResponse
from open_notebook.ai.models import model_manager
from open_notebook.domain.notebook import Note, Source
from open_notebook.exceptions import (
NotFoundError,
OpenNotebookError,
)
router = APIRouter()
@router.post("/embed", response_model=EmbedResponse)
async def embed_content(embed_request: EmbedRequest):
"""Embed content for vector search."""
try:
# Check if embedding model is available
if not await model_manager.get_embedding_model():
raise HTTPException(
status_code=400,
detail="No embedding model configured. Please configure one in the Models section.",
)
item_id = embed_request.item_id
item_type = embed_request.item_type.lower()
# Validate item type
if item_type not in ["source", "note"]:
raise HTTPException(
status_code=400, detail="Item type must be either 'source' or 'note'"
)
# Branch based on processing mode
if embed_request.async_processing:
# ASYNC PATH: Submit command for background processing
logger.info(f"Using async processing for {item_type} {item_id}")
try:
# Import commands to ensure they're registered
import commands.embedding_commands # noqa: F401
# Submit type-specific command
if item_type == "source":
command_name = "embed_source"
command_input = {"source_id": item_id}
else: # note
command_name = "embed_note"
command_input = {"note_id": item_id}
command_id = await CommandService.submit_command_job(
"open_notebook",
command_name,
command_input,
)
logger.info(f"Submitted async {command_name} command: {command_id}")
return EmbedResponse(
success=True,
message="Embedding queued for background processing",
item_id=item_id,
item_type=item_type,
command_id=command_id,
)
except Exception as e:
logger.error(f"Failed to submit async embedding command: {e}")
raise HTTPException(
status_code=500, detail=f"Failed to queue embedding: {str(e)}"
)
else:
# DOMAIN MODEL PATH: Submit job via domain model convenience methods
# These methods internally call submit_command() - still fire-and-forget
logger.info(f"Using domain model path for {item_type} {item_id}")
command_id = None
# Get the item and submit embedding job
if item_type == "source":
source_item = await Source.get(item_id)
# Submit embed_source job (returns command_id for tracking)
command_id = await source_item.vectorize()
message = "Source embedding job submitted"
elif item_type == "note":
note_item = await Note.get(item_id)
# Note.save() internally submits embed_note command and
# returns command_id. Unlike Source.vectorize(), save()'s
# embed submission is best-effort (a hiccup there shouldn't
# fail an otherwise-successful note save) - but this
# endpoint's whole point is submitting the embedding job,
# so a submission failure here (content present, no
# command_id) must still surface as a failure.
command_id = await note_item.save()
if not command_id and note_item.content and note_item.content.strip():
raise HTTPException(
status_code=500, detail="Failed to submit note embedding job"
)
message = "Note embedding job submitted"
return EmbedResponse(
success=True,
message=message,
item_id=item_id,
item_type=item_type,
command_id=command_id,
)
except HTTPException:
raise
except NotFoundError:
raise HTTPException(
status_code=404, detail=f"{embed_request.item_type} not found"
)
except OpenNotebookError:
raise
except Exception as e:
logger.error(
f"Error embedding {embed_request.item_type} {embed_request.item_id}: {str(e)}"
)
raise HTTPException(
status_code=500, detail=f"Error embedding content: {str(e)}"
)
+199
View File
@@ -0,0 +1,199 @@
from fastapi import APIRouter, HTTPException
from loguru import logger
from surreal_commands import get_command_status
from api.command_service import CommandService
from api.models import (
RebuildProgress,
RebuildRequest,
RebuildResponse,
RebuildStats,
RebuildStatusResponse,
)
from open_notebook.database.repository import repo_query
from open_notebook.exceptions import OpenNotebookError
router = APIRouter()
@router.post("/rebuild", response_model=RebuildResponse)
async def start_rebuild(request: RebuildRequest):
"""
Start a background job to rebuild embeddings.
- **mode**: "existing" (re-embed items with embeddings) or "all" (embed everything)
- **include_sources**: Include sources in rebuild (default: true)
- **include_notes**: Include notes in rebuild (default: true)
- **include_insights**: Include insights in rebuild (default: true)
Returns command ID to track progress and estimated item count.
"""
try:
logger.info(f"Starting rebuild request: mode={request.mode}")
# Import commands to ensure they're registered
import commands.embedding_commands # noqa: F401
# Estimate total items (quick count query)
# This is a rough estimate before the command runs
total_estimate = 0
if request.include_sources:
if request.mode == "existing":
# Count sources with embeddings
result = await repo_query(
"""
SELECT VALUE count(array::distinct(
SELECT VALUE source.id
FROM source_embedding
WHERE embedding != none AND array::len(embedding) > 0
)) as count FROM {}
"""
)
else:
# Count all sources with content
result = await repo_query(
"SELECT VALUE count() as count FROM source WHERE full_text != none GROUP ALL"
)
if result and isinstance(result[0], dict):
total_estimate += result[0].get("count", 0)
elif result:
total_estimate += result[0] if isinstance(result[0], int) else 0
if request.include_notes:
if request.mode == "existing":
result = await repo_query(
"SELECT VALUE count() as count FROM note WHERE embedding != none AND array::len(embedding) > 0 GROUP ALL"
)
else:
result = await repo_query(
"SELECT VALUE count() as count FROM note WHERE content != none GROUP ALL"
)
if result and isinstance(result[0], dict):
total_estimate += result[0].get("count", 0)
elif result:
total_estimate += result[0] if isinstance(result[0], int) else 0
if request.include_insights:
if request.mode == "existing":
result = await repo_query(
"SELECT VALUE count() as count FROM source_insight WHERE embedding != none AND array::len(embedding) > 0 GROUP ALL"
)
else:
result = await repo_query(
"SELECT VALUE count() as count FROM source_insight GROUP ALL"
)
if result and isinstance(result[0], dict):
total_estimate += result[0].get("count", 0)
elif result:
total_estimate += result[0] if isinstance(result[0], int) else 0
logger.info(f"Estimated {total_estimate} items to process")
# Submit command
command_id = await CommandService.submit_command_job(
"open_notebook",
"rebuild_embeddings",
{
"mode": request.mode,
"include_sources": request.include_sources,
"include_notes": request.include_notes,
"include_insights": request.include_insights,
},
)
logger.info(f"Submitted rebuild command: {command_id}")
return RebuildResponse(
command_id=command_id,
total_items=total_estimate,
message=f"Rebuild operation started. Estimated {total_estimate} items to process.",
)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Failed to start rebuild: {e}")
logger.exception(e)
raise HTTPException(
status_code=500, detail=f"Failed to start rebuild operation: {str(e)}"
)
@router.get("/rebuild/{command_id}/status", response_model=RebuildStatusResponse)
async def get_rebuild_status(command_id: str):
"""
Get the status of a rebuild operation.
Returns:
- **status**: queued, running, completed, failed
- **progress**: processed count, total count, percentage
- **stats**: breakdown by type (sources, notes, insights, failed)
- **timestamps**: started_at, completed_at
"""
try:
# Get command status from surreal_commands
status = await get_command_status(command_id)
if not status:
raise HTTPException(status_code=404, detail="Rebuild command not found")
# Build response based on status
response = RebuildStatusResponse(
command_id=command_id,
status=status.status,
)
# Extract metadata from command result
if status.result and isinstance(status.result, dict):
result = status.result
# Build progress info
if "total_items" in result and "jobs_submitted" in result:
total = result["total_items"]
submitted = result["jobs_submitted"]
response.progress = RebuildProgress(
processed=submitted,
total=total,
percentage=round((submitted / total * 100) if total > 0 else 0, 2),
)
# Build stats
response.stats = RebuildStats(
sources=result.get("sources_submitted", 0),
notes=result.get("notes_submitted", 0),
insights=result.get("insights_submitted", 0),
failed=result.get("failed_submissions", 0),
)
# Add timestamps
if hasattr(status, "created") and status.created:
response.started_at = str(status.created)
if hasattr(status, "updated") and status.updated:
response.completed_at = str(status.updated)
# Add error message if failed
if (
status.status == "failed"
and status.result
and isinstance(status.result, dict)
):
response.error_message = status.result.get("error_message", "Unknown error")
return response
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Failed to get rebuild status: {e}")
logger.exception(e)
raise HTTPException(
status_code=500, detail=f"Failed to get rebuild status: {str(e)}"
)
+278
View File
@@ -0,0 +1,278 @@
from typing import Dict, List, Optional
from fastapi import APIRouter, HTTPException
from loguru import logger
from pydantic import BaseModel, Field
from open_notebook.exceptions import InvalidInputError, OpenNotebookError
from open_notebook.podcasts.models import EpisodeProfile, SpeakerProfile
router = APIRouter()
class EpisodeProfileResponse(BaseModel):
id: str
name: str
description: str
speaker_config: Optional[str] = Field(
None, description="speaker_profile record ID (null when orphaned)"
)
speaker_config_name: Optional[str] = Field(
None, description="Resolved speaker profile name (for display)"
)
outline_llm: Optional[str] = None
transcript_llm: Optional[str] = None
language: Optional[str] = None
default_briefing: str
num_segments: int
max_tokens: Optional[int] = None
async def _speaker_names_by_id() -> Dict[str, str]:
"""Map speaker_profile record ID -> name for list serialization."""
speakers = await SpeakerProfile.get_all()
return {str(speaker.id): speaker.name for speaker in speakers}
async def _speaker_name_for(speaker_config: Optional[str]) -> Optional[str]:
"""Resolve one profile's speaker_config record ID to the speaker name.
Returns None for a missing or dangling reference - the frontend renders
that as "needs setup"."""
if not speaker_config:
return None
speaker = await SpeakerProfile.resolve(speaker_config)
return speaker.name if speaker else None
def _profile_to_response(
profile: EpisodeProfile, speaker_name: Optional[str]
) -> EpisodeProfileResponse:
return EpisodeProfileResponse(
id=str(profile.id),
name=profile.name,
description=profile.description or "",
speaker_config=profile.speaker_config,
speaker_config_name=speaker_name,
outline_llm=profile.outline_llm,
transcript_llm=profile.transcript_llm,
language=profile.language,
default_briefing=profile.default_briefing,
num_segments=profile.num_segments,
max_tokens=profile.max_tokens,
)
async def _resolve_speaker_config(value: str) -> SpeakerProfile:
"""Resolve an incoming speaker_config (record ID, or name for backward
compatibility) to the referenced SpeakerProfile."""
speaker = await SpeakerProfile.resolve(value)
if not speaker:
raise InvalidInputError(f"Speaker profile '{value}' not found")
return speaker
@router.get("/episode-profiles", response_model=List[EpisodeProfileResponse])
async def list_episode_profiles():
"""List all available episode profiles"""
try:
profiles = await EpisodeProfile.get_all(order_by="name asc")
speaker_names = await _speaker_names_by_id()
return [
_profile_to_response(
p, speaker_names.get(p.speaker_config) if p.speaker_config else None
)
for p in profiles
]
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Failed to fetch episode profiles: {e}")
raise HTTPException(
status_code=500, detail="Failed to fetch episode profiles"
)
@router.get("/episode-profiles/{profile_name}", response_model=EpisodeProfileResponse)
async def get_episode_profile(profile_name: str):
"""Get a specific episode profile by name"""
try:
profile = await EpisodeProfile.get_by_name(profile_name)
if not profile:
raise HTTPException(
status_code=404, detail=f"Episode profile '{profile_name}' not found"
)
return _profile_to_response(
profile, await _speaker_name_for(profile.speaker_config)
)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Failed to fetch episode profile '{profile_name}': {e}")
raise HTTPException(
status_code=500, detail="Failed to fetch episode profile"
)
class EpisodeProfileCreate(BaseModel):
name: str = Field(..., description="Unique profile name")
description: str = Field("", description="Profile description")
speaker_config: str = Field(
...,
description=(
"speaker_profile record ID (a profile name is also accepted "
"for backward compatibility)"
),
)
outline_llm: Optional[str] = Field(None, description="Model record ID for outline")
transcript_llm: Optional[str] = Field(
None, description="Model record ID for transcript"
)
language: Optional[str] = Field(None, description="Podcast language code")
default_briefing: str = Field(..., description="Default briefing template")
num_segments: int = Field(default=5, description="Number of podcast segments")
max_tokens: Optional[int] = Field(
None,
description="Max output tokens for outline/transcript generation",
)
@router.post("/episode-profiles", response_model=EpisodeProfileResponse)
async def create_episode_profile(profile_data: EpisodeProfileCreate):
"""Create a new episode profile"""
try:
speaker = await _resolve_speaker_config(profile_data.speaker_config)
profile = EpisodeProfile(
name=profile_data.name,
description=profile_data.description,
speaker_config=str(speaker.id),
outline_llm=profile_data.outline_llm,
transcript_llm=profile_data.transcript_llm,
language=profile_data.language,
default_briefing=profile_data.default_briefing,
num_segments=profile_data.num_segments,
max_tokens=profile_data.max_tokens,
)
await profile.save()
return _profile_to_response(profile, speaker.name)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Failed to create episode profile: {e}")
raise HTTPException(
status_code=500, detail="Failed to create episode profile"
)
@router.put("/episode-profiles/{profile_id}", response_model=EpisodeProfileResponse)
async def update_episode_profile(profile_id: str, profile_data: EpisodeProfileCreate):
"""Update an existing episode profile"""
try:
profile = await EpisodeProfile.get(profile_id)
if not profile:
raise HTTPException(
status_code=404, detail=f"Episode profile '{profile_id}' not found"
)
update_data = profile_data.model_dump(exclude_unset=True)
speaker_name: Optional[str] = None
if "speaker_config" in update_data:
speaker = await _resolve_speaker_config(update_data["speaker_config"])
update_data["speaker_config"] = str(speaker.id)
speaker_name = speaker.name
for field, value in update_data.items():
setattr(profile, field, value)
await profile.save()
if speaker_name is None:
speaker_name = await _speaker_name_for(profile.speaker_config)
return _profile_to_response(profile, speaker_name)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Failed to update episode profile: {e}")
raise HTTPException(
status_code=500, detail="Failed to update episode profile"
)
@router.delete("/episode-profiles/{profile_id}")
async def delete_episode_profile(profile_id: str):
"""Delete an episode profile"""
try:
profile = await EpisodeProfile.get(profile_id)
if not profile:
raise HTTPException(
status_code=404, detail=f"Episode profile '{profile_id}' not found"
)
await profile.delete()
return {"message": "Episode profile deleted successfully"}
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Failed to delete episode profile: {e}")
raise HTTPException(
status_code=500, detail="Failed to delete episode profile"
)
@router.post(
"/episode-profiles/{profile_id}/duplicate", response_model=EpisodeProfileResponse
)
async def duplicate_episode_profile(profile_id: str):
"""Duplicate an episode profile"""
try:
original = await EpisodeProfile.get(profile_id)
if not original:
raise HTTPException(
status_code=404, detail=f"Episode profile '{profile_id}' not found"
)
duplicate = EpisodeProfile(
name=f"{original.name} - Copy",
description=original.description,
speaker_config=original.speaker_config,
outline_llm=original.outline_llm,
transcript_llm=original.transcript_llm,
language=original.language,
default_briefing=original.default_briefing,
num_segments=original.num_segments,
max_tokens=original.max_tokens,
)
await duplicate.save()
return _profile_to_response(
duplicate, await _speaker_name_for(duplicate.speaker_config)
)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Failed to duplicate episode profile: {e}")
raise HTTPException(
status_code=500, detail="Failed to duplicate episode profile"
)
+94
View File
@@ -0,0 +1,94 @@
from fastapi import APIRouter, HTTPException
from loguru import logger
from api.models import NoteResponse, SaveAsNoteRequest, SourceInsightResponse
from open_notebook.domain.notebook import SourceInsight
from open_notebook.exceptions import (
InvalidInputError,
NotFoundError,
OpenNotebookError,
)
router = APIRouter()
@router.get("/insights/{insight_id}", response_model=SourceInsightResponse)
async def get_insight(insight_id: str):
"""Get a specific insight by ID."""
try:
insight = await SourceInsight.get(insight_id)
if not insight:
raise HTTPException(status_code=404, detail="Insight not found")
# Get source ID from the insight relationship
source = await insight.get_source()
return SourceInsightResponse(
id=insight.id or "",
source_id=source.id or "",
insight_type=insight.insight_type,
content=insight.content,
created=insight.created.isoformat() if insight.created else None,
updated=insight.updated.isoformat() if insight.updated else None,
)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error fetching insight {insight_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error fetching insight")
@router.delete("/insights/{insight_id}")
async def delete_insight(insight_id: str):
"""Delete a specific insight."""
try:
insight = await SourceInsight.get(insight_id)
if not insight:
raise HTTPException(status_code=404, detail="Insight not found")
await insight.delete()
return {"message": "Insight deleted successfully"}
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error deleting insight {insight_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error deleting insight")
@router.post("/insights/{insight_id}/save-as-note", response_model=NoteResponse)
async def save_insight_as_note(insight_id: str, request: SaveAsNoteRequest):
"""Convert an insight to a note."""
try:
insight = await SourceInsight.get(insight_id)
if not insight:
raise HTTPException(status_code=404, detail="Insight not found")
# Use the existing save_as_note method from the domain model
note = await insight.save_as_note(request.notebook_id)
return NoteResponse(
id=note.id or "",
title=note.title,
content=note.content,
note_type=note.note_type,
created=str(note.created),
updated=str(note.updated),
)
except HTTPException:
raise
except NotFoundError:
raise HTTPException(status_code=404, detail="Notebook not found")
except InvalidInputError as e:
raise HTTPException(status_code=400, detail=str(e))
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error saving insight {insight_id} as note: {str(e)}")
raise HTTPException(
status_code=500, detail="Error saving insight as note"
)
+83
View File
@@ -0,0 +1,83 @@
from typing import List
import pycountry
from babel import Locale
from babel.core import get_global
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter()
# Additional regional variants for languages where the distinction matters
# (TTS accent, vocabulary, spelling differences)
_EXTRA_VARIANTS = [
"pt_PT",
"en_GB",
"en_AU",
"en_IN",
"es_MX",
"es_AR",
"es_CO",
"fr_CA",
"fr_CH",
"zh_TW",
"zh_HK",
"de_AT",
"de_CH",
"ar_SA",
"nl_BE",
]
class LanguageResponse(BaseModel):
code: str
name: str
@router.get("/languages", response_model=List[LanguageResponse])
async def list_languages():
"""List available languages as BCP 47 locale codes (e.g. pt-BR, en-US)."""
likely_subtags = get_global("likely_subtags")
languages = []
seen = set()
# 1. For each language, resolve its default locale via CLDR likely subtags
for lang in pycountry.languages:
if not hasattr(lang, "alpha_2"):
continue
code = lang.alpha_2
likely = likely_subtags.get(code)
if likely:
try:
loc = Locale.parse(likely)
if loc.territory:
bcp47 = f"{loc.language}-{loc.territory}"
display = loc.get_display_name("en")
if bcp47 not in seen:
seen.add(bcp47)
languages.append(LanguageResponse(code=bcp47, name=display))
continue
except Exception:
pass
# Fallback: bare language code
if code not in seen:
seen.add(code)
languages.append(LanguageResponse(code=code, name=lang.name))
# 2. Add important regional variants
for locale_str in _EXTRA_VARIANTS:
try:
loc = Locale.parse(locale_str)
bcp47 = f"{loc.language}-{loc.territory}"
if bcp47 not in seen:
seen.add(bcp47)
display = loc.get_display_name("en")
languages.append(LanguageResponse(code=bcp47, name=display))
except Exception:
pass
languages.sort(key=lambda x: x.name)
return languages
+830
View File
@@ -0,0 +1,830 @@
import os
import traceback
from typing import Dict, List, Optional
from esperanto import AIFactory
from fastapi import APIRouter, HTTPException, Query
from loguru import logger
from pydantic import BaseModel
from api.models import (
DefaultModelsResponse,
ModelCreate,
ModelResponse,
ProviderAvailabilityResponse,
)
from open_notebook.ai.connection_tester import test_individual_model
from open_notebook.ai.key_provider import provision_provider_keys
from open_notebook.ai.model_discovery import (
discover_provider_models,
get_provider_model_count,
sync_all_providers,
sync_provider_models,
)
from open_notebook.ai.models import DefaultModels, Model
from open_notebook.domain.credential import Credential
from open_notebook.exceptions import (
InvalidInputError,
NotFoundError,
OpenNotebookError,
)
router = APIRouter()
# =============================================================================
# Model Discovery Response Models
# =============================================================================
class DiscoveredModelResponse(BaseModel):
"""Response model for a discovered model."""
name: str
provider: str
model_type: str
description: Optional[str] = None
class ProviderSyncResponse(BaseModel):
"""Response model for provider sync operation."""
provider: str
discovered: int
new: int
existing: int
class AllProvidersSyncResponse(BaseModel):
"""Response model for syncing all providers."""
results: Dict[str, ProviderSyncResponse]
total_discovered: int
total_new: int
class ProviderModelCountResponse(BaseModel):
"""Response model for provider model counts."""
provider: str
counts: Dict[str, int]
total: int
class AutoAssignResult(BaseModel):
"""Response model for auto-assign operation."""
assigned: Dict[str, str] # slot_name -> model_id
skipped: List[str] # slots already assigned
missing: List[str] # slots with no available models
class ModelTestResponse(BaseModel):
"""Response model for individual model test."""
success: bool
message: str
details: Optional[str] = None
# Provider priority for auto-assignment (higher priority first)
PROVIDER_PRIORITY = [
"openai",
"anthropic",
"google",
"mistral",
"groq",
"deepseek",
"xai",
"openrouter",
"ollama",
"azure",
"openai_compatible",
"dashscope",
"minimax",
]
# Model preference patterns (preferred models within each provider)
MODEL_PREFERENCES = {
"openai": ["gpt-4o", "gpt-4", "gpt-3.5-turbo"],
"anthropic": ["claude-3-5-sonnet", "claude-3-opus", "claude-3-sonnet"],
"google": ["gemini-3.5-flash", "gemini-2.5-flash", "gemini-2.5-pro"],
"mistral": ["mistral-large", "mixtral"],
"groq": ["llama-3.3", "llama-3.1", "mixtral"],
"dashscope": ["qwen-max", "qwen-plus", "qwen-turbo"],
"minimax": ["MiniMax-M2.5", "MiniMax-M2.5-highspeed"],
}
async def _check_provider_has_credential(provider: str) -> bool:
"""Check if a provider has any credentials configured in the database."""
try:
credentials = await Credential.get_by_provider(provider)
return len(credentials) > 0
except Exception:
pass
return False
def _check_azure_support(mode: str) -> bool:
"""
Check if Azure OpenAI provider is available for a specific mode.
Args:
mode: One of 'LLM', 'EMBEDDING', 'STT', 'TTS'
Returns:
bool: True if either generic or mode-specific env vars are set
"""
# Check generic configuration (applies to all modes)
generic = (
os.environ.get("AZURE_OPENAI_API_KEY") is not None
and os.environ.get("AZURE_OPENAI_ENDPOINT") is not None
and os.environ.get("AZURE_OPENAI_API_VERSION") is not None
)
# Check mode-specific configuration (takes precedence)
specific = (
os.environ.get(f"AZURE_OPENAI_API_KEY_{mode}") is not None
and os.environ.get(f"AZURE_OPENAI_ENDPOINT_{mode}") is not None
and os.environ.get(f"AZURE_OPENAI_API_VERSION_{mode}") is not None
)
return generic or specific
def _check_openai_compatible_support(mode: str) -> bool:
"""
Check if OpenAI-compatible provider is available for a specific mode.
Args:
mode: One of 'LLM', 'EMBEDDING', 'STT', 'TTS'
Returns:
bool: True if either generic or mode-specific env var is set
"""
generic = os.environ.get("OPENAI_COMPATIBLE_BASE_URL") is not None
specific = os.environ.get(f"OPENAI_COMPATIBLE_BASE_URL_{mode}") is not None
generic_key = os.environ.get("OPENAI_COMPATIBLE_API_KEY") is not None
specific_key = os.environ.get(f"OPENAI_COMPATIBLE_API_KEY_{mode}") is not None
return generic or specific or generic_key or specific_key
@router.get("/models", response_model=List[ModelResponse])
async def get_models(
type: Optional[str] = Query(None, description="Filter by model type"),
):
"""Get all configured models with optional type filtering."""
try:
if type:
models = await Model.get_models_by_type(type)
else:
models = await Model.get_all()
return [
ModelResponse(
id=model.id,
name=model.name,
provider=model.provider,
type=model.type,
credential=model.credential,
created=str(model.created),
updated=str(model.updated),
)
for model in models
]
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error fetching models: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error fetching models: {str(e)}")
@router.post("/models", response_model=ModelResponse)
async def create_model(model_data: ModelCreate):
"""Create a new model configuration."""
try:
# Validate model type
valid_types = ["language", "embedding", "text_to_speech", "speech_to_text"]
if model_data.type not in valid_types:
raise HTTPException(
status_code=400,
detail=f"Invalid model type. Must be one of: {valid_types}",
)
# Check for duplicate model name under the same provider and type (case-insensitive)
from open_notebook.database.repository import repo_query
existing = await repo_query(
"SELECT * FROM model WHERE string::lowercase(provider) = $provider AND string::lowercase(name) = $name AND string::lowercase(type) = $type LIMIT 1",
{
"provider": model_data.provider.lower(),
"name": model_data.name.lower(),
"type": model_data.type.lower(),
},
)
if existing:
raise HTTPException(
status_code=400,
detail=f"Model '{model_data.name}' already exists for provider '{model_data.provider}' with type '{model_data.type}'",
)
new_model = Model(
name=model_data.name,
provider=model_data.provider,
type=model_data.type,
credential=model_data.credential,
)
await new_model.save()
return ModelResponse(
id=new_model.id or "",
name=new_model.name,
provider=new_model.provider,
type=new_model.type,
credential=new_model.credential,
created=str(new_model.created),
updated=str(new_model.updated),
)
except HTTPException:
raise
except InvalidInputError as e:
raise HTTPException(status_code=400, detail=str(e))
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error creating model: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error creating model: {str(e)}")
@router.delete("/models/{model_id}")
async def delete_model(model_id: str):
"""Delete a model configuration."""
try:
model = await Model.get(model_id)
await model.delete()
return {"message": "Model deleted successfully"}
except HTTPException:
raise
except NotFoundError:
raise HTTPException(status_code=404, detail="Model not found")
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error deleting model {model_id}: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error deleting model: {str(e)}")
@router.post("/models/{model_id}/test", response_model=ModelTestResponse)
async def test_model(model_id: str):
"""Test if a specific model is correctly configured and functional."""
try:
model = await Model.get(model_id)
if not model:
raise HTTPException(status_code=404, detail="Model not found")
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception:
raise HTTPException(status_code=404, detail="Model not found")
try:
success, message = await test_individual_model(model)
return ModelTestResponse(success=success, message=message)
except Exception as e:
logger.error(f"Error testing model {model_id}: {traceback.format_exc()}")
return ModelTestResponse(
success=False,
message=str(e)[:200],
)
@router.get("/models/defaults", response_model=DefaultModelsResponse)
async def get_default_models():
"""Get default model assignments."""
try:
defaults = await DefaultModels.get_instance()
return DefaultModelsResponse(
default_chat_model=defaults.default_chat_model, # type: ignore[attr-defined]
default_transformation_model=defaults.default_transformation_model, # type: ignore[attr-defined]
large_context_model=defaults.large_context_model, # type: ignore[attr-defined]
default_text_to_speech_model=defaults.default_text_to_speech_model, # type: ignore[attr-defined]
default_speech_to_text_model=defaults.default_speech_to_text_model, # type: ignore[attr-defined]
default_embedding_model=defaults.default_embedding_model, # type: ignore[attr-defined]
default_tools_model=defaults.default_tools_model, # type: ignore[attr-defined]
)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error fetching default models: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error fetching default models: {str(e)}"
)
# Defaults the app cannot function without — they can be reassigned but
# never cleared (the optional ones fall back to the chat default or are
# simply skipped when unset).
REQUIRED_DEFAULTS = {"default_chat_model", "default_embedding_model"}
@router.put("/models/defaults", response_model=DefaultModelsResponse)
async def update_default_models(defaults_data: DefaultModelsResponse):
"""Update default model assignments.
Partial-update semantics keyed on field PRESENCE, not value: a field
absent from the payload is left untouched, while an explicit null clears
the default (except required ones). `is not None` checks would silently
ignore a null sent to clear a default — the old value survived while the
client saw success (same anti-pattern fixed for credentials in #1046).
"""
try:
defaults = await DefaultModels.get_instance()
sent = defaults_data.model_fields_set
for field in DefaultModelsResponse.model_fields:
if field not in sent:
continue
value = getattr(defaults_data, field)
if value is None and field in REQUIRED_DEFAULTS:
raise InvalidInputError(
f"{field} is required and cannot be cleared, only reassigned"
)
setattr(defaults, field, value)
await defaults.update()
# No cache refresh needed - next access will fetch fresh data from DB
return DefaultModelsResponse(
default_chat_model=defaults.default_chat_model, # type: ignore[attr-defined]
default_transformation_model=defaults.default_transformation_model, # type: ignore[attr-defined]
large_context_model=defaults.large_context_model, # type: ignore[attr-defined]
default_text_to_speech_model=defaults.default_text_to_speech_model, # type: ignore[attr-defined]
default_speech_to_text_model=defaults.default_speech_to_text_model, # type: ignore[attr-defined]
default_embedding_model=defaults.default_embedding_model, # type: ignore[attr-defined]
default_tools_model=defaults.default_tools_model, # type: ignore[attr-defined]
)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error updating default models: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error updating default models: {str(e)}"
)
@router.get("/models/providers", response_model=ProviderAvailabilityResponse)
async def get_provider_availability():
"""Get provider availability based on database config and environment variables."""
try:
# Check which providers have credentials in the database or env vars
# For each provider, check DB credentials first, then env vars as fallback
# Simple env var mapping for backward compatibility
env_var_map = {
"openai": "OPENAI_API_KEY",
"anthropic": "ANTHROPIC_API_KEY",
"google": "GOOGLE_API_KEY",
"groq": "GROQ_API_KEY",
"mistral": "MISTRAL_API_KEY",
"deepseek": "DEEPSEEK_API_KEY",
"xai": "XAI_API_KEY",
"openrouter": "OPENROUTER_API_KEY",
"voyage": "VOYAGE_API_KEY",
"elevenlabs": "ELEVENLABS_API_KEY",
"deepgram": "DEEPGRAM_API_KEY",
"ollama": "OLLAMA_API_BASE",
"dashscope": "DASHSCOPE_API_KEY",
"minimax": "MINIMAX_API_KEY",
}
provider_status = {}
# Check simple providers: credential in DB or env var
for provider, env_var in env_var_map.items():
has_cred = await _check_provider_has_credential(provider)
has_env = os.environ.get(env_var) is not None
provider_status[provider] = has_cred or has_env
# Google also supports GEMINI_API_KEY
if not provider_status.get("google"):
provider_status["google"] = os.environ.get("GEMINI_API_KEY") is not None
# Vertex: DB credential or env vars
provider_status["vertex"] = (
await _check_provider_has_credential("vertex")
or os.environ.get("VERTEX_PROJECT") is not None
)
# Azure: DB credential or env vars
provider_status["azure"] = (
await _check_provider_has_credential("azure")
or _check_azure_support("LLM")
or _check_azure_support("EMBEDDING")
or _check_azure_support("STT")
or _check_azure_support("TTS")
)
# OpenAI-compatible: DB credential or env vars
provider_status["openai_compatible"] = (
await _check_provider_has_credential("openai_compatible")
or _check_openai_compatible_support("LLM")
or _check_openai_compatible_support("EMBEDDING")
or _check_openai_compatible_support("STT")
or _check_openai_compatible_support("TTS")
)
available_providers = [k for k, v in provider_status.items() if v]
unavailable_providers = [k for k, v in provider_status.items() if not v]
# Get supported model types from Esperanto
esperanto_available = AIFactory.get_available_providers()
# Build supported types mapping only for available providers
supported_types: dict[str, list[str]] = {}
for provider in available_providers:
supported_types[provider] = []
# Map Esperanto model types to our environment variable modes
mode_mapping = {
"language": "LLM",
"embedding": "EMBEDDING",
"speech_to_text": "STT",
"text_to_speech": "TTS",
}
# Special handling for openai-compatible to check mode-specific availability
if provider == "openai_compatible":
# Esperanto exposes this provider with a hyphen ("openai-compatible"),
# while the rest of the codebase uses the underscore form.
esperanto_name = "openai-compatible"
has_db_cred = await _check_provider_has_credential("openai_compatible")
for model_type, mode in mode_mapping.items():
if (
model_type in esperanto_available
and esperanto_name in esperanto_available[model_type]
):
if has_db_cred or _check_openai_compatible_support(mode):
supported_types[provider].append(model_type)
# Special handling for azure to check mode-specific availability
elif provider == "azure":
has_db_cred = await _check_provider_has_credential("azure")
for model_type, mode in mode_mapping.items():
if (
model_type in esperanto_available
and provider in esperanto_available[model_type]
):
if has_db_cred or _check_azure_support(mode):
supported_types[provider].append(model_type)
else:
# Standard provider detection
for model_type, providers in esperanto_available.items():
if provider in providers:
supported_types[provider].append(model_type)
return ProviderAvailabilityResponse(
available=available_providers,
unavailable=unavailable_providers,
supported_types=supported_types,
)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error checking provider availability: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error checking provider availability: {str(e)}"
)
# =============================================================================
# Model Discovery Endpoints
# =============================================================================
@router.get(
"/models/discover/{provider}", response_model=List[DiscoveredModelResponse]
)
async def discover_models(provider: str):
"""
Discover available models from a provider without registering them.
This endpoint queries the provider's API to list available models
but does not save them to the database. Use the sync endpoint
to both discover and register models.
"""
try:
# Provision DB-stored credentials into env vars before discovery
await provision_provider_keys(provider)
discovered = await discover_provider_models(provider)
return [
DiscoveredModelResponse(
name=m.name,
provider=m.provider,
model_type=m.model_type,
description=m.description,
)
for m in discovered
]
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error discovering models for {provider}: {str(e)}")
raise HTTPException(
status_code=500, detail="Error discovering models. Check server logs for details."
)
@router.post("/models/sync/{provider}", response_model=ProviderSyncResponse)
async def sync_models(provider: str):
"""
Sync models for a specific provider.
Discovers available models from the provider's API and registers
any new models in the database. Existing models are skipped.
Returns counts of discovered, new, and existing models.
"""
try:
# Provision DB-stored credentials into env vars before discovery
await provision_provider_keys(provider)
discovered, new, existing = await sync_provider_models(
provider, auto_register=True
)
return ProviderSyncResponse(
provider=provider,
discovered=discovered,
new=new,
existing=existing,
)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error syncing models for {provider}: {str(e)}")
raise HTTPException(status_code=500, detail="Error syncing models. Check server logs for details.")
@router.post("/models/sync", response_model=AllProvidersSyncResponse)
async def sync_all_models():
"""
Sync models for all configured providers.
Discovers and registers models from all providers that have
valid API keys configured. This is useful for initial setup
or periodic refresh of available models.
"""
try:
results = await sync_all_providers()
response_results = {}
total_discovered = 0
total_new = 0
for provider, (discovered, new, existing) in results.items():
response_results[provider] = ProviderSyncResponse(
provider=provider,
discovered=discovered,
new=new,
existing=existing,
)
total_discovered += discovered
total_new += new
return AllProvidersSyncResponse(
results=response_results,
total_discovered=total_discovered,
total_new=total_new,
)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error syncing all models: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error syncing all models: {str(e)}"
)
@router.get("/models/count/{provider}", response_model=ProviderModelCountResponse)
async def get_model_count(provider: str):
"""
Get count of registered models for a provider, grouped by type.
Returns counts for each model type (language, embedding,
speech_to_text, text_to_speech) as well as total count.
"""
try:
counts = await get_provider_model_count(provider)
total = sum(counts.values())
return ProviderModelCountResponse(
provider=provider,
counts=counts,
total=total,
)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error getting model count for {provider}: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error getting model count: {str(e)}"
)
@router.get("/models/by-provider/{provider}", response_model=List[ModelResponse])
async def get_models_by_provider(provider: str):
"""
Get all registered models for a specific provider.
Returns models from the database that belong to the specified provider.
"""
try:
from open_notebook.database.repository import repo_query
models = await repo_query(
"SELECT * FROM model WHERE provider = $provider ORDER BY type, name",
{"provider": provider},
)
return [
ModelResponse(
id=model.get("id", ""),
name=model.get("name", ""),
provider=model.get("provider", ""),
type=model.get("type", ""),
credential=model.get("credential"),
created=str(model.get("created", "")),
updated=str(model.get("updated", "")),
)
for model in models
]
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error fetching models for {provider}: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error fetching models: {str(e)}"
)
def _get_preferred_model(
models: List[Dict], provider_priority: List[str], model_preferences: Dict
) -> Optional[Dict]:
"""
Select the best model from a list based on provider priority and model preferences.
Args:
models: List of model dictionaries with 'provider', 'name', 'id' keys
provider_priority: List of providers in preference order
model_preferences: Dict mapping provider to list of preferred model name patterns
Returns:
The best model dict, or None if no models available
"""
if not models:
return None
# Group models by provider
by_provider: Dict[str, List[Dict]] = {}
for model in models:
provider = model.get("provider", "")
if provider not in by_provider:
by_provider[provider] = []
by_provider[provider].append(model)
# Find first provider with models (in priority order)
for provider in provider_priority:
if provider in by_provider:
provider_models = by_provider[provider]
# Check for preferred models within this provider
if provider in model_preferences:
for preference in model_preferences[provider]:
for model in provider_models:
if preference.lower() in model.get("name", "").lower():
return model
# Fall back to first model from this provider
return provider_models[0]
# Fall back to first model from any provider
return models[0] if models else None
@router.post("/models/auto-assign", response_model=AutoAssignResult)
async def auto_assign_defaults():
"""
Auto-assign default models based on available models.
This endpoint intelligently assigns the first available model of each
required type to the corresponding default slot. It uses provider
priority (preferring premium providers like OpenAI, Anthropic) and
model preferences within each provider.
Returns:
- assigned: Dict of slot names to assigned model IDs
- skipped: List of slots that already have models assigned
- missing: List of slots with no available models
"""
try:
from open_notebook.database.repository import repo_query
# Get current defaults
defaults = await DefaultModels.get_instance()
# Get all models grouped by type
all_models = await repo_query(
"SELECT * FROM model ORDER BY provider, name",
{},
)
# Group models by type
models_by_type: Dict[str, List[Dict]] = {
"language": [],
"embedding": [],
"text_to_speech": [],
"speech_to_text": [],
}
for model in all_models:
model_type = model.get("type", "")
if model_type in models_by_type:
models_by_type[model_type].append(model)
# Define slot configuration: (slot_name, model_type, current_value)
slot_configs = [
("default_chat_model", "language", defaults.default_chat_model), # type: ignore[attr-defined]
("default_transformation_model", "language", defaults.default_transformation_model), # type: ignore[attr-defined]
("default_tools_model", "language", defaults.default_tools_model), # type: ignore[attr-defined]
("large_context_model", "language", defaults.large_context_model), # type: ignore[attr-defined]
("default_embedding_model", "embedding", defaults.default_embedding_model), # type: ignore[attr-defined]
("default_text_to_speech_model", "text_to_speech", defaults.default_text_to_speech_model), # type: ignore[attr-defined]
("default_speech_to_text_model", "speech_to_text", defaults.default_speech_to_text_model), # type: ignore[attr-defined]
]
assigned: Dict[str, str] = {}
skipped: List[str] = []
missing: List[str] = []
for slot_name, model_type, current_value in slot_configs:
if current_value:
# Slot already has a value
skipped.append(slot_name)
continue
available_models = models_by_type.get(model_type, [])
if not available_models:
# No models of this type available
missing.append(slot_name)
continue
# Select best model for this slot
best_model = _get_preferred_model(
available_models, PROVIDER_PRIORITY, MODEL_PREFERENCES
)
if best_model:
model_id = best_model.get("id", "")
assigned[slot_name] = model_id
# Update the defaults object
setattr(defaults, slot_name, model_id)
# Save updated defaults if any assignments were made
if assigned:
await defaults.update()
return AutoAssignResult(
assigned=assigned,
skipped=skipped,
missing=missing,
)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error auto-assigning defaults: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error auto-assigning defaults: {str(e)}"
)
+459
View File
@@ -0,0 +1,459 @@
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query
from loguru import logger
from api.models import (
NotebookCreate,
NotebookDeletePreview,
NotebookDeleteResponse,
NotebookResponse,
NotebookUpdate,
RecentlyViewedResponse,
)
from open_notebook.database.repository import ensure_record_id, repo_query
from open_notebook.domain.notebook import Notebook, Source
from open_notebook.exceptions import (
InvalidInputError,
NotFoundError,
OpenNotebookError,
)
router = APIRouter()
def _last_viewed_sort_key(item: RecentlyViewedResponse) -> str:
return item.last_viewed_at
async def _stamp_notebook_view(notebook_id: str) -> None:
# Best-effort write-on-read: recording the view timestamp must never turn a
# successful read into a 500. Log and move on if the stamp update fails.
try:
await repo_query(
"UPDATE $notebook_id SET last_viewed_at = time::now();",
{"notebook_id": ensure_record_id(notebook_id)},
)
except Exception as e:
logger.warning(
f"Failed to stamp last_viewed_at for notebook {notebook_id}: {e}"
)
def _recently_viewed_notebook(row: dict) -> RecentlyViewedResponse:
return RecentlyViewedResponse(
type="notebook",
id=str(row.get("id", "")),
title=row.get("title") or row.get("name") or "Untitled notebook",
last_viewed_at=str(row.get("last_viewed_at", "")),
)
def _recently_viewed_source(row: dict) -> RecentlyViewedResponse:
return RecentlyViewedResponse(
type="source",
id=str(row.get("id", "")),
title=row.get("title") or "Untitled source",
last_viewed_at=str(row.get("last_viewed_at", "")),
)
@router.get("/notebooks", response_model=List[NotebookResponse])
async def get_notebooks(
archived: Optional[bool] = Query(None, description="Filter by archived status"),
order_by: str = Query("updated desc", description="Order by field and direction"),
):
"""Get all notebooks with optional filtering and ordering."""
try:
# Validate order_by against allowlist to prevent SurrealQL injection
allowed_fields = {"name", "created", "updated"}
allowed_directions = {"asc", "desc"}
parts = order_by.strip().lower().split()
if len(parts) == 1:
if parts[0] not in allowed_fields:
raise HTTPException(
status_code=400,
detail=f"Invalid order_by field: '{order_by}'. Allowed fields: {', '.join(sorted(allowed_fields))}",
)
validated_order_by = parts[0]
elif len(parts) == 2:
if parts[0] not in allowed_fields or parts[1] not in allowed_directions:
raise HTTPException(
status_code=400,
detail=f"Invalid order_by: '{order_by}'. Allowed fields: {', '.join(sorted(allowed_fields))}. Allowed directions: asc, desc",
)
validated_order_by = f"{parts[0]} {parts[1]}"
else:
raise HTTPException(
status_code=400,
detail=f"Invalid order_by format: '{order_by}'. Expected 'field' or 'field direction'",
)
# Build the query with counts
query = f"""
SELECT *,
count(<-reference.in) as source_count,
count(<-artifact.in) as note_count
FROM notebook
ORDER BY {validated_order_by}
"""
result = await repo_query(query)
# Filter by archived status if specified
if archived is not None:
result = [nb for nb in result if nb.get("archived") == archived]
return [
NotebookResponse(
id=str(nb.get("id", "")),
name=nb.get("name", ""),
description=nb.get("description", ""),
archived=nb.get("archived", False),
created=str(nb.get("created", "")),
updated=str(nb.get("updated", "")),
source_count=nb.get("source_count", 0),
note_count=nb.get("note_count", 0),
)
for nb in result
]
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error fetching notebooks: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error fetching notebooks: {str(e)}"
)
@router.post("/notebooks", response_model=NotebookResponse)
async def create_notebook(notebook: NotebookCreate):
"""Create a new notebook."""
try:
new_notebook = Notebook(
name=notebook.name,
description=notebook.description,
)
await new_notebook.save()
return NotebookResponse(
id=new_notebook.id or "",
name=new_notebook.name,
description=new_notebook.description,
archived=new_notebook.archived or False,
created=str(new_notebook.created),
updated=str(new_notebook.updated),
source_count=0, # New notebook has no sources
note_count=0, # New notebook has no notes
)
except InvalidInputError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error creating notebook: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error creating notebook: {str(e)}"
)
@router.get("/recently-viewed", response_model=List[RecentlyViewedResponse])
async def get_recently_viewed(
limit: int = Query(12, ge=1, le=50, description="Number of items to return"),
):
"""Get recently viewed notebooks and sources, newest first."""
try:
notebooks = await repo_query(
"""
SELECT id, name AS title, last_viewed_at
FROM notebook
WHERE last_viewed_at != NONE AND last_viewed_at != NULL
ORDER BY last_viewed_at DESC
LIMIT $limit
""",
{"limit": limit},
)
sources = await repo_query(
"""
SELECT id, title, last_viewed_at
FROM source
WHERE last_viewed_at != NONE AND last_viewed_at != NULL
ORDER BY last_viewed_at DESC
LIMIT $limit
""",
{"limit": limit},
)
items = [
*[_recently_viewed_notebook(nb) for nb in notebooks],
*[_recently_viewed_source(src) for src in sources],
]
items.sort(key=_last_viewed_sort_key, reverse=True)
return items[:limit]
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
# Log full context server-side; return a generic message so internal
# details are not leaked to clients.
logger.exception(f"Error fetching recently viewed items: {e}")
raise HTTPException(
status_code=500, detail="Error fetching recently viewed items"
)
@router.get(
"/notebooks/{notebook_id}/delete-preview", response_model=NotebookDeletePreview
)
async def get_notebook_delete_preview(notebook_id: str):
"""Get a preview of what will be deleted when this notebook is deleted."""
try:
notebook = await Notebook.get(notebook_id)
preview = await notebook.get_delete_preview()
return NotebookDeletePreview(
notebook_id=str(notebook.id),
notebook_name=notebook.name,
note_count=preview["note_count"],
exclusive_source_count=preview["exclusive_source_count"],
shared_source_count=preview["shared_source_count"],
)
except HTTPException:
raise
except NotFoundError:
raise HTTPException(status_code=404, detail="Notebook not found")
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error getting delete preview for notebook {notebook_id}: {e}")
raise HTTPException(
status_code=500,
detail=f"Error fetching notebook deletion preview: {str(e)}",
)
@router.get("/notebooks/{notebook_id}", response_model=NotebookResponse)
async def get_notebook(notebook_id: str):
"""Get a specific notebook by ID."""
try:
# Query with counts for single notebook
query = """
SELECT *,
count(<-reference.in) as source_count,
count(<-artifact.in) as note_count
FROM $notebook_id
"""
result = await repo_query(query, {"notebook_id": ensure_record_id(notebook_id)})
if not result:
raise HTTPException(status_code=404, detail="Notebook not found")
await _stamp_notebook_view(notebook_id)
nb = result[0]
return NotebookResponse(
id=str(nb.get("id", "")),
name=nb.get("name", ""),
description=nb.get("description", ""),
archived=nb.get("archived", False),
created=str(nb.get("created", "")),
updated=str(nb.get("updated", "")),
source_count=nb.get("source_count", 0),
note_count=nb.get("note_count", 0),
)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error fetching notebook {notebook_id}: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error fetching notebook: {str(e)}"
)
@router.put("/notebooks/{notebook_id}", response_model=NotebookResponse)
async def update_notebook(notebook_id: str, notebook_update: NotebookUpdate):
"""Update a notebook."""
try:
notebook = await Notebook.get(notebook_id)
# Update only provided fields
if notebook_update.name is not None:
notebook.name = notebook_update.name
if notebook_update.description is not None:
notebook.description = notebook_update.description
if notebook_update.archived is not None:
notebook.archived = notebook_update.archived
await notebook.save()
# Query with counts after update
query = """
SELECT *,
count(<-reference.in) as source_count,
count(<-artifact.in) as note_count
FROM $notebook_id
"""
result = await repo_query(query, {"notebook_id": ensure_record_id(notebook_id)})
if result:
nb = result[0]
return NotebookResponse(
id=str(nb.get("id", "")),
name=nb.get("name", ""),
description=nb.get("description", ""),
archived=nb.get("archived", False),
created=str(nb.get("created", "")),
updated=str(nb.get("updated", "")),
source_count=nb.get("source_count", 0),
note_count=nb.get("note_count", 0),
)
# Fallback if query fails
return NotebookResponse(
id=notebook.id or "",
name=notebook.name,
description=notebook.description,
archived=notebook.archived or False,
created=str(notebook.created),
updated=str(notebook.updated),
source_count=0,
note_count=0,
)
except HTTPException:
raise
except NotFoundError:
raise HTTPException(status_code=404, detail="Notebook not found")
except InvalidInputError as e:
raise HTTPException(status_code=400, detail=str(e))
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error updating notebook {notebook_id}: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error updating notebook: {str(e)}"
)
@router.post("/notebooks/{notebook_id}/sources/{source_id}")
async def add_source_to_notebook(notebook_id: str, source_id: str):
"""Add an existing source to a notebook (create the reference)."""
try:
# Verify the notebook and source exist (raises NotFoundError -> 404)
await Notebook.get(notebook_id)
await Source.get(source_id)
# Check if reference already exists (idempotency)
existing_ref = await repo_query(
"SELECT * FROM reference WHERE out = $source_id AND in = $notebook_id",
{
"notebook_id": ensure_record_id(notebook_id),
"source_id": ensure_record_id(source_id),
},
)
# If reference doesn't exist, create it
if not existing_ref:
await repo_query(
"RELATE $source_id->reference->$notebook_id",
{
"notebook_id": ensure_record_id(notebook_id),
"source_id": ensure_record_id(source_id),
},
)
return {"message": "Source linked to notebook successfully"}
except HTTPException:
raise
except NotFoundError:
raise HTTPException(status_code=404, detail="Notebook or source not found")
except OpenNotebookError:
raise
except Exception as e:
logger.error(
f"Error linking source {source_id} to notebook {notebook_id}: {str(e)}"
)
raise HTTPException(
status_code=500, detail=f"Error linking source to notebook: {str(e)}"
)
@router.delete("/notebooks/{notebook_id}/sources/{source_id}")
async def remove_source_from_notebook(notebook_id: str, source_id: str):
"""Remove a source from a notebook (delete the reference)."""
try:
# Verify the notebook exists (raises NotFoundError -> 404)
await Notebook.get(notebook_id)
# Delete the reference record linking source to notebook
await repo_query(
"DELETE FROM reference WHERE out = $notebook_id AND in = $source_id",
{
"notebook_id": ensure_record_id(notebook_id),
"source_id": ensure_record_id(source_id),
},
)
return {"message": "Source removed from notebook successfully"}
except HTTPException:
raise
except NotFoundError:
raise HTTPException(status_code=404, detail="Notebook not found")
except OpenNotebookError:
raise
except Exception as e:
logger.error(
f"Error removing source {source_id} from notebook {notebook_id}: {str(e)}"
)
raise HTTPException(
status_code=500, detail=f"Error removing source from notebook: {str(e)}"
)
@router.delete("/notebooks/{notebook_id}", response_model=NotebookDeleteResponse)
async def delete_notebook(
notebook_id: str,
delete_exclusive_sources: bool = Query(
False,
description="Whether to delete sources that belong only to this notebook",
),
):
"""
Delete a notebook with cascade deletion.
Always deletes all notes associated with the notebook.
If delete_exclusive_sources is True, also deletes sources that belong only
to this notebook (not linked to any other notebooks).
"""
try:
notebook = await Notebook.get(notebook_id)
result = await notebook.delete(
delete_exclusive_sources=delete_exclusive_sources
)
return NotebookDeleteResponse(
message="Notebook deleted successfully",
deleted_notes=result["deleted_notes"],
deleted_sources=result["deleted_sources"],
unlinked_sources=result["unlinked_sources"],
)
except HTTPException:
raise
except NotFoundError:
raise HTTPException(status_code=404, detail="Notebook not found")
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error deleting notebook {notebook_id}: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error deleting notebook: {str(e)}"
)
+206
View File
@@ -0,0 +1,206 @@
from typing import List, Literal, Optional
from fastapi import APIRouter, HTTPException, Query
from loguru import logger
from api.models import NoteCreate, NoteResponse, NoteUpdate
from open_notebook.domain.notebook import Note
from open_notebook.exceptions import (
InvalidInputError,
NotFoundError,
OpenNotebookError,
)
router = APIRouter()
@router.get("/notes", response_model=List[NoteResponse])
async def get_notes(
notebook_id: Optional[str] = Query(None, description="Filter by notebook ID"),
):
"""Get all notes with optional notebook filtering."""
try:
if notebook_id:
# Get notes for a specific notebook
from open_notebook.domain.notebook import Notebook
notebook = await Notebook.get(notebook_id)
notes = await notebook.get_notes()
else:
# Get all notes
notes = await Note.get_all(order_by="updated desc")
return [
NoteResponse(
id=note.id or "",
title=note.title,
content=note.content,
note_type=note.note_type,
created=str(note.created),
updated=str(note.updated),
)
for note in notes
]
except HTTPException:
raise
except NotFoundError:
raise HTTPException(status_code=404, detail="Notebook not found")
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error fetching notes: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error fetching notes: {str(e)}")
@router.post("/notes", response_model=NoteResponse)
async def create_note(note_data: NoteCreate):
"""Create a new note."""
try:
# Auto-generate title if not provided and it's an AI note
title = note_data.title
if not title and note_data.note_type == "ai" and note_data.content:
from open_notebook.graphs.prompt import graph as prompt_graph
prompt = "Based on the Note below, please provide a Title for this content, with max 15 words"
# LangGraph accepts a partial state dict at runtime, but its typed
# overloads require the full state type (langgraph typing limitation).
result = await prompt_graph.ainvoke( # type: ignore[call-overload]
{
"input_text": note_data.content,
"prompt": prompt,
}
)
title = result.get("output", "Untitled Note")
# Validate note_type
note_type: Optional[Literal["human", "ai"]] = None
if note_data.note_type in ("human", "ai"):
note_type = note_data.note_type # type: ignore[assignment]
elif note_data.note_type is not None:
raise HTTPException(
status_code=400, detail="note_type must be 'human' or 'ai'"
)
new_note = Note(
title=title,
content=note_data.content,
note_type=note_type,
)
command_id = await new_note.save()
# Add to notebook if specified
if note_data.notebook_id:
from open_notebook.domain.notebook import Notebook
# Verify the notebook exists (raises NotFoundError -> 404)
await Notebook.get(note_data.notebook_id)
await new_note.add_to_notebook(note_data.notebook_id)
return NoteResponse(
id=new_note.id or "",
title=new_note.title,
content=new_note.content,
note_type=new_note.note_type,
created=str(new_note.created),
updated=str(new_note.updated),
command_id=str(command_id) if command_id else None,
)
except HTTPException:
raise
except NotFoundError:
raise HTTPException(status_code=404, detail="Notebook not found")
except InvalidInputError as e:
raise HTTPException(status_code=400, detail=str(e))
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error creating note: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error creating note: {str(e)}")
@router.get("/notes/{note_id}", response_model=NoteResponse)
async def get_note(note_id: str):
"""Get a specific note by ID."""
try:
note = await Note.get(note_id)
return NoteResponse(
id=note.id or "",
title=note.title,
content=note.content,
note_type=note.note_type,
created=str(note.created),
updated=str(note.updated),
)
except HTTPException:
raise
except NotFoundError:
raise HTTPException(status_code=404, detail="Note not found")
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error fetching note {note_id}: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error fetching note: {str(e)}")
@router.put("/notes/{note_id}", response_model=NoteResponse)
async def update_note(note_id: str, note_update: NoteUpdate):
"""Update a note."""
try:
note = await Note.get(note_id)
# Update only provided fields
if note_update.title is not None:
note.title = note_update.title
if note_update.content is not None:
note.content = note_update.content
if note_update.note_type is not None:
if note_update.note_type in ("human", "ai"):
note.note_type = note_update.note_type # type: ignore[assignment]
else:
raise HTTPException(
status_code=400, detail="note_type must be 'human' or 'ai'"
)
command_id = await note.save()
return NoteResponse(
id=note.id or "",
title=note.title,
content=note.content,
note_type=note.note_type,
created=str(note.created),
updated=str(note.updated),
command_id=str(command_id) if command_id else None,
)
except HTTPException:
raise
except NotFoundError:
raise HTTPException(status_code=404, detail="Note not found")
except InvalidInputError as e:
raise HTTPException(status_code=400, detail=str(e))
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error updating note {note_id}: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error updating note: {str(e)}")
@router.delete("/notes/{note_id}")
async def delete_note(note_id: str):
"""Delete a note."""
try:
note = await Note.get(note_id)
await note.delete()
return {"message": "Note deleted successfully"}
except HTTPException:
raise
except NotFoundError:
raise HTTPException(status_code=404, detail="Note not found")
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error deleting note {note_id}: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error deleting note: {str(e)}")
+432
View File
@@ -0,0 +1,432 @@
from typing import List, Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from loguru import logger
from pydantic import BaseModel
from api.podcast_service import (
PodcastGenerationRequest,
PodcastGenerationResponse,
PodcastService,
)
from open_notebook.ai.models import Model
from open_notebook.exceptions import OpenNotebookError
from open_notebook.podcasts.audio_paths import resolve_contained_audio_path
from open_notebook.podcasts.models import PodcastEpisode
router = APIRouter()
# Model reference fields stored in the denormalized profile snapshots on an
# episode, mapped to the resolved display fields the frontend renders
# ("provider / name" rows in EpisodeCard). Mirrors the speaker_config ->
# speaker_config_name precedent in api/routers/episode_profiles.py.
_EPISODE_PROFILE_MODEL_FIELDS = {
"outline_llm": ("outline_model_provider", "outline_model_name"),
"transcript_llm": ("transcript_model_provider", "transcript_model_name"),
}
_SPEAKER_PROFILE_MODEL_FIELDS = {
"voice_model": ("voice_model_provider", "voice_model_name"),
}
def _collect_snapshot_model_ids(episodes: List[PodcastEpisode]) -> List[str]:
"""Collect the distinct model record IDs referenced by episode snapshots."""
ids = set()
for episode in episodes:
for field in _EPISODE_PROFILE_MODEL_FIELDS:
ref = (episode.episode_profile or {}).get(field)
if ref:
ids.add(str(ref))
for field in _SPEAKER_PROFILE_MODEL_FIELDS:
ref = (episode.speaker_profile or {}).get(field)
if ref:
ids.add(str(ref))
return sorted(ids)
def _with_resolved_model_fields(
snapshot: dict,
field_map: dict,
models_by_id: dict,
) -> dict:
"""Return a copy of a profile snapshot with resolved model display fields.
Only sets the display fields when the reference resolves; unresolvable
references (deleted model) and legacy snapshots without references are
left untouched so the frontend can fall back to the historical
provider/model strings, then to a placeholder.
"""
enriched = dict(snapshot or {})
for ref_field, (provider_field, name_field) in field_map.items():
ref = enriched.get(ref_field)
info = models_by_id.get(str(ref)) if ref else None
if info:
enriched[provider_field] = info["provider"]
enriched[name_field] = info["name"]
return enriched
async def _resolve_snapshot_models(
episodes: List[PodcastEpisode],
) -> dict:
"""Batch-resolve every model reference in the episodes' snapshots.
One query for the whole list (see Model.get_display_info_for_ids) - a
failure degrades to no resolved fields rather than failing the request.
"""
try:
return await Model.get_display_info_for_ids(
_collect_snapshot_model_ids(episodes)
)
except Exception as e:
logger.warning(f"Error batch-resolving snapshot model references: {str(e)}")
return {}
def _delete_episode_audio(episode: PodcastEpisode, episode_id: str) -> None:
"""Best-effort unlink of an episode's audio file, refusing invalid paths.
Shared by the delete and retry endpoints. Legacy/escaping audio_file
values (resolve_contained_audio_path -> None) are logged and skipped.
"""
if not episode.audio_file:
return
audio_path = resolve_contained_audio_path(episode.audio_file)
if audio_path is None:
logger.warning(
f"Refusing to delete audio file outside podcasts directory "
f"for episode {episode_id}: {episode.audio_file}"
)
elif audio_path.exists():
try:
audio_path.unlink()
logger.info(f"Deleted audio file: {audio_path}")
except Exception as e:
logger.warning(f"Failed to delete audio file {audio_path}: {e}")
class PodcastEpisodeResponse(BaseModel):
id: str
name: str
episode_profile: dict
speaker_profile: dict
briefing: str
audio_file: Optional[str] = None
audio_url: Optional[str] = None
transcript: Optional[dict] = None
outline: Optional[dict] = None
created: Optional[str] = None
job_status: Optional[str] = None
error_message: Optional[str] = None
@router.post("/podcasts/generate", response_model=PodcastGenerationResponse)
async def generate_podcast(request: PodcastGenerationRequest):
"""
Generate a podcast episode using Episode Profiles.
Returns immediately with job ID for status tracking.
"""
try:
job_id = await PodcastService.submit_generation_job(
episode_profile_name=request.episode_profile,
speaker_profile_name=request.speaker_profile,
episode_name=request.episode_name,
notebook_id=request.notebook_id,
content=request.content,
briefing_suffix=request.briefing_suffix,
)
return PodcastGenerationResponse(
job_id=job_id,
status="submitted",
message=f"Podcast generation started for episode '{request.episode_name}'",
episode_profile=request.episode_profile,
episode_name=request.episode_name,
)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error generating podcast: {str(e)}")
raise HTTPException(
status_code=500, detail="Failed to generate podcast"
)
@router.get("/podcasts/jobs/{job_id}")
async def get_podcast_job_status(job_id: str):
"""Get the status of a podcast generation job"""
try:
status_data = await PodcastService.get_job_status(job_id)
return status_data
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error fetching podcast job status: {str(e)}")
raise HTTPException(
status_code=500, detail="Failed to fetch job status"
)
@router.get("/podcasts/episodes", response_model=List[PodcastEpisodeResponse])
async def list_podcast_episodes():
"""List all podcast episodes"""
try:
episodes = await PodcastService.list_episodes()
# Batch-fetch job status for every episode with a command in one
# query instead of one round trip per episode (see
# PodcastEpisode.get_job_details_for_commands docstring).
try:
details_by_command = await PodcastEpisode.get_job_details_for_commands(
[episode.command for episode in episodes if episode.command]
)
except Exception as e:
logger.warning(f"Error batch-fetching podcast job statuses: {str(e)}")
details_by_command = {}
# Batch-resolve the snapshots' model references (outline_llm,
# transcript_llm, voice_model) to display fields in one query
# instead of one lookup per episode.
models_by_id = await _resolve_snapshot_models(episodes)
response_episodes = []
for episode in episodes:
# Skip incomplete episodes without command or audio
if not episode.command and not episode.audio_file:
continue
# Get job status and error message if available
job_status = None
error_message = None
if episode.command:
detail = details_by_command.get(str(episode.command))
if detail is not None:
job_status = detail["status"]
error_message = detail["error_message"]
else:
job_status = "unknown"
else:
# No command but has audio file = completed import
job_status = "completed"
audio_url = None
audio_path = resolve_contained_audio_path(episode.audio_file)
if audio_path is not None and audio_path.exists():
audio_url = f"/api/podcasts/episodes/{episode.id}/audio"
response_episodes.append(
PodcastEpisodeResponse(
id=str(episode.id),
name=episode.name,
episode_profile=_with_resolved_model_fields(
episode.episode_profile,
_EPISODE_PROFILE_MODEL_FIELDS,
models_by_id,
),
speaker_profile=_with_resolved_model_fields(
episode.speaker_profile,
_SPEAKER_PROFILE_MODEL_FIELDS,
models_by_id,
),
briefing=episode.briefing,
audio_file=episode.audio_file,
audio_url=audio_url,
transcript=episode.transcript,
outline=episode.outline,
created=str(episode.created) if episode.created else None,
job_status=job_status,
error_message=error_message,
)
)
return response_episodes
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error listing podcast episodes: {str(e)}")
raise HTTPException(
status_code=500, detail="Failed to list podcast episodes"
)
@router.get("/podcasts/episodes/{episode_id}", response_model=PodcastEpisodeResponse)
async def get_podcast_episode(episode_id: str):
"""Get a specific podcast episode"""
try:
episode = await PodcastService.get_episode(episode_id)
# Get job status and error message if available
job_status = None
error_message = None
if episode.command:
try:
detail = await episode.get_job_detail()
job_status = detail["status"]
error_message = detail["error_message"]
except Exception:
job_status = "unknown"
else:
# No command but has audio file = completed import
job_status = "completed" if episode.audio_file else "unknown"
audio_url = None
audio_path = resolve_contained_audio_path(episode.audio_file)
if audio_path is not None and audio_path.exists():
audio_url = f"/api/podcasts/episodes/{episode.id}/audio"
models_by_id = await _resolve_snapshot_models([episode])
return PodcastEpisodeResponse(
id=str(episode.id),
name=episode.name,
episode_profile=_with_resolved_model_fields(
episode.episode_profile,
_EPISODE_PROFILE_MODEL_FIELDS,
models_by_id,
),
speaker_profile=_with_resolved_model_fields(
episode.speaker_profile,
_SPEAKER_PROFILE_MODEL_FIELDS,
models_by_id,
),
briefing=episode.briefing,
audio_file=episode.audio_file,
audio_url=audio_url,
transcript=episode.transcript,
outline=episode.outline,
created=str(episode.created) if episode.created else None,
job_status=job_status,
error_message=error_message,
)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error fetching podcast episode: {str(e)}")
raise HTTPException(status_code=404, detail="Episode not found")
@router.get("/podcasts/episodes/{episode_id}/audio")
async def stream_podcast_episode_audio(episode_id: str):
"""Stream the audio file associated with a podcast episode"""
try:
episode = await PodcastService.get_episode(episode_id)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error fetching podcast episode for audio: {str(e)}")
raise HTTPException(status_code=404, detail="Episode not found")
if not episode.audio_file:
raise HTTPException(status_code=404, detail="Episode has no audio file")
audio_path = resolve_contained_audio_path(episode.audio_file)
if audio_path is None:
logger.warning(
f"Blocked audio access outside podcasts directory for episode "
f"{episode_id}: {episode.audio_file}"
)
raise HTTPException(status_code=403, detail="Access to file denied")
if not audio_path.exists():
raise HTTPException(status_code=404, detail="Audio file not found on disk")
return FileResponse(
audio_path,
media_type="audio/mpeg",
filename=audio_path.name,
)
@router.post("/podcasts/episodes/{episode_id}/retry")
async def retry_podcast_episode(episode_id: str):
"""Retry a failed podcast episode by deleting it and submitting a new job"""
try:
episode = await PodcastService.get_episode(episode_id)
# Validate episode is in a failed state
detail = await episode.get_job_detail()
if detail["status"] not in ("failed", "error"):
raise HTTPException(
status_code=400,
detail=f"Episode is not in a failed state (current: {detail['status']})",
)
# Extract params for re-submission
ep_profile_name = episode.episode_profile.get("name")
sp_profile_name = episode.speaker_profile.get("name")
episode_name = episode.name
content = episode.content
if not ep_profile_name or not sp_profile_name:
raise HTTPException(
status_code=400,
detail="Cannot retry: episode or speaker profile name missing from stored data",
)
# Delete audio file if any
_delete_episode_audio(episode, episode_id)
# Delete the failed episode
await episode.delete()
# Submit a new job
job_id = await PodcastService.submit_generation_job(
episode_profile_name=ep_profile_name,
speaker_profile_name=sp_profile_name,
episode_name=episode_name,
content=content,
)
return {"job_id": job_id, "message": "Retry submitted successfully"}
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error retrying podcast episode: {str(e)}")
raise HTTPException(
status_code=500, detail="Failed to retry episode"
)
@router.delete("/podcasts/episodes/{episode_id}")
async def delete_podcast_episode(episode_id: str):
"""Delete a podcast episode and its associated audio file"""
try:
# Get the episode first to check if it exists and get the audio file path
episode = await PodcastService.get_episode(episode_id)
# Delete the physical audio file if it exists
_delete_episode_audio(episode, episode_id)
# Delete the episode from the database
await episode.delete()
logger.info(f"Deleted podcast episode: {episode_id}")
return {"message": "Episode deleted successfully", "episode_id": episode_id}
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error deleting podcast episode: {str(e)}")
raise HTTPException(
status_code=500, detail="Failed to delete episode"
)
+35
View File
@@ -0,0 +1,35 @@
"""
Providers Router
Exposes the provider registry (open_notebook/ai/provider_registry.py) so
clients can enumerate supported providers and their metadata instead of
keeping their own copies.
Endpoints:
- GET /providers - List all supported providers with metadata
"""
from typing import List
from fastapi import APIRouter
from api.credentials_service import check_env_configured
from api.models import ProviderInfoResponse
from open_notebook.ai.provider_registry import PROVIDERS
router = APIRouter(prefix="/providers", tags=["providers"])
@router.get("", response_model=List[ProviderInfoResponse])
async def list_providers():
"""List all supported AI providers with their registry metadata."""
return [
ProviderInfoResponse(
name=spec.name,
display_name=spec.display_name,
modalities=list(spec.modalities),
docs_url=spec.docs_url,
env_configured=check_env_configured(spec.name),
)
for spec in PROVIDERS.values()
]
+238
View File
@@ -0,0 +1,238 @@
import json
from typing import AsyncGenerator
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from loguru import logger
from api.models import AskRequest, AskResponse, SearchRequest, SearchResponse
from open_notebook.ai.models import Model, model_manager
from open_notebook.domain.notebook import text_search, vector_search
from open_notebook.exceptions import (
DatabaseOperationError,
InvalidInputError,
OpenNotebookError,
)
from open_notebook.graphs.ask import graph as ask_graph
router = APIRouter()
@router.post("/search", response_model=SearchResponse)
async def search_knowledge_base(search_request: SearchRequest):
"""Search the knowledge base using text or vector search."""
try:
if search_request.type == "vector":
# Check if embedding model is available for vector search
if not await model_manager.get_embedding_model():
raise HTTPException(
status_code=400,
detail="Vector search requires an embedding model. Please configure one in the Models section.",
)
results = await vector_search(
keyword=search_request.query,
results=search_request.limit,
source=search_request.search_sources,
note=search_request.search_notes,
minimum_score=search_request.minimum_score,
)
else:
# Text search
results = await text_search(
keyword=search_request.query,
results=search_request.limit,
source=search_request.search_sources,
note=search_request.search_notes,
)
return SearchResponse(
results=results or [],
total_count=len(results) if results else 0,
search_type=search_request.type,
)
except InvalidInputError as e:
raise HTTPException(status_code=400, detail=str(e))
except DatabaseOperationError as e:
logger.error(f"Database error during search: {str(e)}")
raise HTTPException(status_code=500, detail=f"Search failed: {str(e)}")
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Unexpected error during search: {str(e)}")
raise HTTPException(status_code=500, detail=f"Search failed: {str(e)}")
async def stream_ask_response(
question: str, strategy_model: Model, answer_model: Model, final_answer_model: Model
) -> AsyncGenerator[str, None]:
"""Stream the ask response as Server-Sent Events."""
try:
final_answer = None
# LangGraph accepts a partial state dict at runtime, but its typed
# overloads require the full state type (langgraph typing limitation).
async for chunk in ask_graph.astream( # type: ignore[call-overload]
input=dict(question=question),
config=dict(
configurable=dict(
strategy_model=strategy_model.id,
answer_model=answer_model.id,
final_answer_model=final_answer_model.id,
)
),
stream_mode="updates",
):
if "agent" in chunk:
strategy_data = {
"type": "strategy",
"reasoning": chunk["agent"]["strategy"].reasoning,
"searches": [
{"term": search.term, "instructions": search.instructions}
for search in chunk["agent"]["strategy"].searches
],
}
yield f"data: {json.dumps(strategy_data)}\n\n"
elif "provide_answer" in chunk:
for answer in chunk["provide_answer"]["answers"]:
answer_data = {"type": "answer", "content": answer}
yield f"data: {json.dumps(answer_data)}\n\n"
elif "write_final_answer" in chunk:
final_answer = chunk["write_final_answer"]["final_answer"]
final_data = {"type": "final_answer", "content": final_answer}
yield f"data: {json.dumps(final_data)}\n\n"
# Send completion signal
completion_data = {"type": "complete", "final_answer": final_answer}
yield f"data: {json.dumps(completion_data)}\n\n"
except Exception as e:
from open_notebook.utils.error_classifier import classify_error
_, user_message = classify_error(e)
logger.error(f"Error in ask streaming: {str(e)}")
error_data = {"type": "error", "message": user_message}
yield f"data: {json.dumps(error_data)}\n\n"
@router.post("/search/ask")
async def ask_knowledge_base(ask_request: AskRequest):
"""Ask the knowledge base a question using AI models."""
try:
# Validate models exist
strategy_model = await Model.get(ask_request.strategy_model)
answer_model = await Model.get(ask_request.answer_model)
final_answer_model = await Model.get(ask_request.final_answer_model)
if not strategy_model:
raise HTTPException(
status_code=400,
detail=f"Strategy model {ask_request.strategy_model} not found",
)
if not answer_model:
raise HTTPException(
status_code=400,
detail=f"Answer model {ask_request.answer_model} not found",
)
if not final_answer_model:
raise HTTPException(
status_code=400,
detail=f"Final answer model {ask_request.final_answer_model} not found",
)
# Check if embedding model is available
if not await model_manager.get_embedding_model():
raise HTTPException(
status_code=400,
detail="Ask feature requires an embedding model. Please configure one in the Models section.",
)
# For streaming response
return StreamingResponse(
stream_ask_response(
ask_request.question, strategy_model, answer_model, final_answer_model
),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error in ask endpoint: {str(e)}")
raise HTTPException(status_code=500, detail=f"Ask operation failed: {str(e)}")
@router.post("/search/ask/simple", response_model=AskResponse)
async def ask_knowledge_base_simple(ask_request: AskRequest):
"""Ask the knowledge base a question and return a simple response (non-streaming)."""
try:
# Validate models exist
strategy_model = await Model.get(ask_request.strategy_model)
answer_model = await Model.get(ask_request.answer_model)
final_answer_model = await Model.get(ask_request.final_answer_model)
if not strategy_model:
raise HTTPException(
status_code=400,
detail=f"Strategy model {ask_request.strategy_model} not found",
)
if not answer_model:
raise HTTPException(
status_code=400,
detail=f"Answer model {ask_request.answer_model} not found",
)
if not final_answer_model:
raise HTTPException(
status_code=400,
detail=f"Final answer model {ask_request.final_answer_model} not found",
)
# Check if embedding model is available
if not await model_manager.get_embedding_model():
raise HTTPException(
status_code=400,
detail="Ask feature requires an embedding model. Please configure one in the Models section.",
)
# Run the ask graph and get final result
final_answer = None
# LangGraph accepts a partial state dict at runtime, but its typed
# overloads require the full state type (langgraph typing limitation).
async for chunk in ask_graph.astream( # type: ignore[call-overload]
input=dict(question=ask_request.question),
config=dict(
configurable=dict(
strategy_model=strategy_model.id,
answer_model=answer_model.id,
final_answer_model=final_answer_model.id,
)
),
stream_mode="updates",
):
if "write_final_answer" in chunk:
final_answer = chunk["write_final_answer"]["final_answer"]
if not final_answer:
raise HTTPException(status_code=500, detail="No answer generated")
return AskResponse(answer=final_answer, question=ask_request.question)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error in ask simple endpoint: {str(e)}")
raise HTTPException(status_code=500, detail=f"Ask operation failed: {str(e)}")
+101
View File
@@ -0,0 +1,101 @@
from fastapi import APIRouter, HTTPException
from loguru import logger
from api.models import SettingsResponse, SettingsUpdate
from open_notebook.domain.content_settings import ContentSettings
from open_notebook.exceptions import (
InvalidInputError,
OpenNotebookError,
)
router = APIRouter()
@router.get("/settings", response_model=SettingsResponse)
async def get_settings():
"""Get all application settings."""
try:
settings: ContentSettings = await ContentSettings.get_instance() # type: ignore[assignment]
return SettingsResponse(
default_content_processing_engine_doc=settings.default_content_processing_engine_doc,
default_content_processing_engine_url=settings.default_content_processing_engine_url,
default_embedding_option=settings.default_embedding_option,
auto_delete_files=settings.auto_delete_files,
docling_ocr=settings.docling_ocr,
youtube_preferred_languages=settings.youtube_preferred_languages,
)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error fetching settings: {str(e)}")
raise HTTPException(
status_code=500, detail="Error fetching settings"
)
@router.put("/settings", response_model=SettingsResponse)
async def update_settings(settings_update: SettingsUpdate):
"""Update application settings."""
try:
settings: ContentSettings = await ContentSettings.get_instance() # type: ignore[assignment]
# Update only provided fields
if settings_update.default_content_processing_engine_doc is not None:
# Cast to proper literal type
from typing import Literal, cast
settings.default_content_processing_engine_doc = cast(
Literal["auto", "docling", "simple"],
settings_update.default_content_processing_engine_doc,
)
if settings_update.default_content_processing_engine_url is not None:
from typing import Literal, cast
settings.default_content_processing_engine_url = cast(
Literal["auto", "firecrawl", "jina", "crawl4ai", "simple"],
settings_update.default_content_processing_engine_url,
)
if settings_update.default_embedding_option is not None:
from typing import Literal, cast
settings.default_embedding_option = cast(
Literal["ask", "always", "never"],
settings_update.default_embedding_option,
)
if settings_update.auto_delete_files is not None:
from typing import Literal, cast
settings.auto_delete_files = cast(
Literal["yes", "no"], settings_update.auto_delete_files
)
if settings_update.docling_ocr is not None:
settings.docling_ocr = settings_update.docling_ocr
if settings_update.youtube_preferred_languages is not None:
settings.youtube_preferred_languages = (
settings_update.youtube_preferred_languages
)
await settings.update()
return SettingsResponse(
default_content_processing_engine_doc=settings.default_content_processing_engine_doc,
default_content_processing_engine_url=settings.default_content_processing_engine_url,
default_embedding_option=settings.default_embedding_option,
auto_delete_files=settings.auto_delete_files,
docling_ocr=settings.docling_ocr,
youtube_preferred_languages=settings.youtube_preferred_languages,
)
except HTTPException:
raise
except InvalidInputError as e:
raise HTTPException(status_code=400, detail=str(e))
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error updating settings: {str(e)}")
raise HTTPException(
status_code=500, detail="Error updating settings"
)
+453
View File
@@ -0,0 +1,453 @@
import asyncio
import json
from typing import AsyncGenerator, List, Optional
from fastapi import APIRouter, HTTPException, Path
from fastapi.responses import StreamingResponse
from langchain_core.messages import HumanMessage
from langchain_core.runnables import RunnableConfig
from loguru import logger
from pydantic import BaseModel, Field
from api.routers._chat_shared import (
ChatMessage,
SuccessResponse,
extract_chat_messages,
get_source_or_404,
get_verified_source_session,
)
from open_notebook.database.repository import ensure_record_id, repo_query
from open_notebook.domain.notebook import ChatSession
from open_notebook.exceptions import (
NotFoundError,
OpenNotebookError,
)
from open_notebook.graphs.source_chat import source_chat_graph as source_chat_graph
from open_notebook.utils.graph_utils import get_session_message_count
router = APIRouter()
# Request/Response models
class CreateSourceChatSessionRequest(BaseModel):
source_id: str = Field(..., description="Source ID to create chat session for")
title: Optional[str] = Field(None, description="Optional session title")
model_override: Optional[str] = Field(
None, description="Optional model override for this session"
)
class UpdateSourceChatSessionRequest(BaseModel):
title: Optional[str] = Field(None, description="New session title")
model_override: Optional[str] = Field(
None, description="Model override for this session"
)
class ContextIndicator(BaseModel):
sources: List[str] = Field(
default_factory=list, description="Source IDs used in context"
)
insights: List[str] = Field(
default_factory=list, description="Insight IDs used in context"
)
notes: List[str] = Field(
default_factory=list, description="Note IDs used in context"
)
class SourceChatSessionResponse(BaseModel):
id: str = Field(..., description="Session ID")
title: str = Field(..., description="Session title")
source_id: str = Field(..., description="Source ID")
model_override: Optional[str] = Field(
None, description="Model override for this session"
)
created: str = Field(..., description="Creation timestamp")
updated: str = Field(..., description="Last update timestamp")
message_count: Optional[int] = Field(
None, description="Number of messages in session"
)
class SourceChatSessionWithMessagesResponse(SourceChatSessionResponse):
messages: List[ChatMessage] = Field(
default_factory=list, description="Session messages"
)
context_indicators: Optional[ContextIndicator] = Field(
None, description="Context indicators from last response"
)
class SendMessageRequest(BaseModel):
message: str = Field(..., description="User message content")
model_override: Optional[str] = Field(
None, description="Optional model override for this message"
)
@router.post(
"/sources/{source_id}/chat/sessions", response_model=SourceChatSessionResponse
)
async def create_source_chat_session(
request: CreateSourceChatSessionRequest,
source_id: str = Path(..., description="Source ID"),
):
"""Create a new chat session for a source."""
try:
# Verify source exists (normalizes the ID and 404s if missing)
full_source_id, _source = await get_source_or_404(source_id)
# Create new session with model_override support
session = ChatSession(
title=request.title or f"Source Chat {asyncio.get_event_loop().time():.0f}",
model_override=request.model_override,
)
await session.save()
# Relate session to source using "refers_to" relation
await session.relate("refers_to", full_source_id)
return SourceChatSessionResponse(
id=session.id or "",
title=session.title or "Untitled Session",
source_id=source_id,
model_override=session.model_override,
created=str(session.created),
updated=str(session.updated),
message_count=0,
)
except NotFoundError:
raise HTTPException(status_code=404, detail="Source not found")
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error creating source chat session: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error creating source chat session: {str(e)}"
)
@router.get(
"/sources/{source_id}/chat/sessions", response_model=List[SourceChatSessionResponse]
)
async def get_source_chat_sessions(source_id: str = Path(..., description="Source ID")):
"""Get all chat sessions for a source."""
try:
# Verify source exists (normalizes the ID and 404s if missing)
full_source_id, _source = await get_source_or_404(source_id)
# Get sessions that refer to this source - first get relations, then sessions
relations = await repo_query(
"SELECT in FROM refers_to WHERE out = $source_id",
{"source_id": ensure_record_id(full_source_id)},
)
sessions = []
for relation in relations:
session_id_raw = relation.get("in")
if session_id_raw:
session_id = str(session_id_raw)
session_result = await repo_query(
"SELECT * FROM $id", {"id": ensure_record_id(session_id)}
)
if session_result and len(session_result) > 0:
session_data = session_result[0]
# Get message count from LangGraph state
msg_count = await get_session_message_count(
source_chat_graph, session_id
)
sessions.append(
SourceChatSessionResponse(
id=session_data.get("id") or "",
title=session_data.get("title") or "Untitled Session",
source_id=source_id,
model_override=session_data.get("model_override"),
created=str(session_data.get("created")),
updated=str(session_data.get("updated")),
message_count=msg_count,
)
)
# Sort sessions by created date (newest first)
sessions.sort(key=lambda x: x.created, reverse=True)
return sessions
except NotFoundError:
raise HTTPException(status_code=404, detail="Source not found")
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error fetching source chat sessions: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error fetching source chat sessions: {str(e)}"
)
@router.get(
"/sources/{source_id}/chat/sessions/{session_id}",
response_model=SourceChatSessionWithMessagesResponse,
)
async def get_source_chat_session(
source_id: str = Path(..., description="Source ID"),
session_id: str = Path(..., description="Session ID"),
):
"""Get a specific source chat session with its messages."""
try:
# Verify source + session exist and are related (404s otherwise)
_full_source_id, _source, full_session_id, session = (
await get_verified_source_session(source_id, session_id)
)
# Get session state from LangGraph to retrieve messages
# Use sync get_state() in a thread since SqliteSaver doesn't support async
thread_state = await asyncio.to_thread(
source_chat_graph.get_state,
config=RunnableConfig(configurable={"thread_id": full_session_id}),
)
# Extract messages from state
messages: list[ChatMessage] = []
context_indicators = None
if thread_state and thread_state.values:
# Extract messages
if "messages" in thread_state.values:
messages = extract_chat_messages(thread_state.values["messages"])
# Extract context indicators from the last state
if "context_indicators" in thread_state.values:
context_data = thread_state.values["context_indicators"]
context_indicators = ContextIndicator(
sources=context_data.get("sources", []),
insights=context_data.get("insights", []),
notes=context_data.get("notes", []),
)
return SourceChatSessionWithMessagesResponse(
id=session.id or "",
title=session.title or "Untitled Session",
source_id=source_id,
model_override=getattr(session, "model_override", None),
created=str(session.created),
updated=str(session.updated),
message_count=len(messages),
messages=messages,
context_indicators=context_indicators,
)
except NotFoundError:
raise HTTPException(status_code=404, detail="Source or session not found")
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error fetching source chat session: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error fetching source chat session: {str(e)}"
)
@router.put(
"/sources/{source_id}/chat/sessions/{session_id}",
response_model=SourceChatSessionResponse,
)
async def update_source_chat_session(
request: UpdateSourceChatSessionRequest,
source_id: str = Path(..., description="Source ID"),
session_id: str = Path(..., description="Session ID"),
):
"""Update source chat session title and/or model override."""
try:
# Verify source + session exist and are related (404s otherwise)
_full_source_id, _source, full_session_id, session = (
await get_verified_source_session(source_id, session_id)
)
# Update session fields
if request.title is not None:
session.title = request.title
if request.model_override is not None:
session.model_override = request.model_override
await session.save()
# Get message count from LangGraph state
msg_count = await get_session_message_count(source_chat_graph, full_session_id)
return SourceChatSessionResponse(
id=session.id or "",
title=session.title or "Untitled Session",
source_id=source_id,
model_override=getattr(session, "model_override", None),
created=str(session.created),
updated=str(session.updated),
message_count=msg_count,
)
except NotFoundError:
raise HTTPException(status_code=404, detail="Source or session not found")
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error updating source chat session: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error updating source chat session: {str(e)}"
)
@router.delete(
"/sources/{source_id}/chat/sessions/{session_id}", response_model=SuccessResponse
)
async def delete_source_chat_session(
source_id: str = Path(..., description="Source ID"),
session_id: str = Path(..., description="Session ID"),
):
"""Delete a source chat session."""
try:
# Verify source + session exist and are related (404s otherwise)
_full_source_id, _source, full_session_id, session = (
await get_verified_source_session(source_id, session_id)
)
await session.delete()
return SuccessResponse(
success=True, message="Source chat session deleted successfully"
)
except NotFoundError:
raise HTTPException(status_code=404, detail="Source or session not found")
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error deleting source chat session: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error deleting source chat session: {str(e)}"
)
async def stream_source_chat_response(
session_id: str, source_id: str, message: str, model_override: Optional[str] = None
) -> AsyncGenerator[str, None]:
"""Stream the source chat response as Server-Sent Events."""
try:
# Get current state
# Use sync get_state() in a thread since SqliteSaver doesn't support async
current_state = await asyncio.to_thread(
source_chat_graph.get_state,
config=RunnableConfig(configurable={"thread_id": session_id}),
)
# Prepare state for execution
state_values = current_state.values if current_state else {}
state_values["messages"] = state_values.get("messages", [])
state_values["source_id"] = source_id
state_values["model_override"] = model_override
# Add user message to state
user_message = HumanMessage(content=message)
state_values["messages"].append(user_message)
# Send user message event
user_event = {"type": "user_message", "content": message, "timestamp": None}
yield f"data: {json.dumps(user_event)}\n\n"
# Run the synchronous LangGraph invoke in a thread so it doesn't block the
# event loop. While blocked, even the already-yielded SSE events can't
# flush and every other request stalls until the LLM finishes. Mirrors the
# get_state() calls above.
# The lambda pins down which `invoke` overload is used; asyncio.to_thread
# can't resolve overloaded callables on its own. The ignore is a langgraph
# typing limitation: it accepts a partial state dict at runtime, but the
# signature requires the full state type.
result = await asyncio.to_thread(
lambda: source_chat_graph.invoke(
input=state_values, # type: ignore[arg-type]
config=RunnableConfig(
configurable={"thread_id": session_id, "model_id": model_override}
),
)
)
# Stream the complete AI response
if "messages" in result:
for msg in result["messages"]:
if hasattr(msg, "type") and msg.type == "ai":
ai_event = {
"type": "ai_message",
"content": msg.content if hasattr(msg, "content") else str(msg),
"timestamp": None,
}
yield f"data: {json.dumps(ai_event)}\n\n"
# Stream context indicators
if "context_indicators" in result:
context_event = {
"type": "context_indicators",
"data": result["context_indicators"],
}
yield f"data: {json.dumps(context_event)}\n\n"
# Send completion signal
completion_event = {"type": "complete"}
yield f"data: {json.dumps(completion_event)}\n\n"
except Exception as e:
from open_notebook.utils.error_classifier import classify_error
_, error_message = classify_error(e)
logger.error(f"Error in source chat streaming: {str(e)}")
error_event = {"type": "error", "message": error_message}
yield f"data: {json.dumps(error_event)}\n\n"
@router.post("/sources/{source_id}/chat/sessions/{session_id}/messages")
async def send_message_to_source_chat(
request: SendMessageRequest,
source_id: str = Path(..., description="Source ID"),
session_id: str = Path(..., description="Session ID"),
):
"""Send a message to source chat session with SSE streaming response."""
try:
# Verify source + session exist and are related (404s otherwise)
full_source_id, _source, full_session_id, session = (
await get_verified_source_session(source_id, session_id)
)
if not request.message:
raise HTTPException(status_code=400, detail="Message content is required")
# Determine model override (request override takes precedence over session override)
model_override = request.model_override or getattr(
session, "model_override", None
)
# Update session timestamp
await session.save()
# Return streaming response
return StreamingResponse(
stream_source_chat_response(
session_id=full_session_id,
source_id=full_source_id,
message=request.message,
model_override=model_override,
),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error sending message to source chat: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error sending message: {str(e)}")
File diff suppressed because it is too large Load Diff
+191
View File
@@ -0,0 +1,191 @@
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException
from loguru import logger
from pydantic import BaseModel, Field
from open_notebook.exceptions import OpenNotebookError
from open_notebook.podcasts.models import SpeakerProfile
router = APIRouter()
class SpeakerProfileResponse(BaseModel):
id: str
name: str
description: str
voice_model: Optional[str] = None
speakers: List[Dict[str, Any]]
def _profile_to_response(profile: SpeakerProfile) -> SpeakerProfileResponse:
return SpeakerProfileResponse(
id=str(profile.id),
name=profile.name,
description=profile.description or "",
voice_model=profile.voice_model,
speakers=profile.speakers,
)
@router.get("/speaker-profiles", response_model=List[SpeakerProfileResponse])
async def list_speaker_profiles():
"""List all available speaker profiles"""
try:
profiles = await SpeakerProfile.get_all(order_by="name asc")
return [_profile_to_response(p) for p in profiles]
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Failed to fetch speaker profiles: {e}")
raise HTTPException(
status_code=500, detail="Failed to fetch speaker profiles"
)
@router.get("/speaker-profiles/{profile_name}", response_model=SpeakerProfileResponse)
async def get_speaker_profile(profile_name: str):
"""Get a specific speaker profile by name"""
try:
profile = await SpeakerProfile.get_by_name(profile_name)
if not profile:
raise HTTPException(
status_code=404, detail=f"Speaker profile '{profile_name}' not found"
)
return _profile_to_response(profile)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Failed to fetch speaker profile '{profile_name}': {e}")
raise HTTPException(
status_code=500, detail="Failed to fetch speaker profile"
)
class SpeakerProfileCreate(BaseModel):
name: str = Field(..., description="Unique profile name")
description: str = Field("", description="Profile description")
voice_model: Optional[str] = Field(None, description="Model record ID for TTS")
speakers: List[Dict[str, Any]] = Field(
..., description="Array of speaker configurations"
)
@router.post("/speaker-profiles", response_model=SpeakerProfileResponse)
async def create_speaker_profile(profile_data: SpeakerProfileCreate):
"""Create a new speaker profile"""
try:
profile = SpeakerProfile(
name=profile_data.name,
description=profile_data.description,
voice_model=profile_data.voice_model,
speakers=profile_data.speakers,
)
await profile.save()
return _profile_to_response(profile)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Failed to create speaker profile: {e}")
raise HTTPException(
status_code=500, detail="Failed to create speaker profile"
)
@router.put("/speaker-profiles/{profile_id}", response_model=SpeakerProfileResponse)
async def update_speaker_profile(profile_id: str, profile_data: SpeakerProfileCreate):
"""Update an existing speaker profile"""
try:
profile = await SpeakerProfile.get(profile_id)
if not profile:
raise HTTPException(
status_code=404, detail=f"Speaker profile '{profile_id}' not found"
)
for field, value in profile_data.model_dump(exclude_unset=True).items():
setattr(profile, field, value)
await profile.save()
return _profile_to_response(profile)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Failed to update speaker profile: {e}")
raise HTTPException(
status_code=500, detail="Failed to update speaker profile"
)
@router.delete("/speaker-profiles/{profile_id}")
async def delete_speaker_profile(profile_id: str):
"""Delete a speaker profile"""
try:
profile = await SpeakerProfile.get(profile_id)
if not profile:
raise HTTPException(
status_code=404, detail=f"Speaker profile '{profile_id}' not found"
)
await profile.delete()
return {"message": "Speaker profile deleted successfully"}
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Failed to delete speaker profile: {e}")
raise HTTPException(
status_code=500, detail="Failed to delete speaker profile"
)
@router.post(
"/speaker-profiles/{profile_id}/duplicate", response_model=SpeakerProfileResponse
)
async def duplicate_speaker_profile(profile_id: str):
"""Duplicate a speaker profile"""
try:
original = await SpeakerProfile.get(profile_id)
if not original:
raise HTTPException(
status_code=404, detail=f"Speaker profile '{profile_id}' not found"
)
duplicate = SpeakerProfile(
name=f"{original.name} - Copy",
description=original.description,
voice_model=original.voice_model,
speakers=original.speakers,
)
await duplicate.save()
return _profile_to_response(duplicate)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Failed to duplicate speaker profile: {e}")
raise HTTPException(
status_code=500, detail="Failed to duplicate speaker profile"
)
+273
View File
@@ -0,0 +1,273 @@
from typing import List
from fastapi import APIRouter, HTTPException
from loguru import logger
from api.models import (
DefaultPromptResponse,
DefaultPromptUpdate,
TransformationCreate,
TransformationExecuteRequest,
TransformationExecuteResponse,
TransformationResponse,
TransformationUpdate,
)
from open_notebook.ai.models import Model
from open_notebook.domain.transformation import DefaultPrompts, Transformation
from open_notebook.exceptions import InvalidInputError, OpenNotebookError
from open_notebook.graphs.transformation import graph as transformation_graph
router = APIRouter()
def _transformation_response(transformation: Transformation) -> TransformationResponse:
return TransformationResponse(
id=transformation.id or "",
name=transformation.name,
title=transformation.title,
description=transformation.description,
prompt=transformation.prompt,
apply_default=transformation.apply_default,
model_id=transformation.model_id,
created=str(transformation.created),
updated=str(transformation.updated),
)
@router.get("/transformations", response_model=List[TransformationResponse])
async def get_transformations():
"""Get all transformations."""
try:
transformations = await Transformation.get_all(order_by="name asc")
return [
_transformation_response(transformation)
for transformation in transformations
]
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error fetching transformations: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error fetching transformations: {str(e)}"
)
@router.post("/transformations", response_model=TransformationResponse)
async def create_transformation(transformation_data: TransformationCreate):
"""Create a new transformation."""
try:
# Reject unknown model references up front (same check as execute);
# otherwise an invalid model_id is stored and only fails at run time.
if transformation_data.model_id:
model = await Model.get(transformation_data.model_id)
if not model:
raise HTTPException(status_code=404, detail="Model not found")
new_transformation = Transformation(
name=transformation_data.name,
title=transformation_data.title,
description=transformation_data.description,
prompt=transformation_data.prompt,
apply_default=transformation_data.apply_default,
model_id=transformation_data.model_id,
)
await new_transformation.save()
return _transformation_response(new_transformation)
except HTTPException:
raise
except InvalidInputError as e:
raise HTTPException(status_code=400, detail=str(e))
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error creating transformation: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error creating transformation: {str(e)}"
)
@router.post("/transformations/execute", response_model=TransformationExecuteResponse)
async def execute_transformation(execute_request: TransformationExecuteRequest):
"""Execute a transformation on input text."""
try:
# Validate transformation exists
transformation = await Transformation.get(execute_request.transformation_id)
if not transformation:
raise HTTPException(status_code=404, detail="Transformation not found")
model_id = execute_request.model_id or transformation.model_id
# Validate explicit or transformation-specific model exists.
# None is allowed so the graph can use the configured transformation default.
if model_id:
model = await Model.get(model_id)
if not model:
raise HTTPException(status_code=404, detail="Model not found")
# Execute the transformation.
# LangGraph accepts a partial state dict at runtime, but its typed
# overloads require the full state type (langgraph typing limitation).
result = await transformation_graph.ainvoke( # type: ignore[call-overload]
dict(
input_text=execute_request.input_text,
transformation=transformation,
),
config=dict(configurable={"model_id": model_id}),
)
return TransformationExecuteResponse(
output=result["output"],
transformation_id=execute_request.transformation_id,
model_id=model_id,
)
except HTTPException:
raise
except OpenNotebookError:
raise # Let global exception handlers return proper status codes
except Exception as e:
logger.error(f"Error executing transformation: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error executing transformation: {str(e)}"
)
@router.get("/transformations/default-prompt", response_model=DefaultPromptResponse)
async def get_default_prompt():
"""Get the default transformation prompt."""
try:
default_prompts: DefaultPrompts = await DefaultPrompts.get_instance() # type: ignore[assignment]
return DefaultPromptResponse(
transformation_instructions=default_prompts.transformation_instructions
or ""
)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error fetching default prompt: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error fetching default prompt: {str(e)}"
)
@router.put("/transformations/default-prompt", response_model=DefaultPromptResponse)
async def update_default_prompt(prompt_update: DefaultPromptUpdate):
"""Update the default transformation prompt."""
try:
default_prompts: DefaultPrompts = await DefaultPrompts.get_instance() # type: ignore[assignment]
default_prompts.transformation_instructions = (
prompt_update.transformation_instructions
)
await default_prompts.update()
return DefaultPromptResponse(
transformation_instructions=default_prompts.transformation_instructions
)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error updating default prompt: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error updating default prompt: {str(e)}"
)
@router.get(
"/transformations/{transformation_id}", response_model=TransformationResponse
)
async def get_transformation(transformation_id: str):
"""Get a specific transformation by ID."""
try:
transformation = await Transformation.get(transformation_id)
if not transformation:
raise HTTPException(status_code=404, detail="Transformation not found")
return _transformation_response(transformation)
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error fetching transformation {transformation_id}: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error fetching transformation: {str(e)}"
)
@router.put(
"/transformations/{transformation_id}", response_model=TransformationResponse
)
async def update_transformation(
transformation_id: str, transformation_update: TransformationUpdate
):
"""Update a transformation."""
try:
transformation = await Transformation.get(transformation_id)
if not transformation:
raise HTTPException(status_code=404, detail="Transformation not found")
# Update only provided fields
if transformation_update.name is not None:
transformation.name = transformation_update.name
if transformation_update.title is not None:
transformation.title = transformation_update.title
if transformation_update.description is not None:
transformation.description = transformation_update.description
if transformation_update.prompt is not None:
transformation.prompt = transformation_update.prompt
if transformation_update.apply_default is not None:
transformation.apply_default = transformation_update.apply_default
if "model_id" in transformation_update.model_fields_set:
# Validate a newly supplied model reference (allow clearing to None).
if transformation_update.model_id:
model = await Model.get(transformation_update.model_id)
if not model:
raise HTTPException(status_code=404, detail="Model not found")
transformation.model_id = transformation_update.model_id
await transformation.save()
return _transformation_response(transformation)
except HTTPException:
raise
except InvalidInputError as e:
raise HTTPException(status_code=400, detail=str(e))
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error updating transformation {transformation_id}: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error updating transformation: {str(e)}"
)
@router.delete("/transformations/{transformation_id}")
async def delete_transformation(transformation_id: str):
"""Delete a transformation."""
try:
transformation = await Transformation.get(transformation_id)
if not transformation:
raise HTTPException(status_code=404, detail="Transformation not found")
await transformation.delete()
return {"message": "Transformation deleted successfully"}
except HTTPException:
raise
except OpenNotebookError:
raise
except Exception as e:
logger.error(f"Error deleting transformation {transformation_id}: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error deleting transformation: {str(e)}"
)