open-notebook/api/models.py
Luis Novo ba01f7df4e fix: handle credential decryption errors gracefully (#740)
- Credential.get_all() now uses per-row error handling instead of failing on first bad row
- Broken credentials include decryption_error field with descriptive message
- DELETE endpoint falls back to direct DB delete when credential can't be decrypted
- Frontend shows amber warning alert for broken credentials with disabled test/edit/discover
- Added i18n translation keys for decryption error warning in all 9 locales
2026-04-12 21:22:37 -03:00

686 lines
24 KiB
Python

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
# 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", 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"
)
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"
)
class TransformationResponse(BaseModel):
id: str
name: str
title: str
description: str
prompt: str
apply_default: bool
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: str = Field(..., description="Model ID to use for the transformation")
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: str = Field(..., 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
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
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, description="List of notebook IDs to add the source to"
)
# 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, description="Transformation IDs to apply"
)
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
# Context API models
class ContextConfig(BaseModel):
sources: Dict[str, str] = Field(
default_factory=dict, description="Source inclusion config {source_id: level}"
)
notes: Dict[str, str] = Field(
default_factory=dict, description="Note inclusion config {note_id: level}"
)
class ContextRequest(BaseModel):
notebook_id: str = Field(..., description="Notebook ID to get context for")
context_config: Optional[ContextConfig] = Field(
None, description="Context configuration"
)
class ContextResponse(BaseModel):
notebook_id: str
sources: List[Dict[str, Any]] = Field(..., description="Source context data")
notes: List[Dict[str, Any]] = Field(..., description="Note context data")
total_tokens: Optional[int] = Field(None, description="Estimated token count")
# Insights API models
class SourceInsightResponse(BaseModel):
id: str
source_id: str
insight_type: str
content: str
created: str
updated: str
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
class CreateCredentialRequest(BaseModel):
"""Request to create a new credential."""
name: str = Field(..., description="Credential name")
provider: str = 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)"
)
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")
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
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"
)