chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
@../open_notebook/AGENTS.md
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
+82
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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"}
|
||||
@@ -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
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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)}")
|
||||
@@ -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": [],
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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)}")
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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()
|
||||
]
|
||||
@@ -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)}")
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
@@ -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"
|
||||
)
|
||||
@@ -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)}"
|
||||
)
|
||||
Reference in New Issue
Block a user