mirror of
https://github.com/lfnovo/open-notebook.git
synced 2026-05-05 07:08:28 +00:00
* fix: increase API client timeouts for transformation operations - Increase frontend timeout from 30s to 300s (5 minutes) - Increase Streamlit API client timeout from 30s to 300s - Add API_CLIENT_TIMEOUT environment variable for configurability - Add ESPERANTO_LLM_TIMEOUT environment variable documentation - Update .env.example with comprehensive timeout documentation Fixes #131 - API timeout errors during transformation generation Transformations now have sufficient time to complete on slower hardware (Ollama, LM Studio) without frontend timeout errors. Users can now configure timeouts for both the API client layer (API_CLIENT_TIMEOUT) and the LLM provider layer (ESPERANTO_LLM_TIMEOUT) to accommodate their specific hardware and network conditions. * docs: add timeout configuration documentation - Add comprehensive timeout troubleshooting section to common-issues.md - Add FAQ entry about timeout errors during transformations - Document API_CLIENT_TIMEOUT and ESPERANTO_LLM_TIMEOUT usage - Provide specific timeout recommendations for different hardware/network scenarios - Link to GitHub issue #131 for reference * chore: bump * refactor: improve timeout configuration with validation and consistency Based on PR review feedback, this commit addresses several improvements: **Timeout Validation:** - Add validation to ensure timeout values are between 30s and 3600s - Invalid values fall back to default 300s with warning logs - Handles edge cases (negative, zero, invalid strings) **Fix Hard-coded Timeouts:** - Replace all hard-coded timeout values in api/client.py - ask_simple: 300s → self.timeout - execute_transformation: 120s → self.timeout - embed_content: 120s → self.timeout - create_source: 300s → self.timeout - rebuild_embeddings: Uses smart logic (2x timeout, max 3600s) **Improved Documentation:** - Add clarifying comments about ms vs seconds (frontend vs backend) - Document that frontend uses 300000ms = backend 300s - Add inline documentation for rebuild_embeddings timeout logic **Development Dependencies:** - Add pytest>=8.0.0 to dev dependencies for future test coverage This makes timeout configuration more robust, consistent, and user-friendly while maintaining backward compatibility.
473 lines
19 KiB
Python
473 lines
19 KiB
Python
"""
|
|
API client for Open Notebook API.
|
|
This module provides a client interface to interact with the Open Notebook API.
|
|
"""
|
|
|
|
import os
|
|
from typing import Any, Dict, List, Optional, Union
|
|
|
|
import httpx
|
|
from loguru import logger
|
|
|
|
|
|
class APIClient:
|
|
"""Client for Open Notebook API."""
|
|
|
|
def __init__(self, base_url: Optional[str] = None):
|
|
self.base_url = base_url or os.getenv("API_BASE_URL", "http://127.0.0.1:5055")
|
|
# Timeout increased to 5 minutes (300s) to accommodate slow LLM operations
|
|
# (transformations, insights) on slower hardware (Ollama, LM Studio, remote APIs)
|
|
# Configurable via API_CLIENT_TIMEOUT environment variable (in seconds)
|
|
timeout_str = os.getenv("API_CLIENT_TIMEOUT", "300.0")
|
|
try:
|
|
timeout_value = float(timeout_str)
|
|
# Validate timeout is within reasonable bounds (30s - 3600s / 1 hour)
|
|
if timeout_value < 30:
|
|
logger.warning(f"API_CLIENT_TIMEOUT={timeout_value}s is too low, using minimum of 30s")
|
|
timeout_value = 30.0
|
|
elif timeout_value > 3600:
|
|
logger.warning(f"API_CLIENT_TIMEOUT={timeout_value}s is too high, using maximum of 3600s")
|
|
timeout_value = 3600.0
|
|
self.timeout = timeout_value
|
|
except ValueError:
|
|
logger.error(f"Invalid API_CLIENT_TIMEOUT value '{timeout_str}', using default 300s")
|
|
self.timeout = 300.0
|
|
|
|
# Add authentication header if password is set
|
|
self.headers = {}
|
|
password = os.getenv("OPEN_NOTEBOOK_PASSWORD")
|
|
if password:
|
|
self.headers["Authorization"] = f"Bearer {password}"
|
|
|
|
def _make_request(
|
|
self, method: str, endpoint: str, timeout: Optional[float] = None, **kwargs
|
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Make HTTP request to the API."""
|
|
url = f"{self.base_url}{endpoint}"
|
|
request_timeout = timeout if timeout is not None else self.timeout
|
|
|
|
# Merge headers
|
|
headers = kwargs.get("headers", {})
|
|
headers.update(self.headers)
|
|
kwargs["headers"] = headers
|
|
|
|
try:
|
|
with httpx.Client(timeout=request_timeout) as client:
|
|
response = client.request(method, url, **kwargs)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except httpx.RequestError as e:
|
|
logger.error(f"Request error for {method} {url}: {str(e)}")
|
|
raise ConnectionError(f"Failed to connect to API: {str(e)}")
|
|
except httpx.HTTPStatusError as e:
|
|
logger.error(
|
|
f"HTTP error {e.response.status_code} for {method} {url}: {e.response.text}"
|
|
)
|
|
raise RuntimeError(
|
|
f"API request failed: {e.response.status_code} - {e.response.text}"
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error for {method} {url}: {str(e)}")
|
|
raise
|
|
|
|
# Notebooks API methods
|
|
def get_notebooks(
|
|
self, archived: Optional[bool] = None, order_by: str = "updated desc"
|
|
) -> List[Dict[Any, Any]]:
|
|
"""Get all notebooks."""
|
|
params: Dict[str, Any] = {"order_by": order_by}
|
|
if archived is not None:
|
|
params["archived"] = str(archived).lower()
|
|
|
|
result = self._make_request("GET", "/api/notebooks", params=params)
|
|
return result if isinstance(result, list) else [result]
|
|
|
|
def create_notebook(self, name: str, description: str = "") -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Create a new notebook."""
|
|
data = {"name": name, "description": description}
|
|
return self._make_request("POST", "/api/notebooks", json=data)
|
|
|
|
def get_notebook(self, notebook_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Get a specific notebook."""
|
|
return self._make_request("GET", f"/api/notebooks/{notebook_id}")
|
|
|
|
def update_notebook(self, notebook_id: str, **updates) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Update a notebook."""
|
|
return self._make_request("PUT", f"/api/notebooks/{notebook_id}", json=updates)
|
|
|
|
def delete_notebook(self, notebook_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Delete a notebook."""
|
|
return self._make_request("DELETE", f"/api/notebooks/{notebook_id}")
|
|
|
|
# Search API methods
|
|
def search(
|
|
self,
|
|
query: str,
|
|
search_type: str = "text",
|
|
limit: int = 100,
|
|
search_sources: bool = True,
|
|
search_notes: bool = True,
|
|
minimum_score: float = 0.2,
|
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Search the knowledge base."""
|
|
data = {
|
|
"query": query,
|
|
"type": search_type,
|
|
"limit": limit,
|
|
"search_sources": search_sources,
|
|
"search_notes": search_notes,
|
|
"minimum_score": minimum_score,
|
|
}
|
|
return self._make_request("POST", "/api/search", json=data)
|
|
|
|
def ask_simple(
|
|
self,
|
|
question: str,
|
|
strategy_model: str,
|
|
answer_model: str,
|
|
final_answer_model: str,
|
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Ask the knowledge base a question (simple, non-streaming)."""
|
|
data = {
|
|
"question": question,
|
|
"strategy_model": strategy_model,
|
|
"answer_model": answer_model,
|
|
"final_answer_model": final_answer_model,
|
|
}
|
|
# Use configured timeout for long-running ask operations
|
|
return self._make_request(
|
|
"POST", "/api/search/ask/simple", json=data, timeout=self.timeout
|
|
)
|
|
|
|
# Models API methods
|
|
def get_models(self, model_type: Optional[str] = None) -> List[Dict[Any, Any]]:
|
|
"""Get all models with optional type filtering."""
|
|
params = {}
|
|
if model_type:
|
|
params["type"] = model_type
|
|
result = self._make_request("GET", "/api/models", params=params)
|
|
return result if isinstance(result, list) else [result]
|
|
|
|
def create_model(self, name: str, provider: str, model_type: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Create a new model."""
|
|
data = {
|
|
"name": name,
|
|
"provider": provider,
|
|
"type": model_type,
|
|
}
|
|
return self._make_request("POST", "/api/models", json=data)
|
|
|
|
def delete_model(self, model_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Delete a model."""
|
|
return self._make_request("DELETE", f"/api/models/{model_id}")
|
|
|
|
def get_default_models(self) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Get default model assignments."""
|
|
return self._make_request("GET", "/api/models/defaults")
|
|
|
|
def update_default_models(self, **defaults) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Update default model assignments."""
|
|
return self._make_request("PUT", "/api/models/defaults", json=defaults)
|
|
|
|
# Transformations API methods
|
|
def get_transformations(self) -> List[Dict[Any, Any]]:
|
|
"""Get all transformations."""
|
|
result = self._make_request("GET", "/api/transformations")
|
|
return result if isinstance(result, list) else [result]
|
|
|
|
def create_transformation(
|
|
self,
|
|
name: str,
|
|
title: str,
|
|
description: str,
|
|
prompt: str,
|
|
apply_default: bool = False,
|
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Create a new transformation."""
|
|
data = {
|
|
"name": name,
|
|
"title": title,
|
|
"description": description,
|
|
"prompt": prompt,
|
|
"apply_default": apply_default,
|
|
}
|
|
return self._make_request("POST", "/api/transformations", json=data)
|
|
|
|
def get_transformation(self, transformation_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Get a specific transformation."""
|
|
return self._make_request("GET", f"/api/transformations/{transformation_id}")
|
|
|
|
def update_transformation(self, transformation_id: str, **updates) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Update a transformation."""
|
|
return self._make_request(
|
|
"PUT", f"/api/transformations/{transformation_id}", json=updates
|
|
)
|
|
|
|
def delete_transformation(self, transformation_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Delete a transformation."""
|
|
return self._make_request("DELETE", f"/api/transformations/{transformation_id}")
|
|
|
|
def execute_transformation(
|
|
self, transformation_id: str, input_text: str, model_id: str
|
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Execute a transformation on input text."""
|
|
data = {
|
|
"transformation_id": transformation_id,
|
|
"input_text": input_text,
|
|
"model_id": model_id,
|
|
}
|
|
# Use configured timeout for transformation operations
|
|
return self._make_request(
|
|
"POST", "/api/transformations/execute", json=data, timeout=self.timeout
|
|
)
|
|
|
|
# Notes API methods
|
|
def get_notes(self, notebook_id: Optional[str] = None) -> List[Dict[Any, Any]]:
|
|
"""Get all notes with optional notebook filtering."""
|
|
params = {}
|
|
if notebook_id:
|
|
params["notebook_id"] = notebook_id
|
|
result = self._make_request("GET", "/api/notes", params=params)
|
|
return result if isinstance(result, list) else [result]
|
|
|
|
def create_note(
|
|
self,
|
|
content: str,
|
|
title: Optional[str] = None,
|
|
note_type: str = "human",
|
|
notebook_id: Optional[str] = None,
|
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Create a new note."""
|
|
data = {
|
|
"content": content,
|
|
"note_type": note_type,
|
|
}
|
|
if title:
|
|
data["title"] = title
|
|
if notebook_id:
|
|
data["notebook_id"] = notebook_id
|
|
return self._make_request("POST", "/api/notes", json=data)
|
|
|
|
def get_note(self, note_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Get a specific note."""
|
|
return self._make_request("GET", f"/api/notes/{note_id}")
|
|
|
|
def update_note(self, note_id: str, **updates) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Update a note."""
|
|
return self._make_request("PUT", f"/api/notes/{note_id}", json=updates)
|
|
|
|
def delete_note(self, note_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Delete a note."""
|
|
return self._make_request("DELETE", f"/api/notes/{note_id}")
|
|
|
|
# Embedding API methods
|
|
def embed_content(self, item_id: str, item_type: str, async_processing: bool = False) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Embed content for vector search."""
|
|
data = {
|
|
"item_id": item_id,
|
|
"item_type": item_type,
|
|
"async_processing": async_processing,
|
|
}
|
|
# Use configured timeout for embedding operations
|
|
return self._make_request("POST", "/api/embed", json=data, timeout=self.timeout)
|
|
|
|
def rebuild_embeddings(
|
|
self,
|
|
mode: str = "existing",
|
|
include_sources: bool = True,
|
|
include_notes: bool = True,
|
|
include_insights: bool = True
|
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Rebuild embeddings in bulk.
|
|
|
|
Note: This operation can take a long time for large databases.
|
|
Consider increasing API_CLIENT_TIMEOUT to 600-900s for bulk rebuilds.
|
|
"""
|
|
data = {
|
|
"mode": mode,
|
|
"include_sources": include_sources,
|
|
"include_notes": include_notes,
|
|
"include_insights": include_insights,
|
|
}
|
|
# Use double the configured timeout for bulk rebuild operations (or configured value if already high)
|
|
rebuild_timeout = max(self.timeout, min(self.timeout * 2, 3600.0))
|
|
return self._make_request("POST", "/api/embeddings/rebuild", json=data, timeout=rebuild_timeout)
|
|
|
|
def get_rebuild_status(self, command_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Get status of a rebuild operation."""
|
|
return self._make_request("GET", f"/api/embeddings/rebuild/{command_id}/status")
|
|
|
|
# Settings API methods
|
|
def get_settings(self) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Get all application settings."""
|
|
return self._make_request("GET", "/api/settings")
|
|
|
|
def update_settings(self, **settings) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Update application settings."""
|
|
return self._make_request("PUT", "/api/settings", json=settings)
|
|
|
|
# Context API methods
|
|
def get_notebook_context(
|
|
self, notebook_id: str, context_config: Optional[Dict] = None
|
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Get context for a notebook."""
|
|
data: Dict[str, Any] = {"notebook_id": notebook_id}
|
|
if context_config:
|
|
data["context_config"] = context_config
|
|
result = self._make_request(
|
|
"POST", f"/api/notebooks/{notebook_id}/context", json=data
|
|
)
|
|
return result if isinstance(result, dict) else {}
|
|
|
|
# Sources API methods
|
|
def get_sources(self, notebook_id: Optional[str] = None) -> List[Dict[Any, Any]]:
|
|
"""Get all sources with optional notebook filtering."""
|
|
params = {}
|
|
if notebook_id:
|
|
params["notebook_id"] = notebook_id
|
|
result = self._make_request("GET", "/api/sources", params=params)
|
|
return result if isinstance(result, list) else [result]
|
|
|
|
def create_source(
|
|
self,
|
|
notebook_id: Optional[str] = None,
|
|
notebooks: Optional[List[str]] = None,
|
|
source_type: str = "text",
|
|
url: Optional[str] = None,
|
|
file_path: Optional[str] = None,
|
|
content: Optional[str] = None,
|
|
title: Optional[str] = None,
|
|
transformations: Optional[List[str]] = None,
|
|
embed: bool = False,
|
|
delete_source: bool = False,
|
|
async_processing: bool = False,
|
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Create a new source."""
|
|
data = {
|
|
"type": source_type,
|
|
"embed": embed,
|
|
"delete_source": delete_source,
|
|
"async_processing": async_processing,
|
|
}
|
|
|
|
# Handle backward compatibility for notebook_id vs notebooks
|
|
if notebooks:
|
|
data["notebooks"] = notebooks
|
|
elif notebook_id:
|
|
data["notebook_id"] = notebook_id
|
|
else:
|
|
raise ValueError("Either notebook_id or notebooks must be provided")
|
|
|
|
if url:
|
|
data["url"] = url
|
|
if file_path:
|
|
data["file_path"] = file_path
|
|
if content:
|
|
data["content"] = content
|
|
if title:
|
|
data["title"] = title
|
|
if transformations:
|
|
data["transformations"] = transformations
|
|
|
|
# Use configured timeout for source creation (especially PDF processing with OCR)
|
|
return self._make_request("POST", "/api/sources/json", json=data, timeout=self.timeout)
|
|
|
|
def get_source(self, source_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Get a specific source."""
|
|
return self._make_request("GET", f"/api/sources/{source_id}")
|
|
|
|
def get_source_status(self, source_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Get processing status for a source."""
|
|
return self._make_request("GET", f"/api/sources/{source_id}/status")
|
|
|
|
def update_source(self, source_id: str, **updates) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Update a source."""
|
|
return self._make_request("PUT", f"/api/sources/{source_id}", json=updates)
|
|
|
|
def delete_source(self, source_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Delete a source."""
|
|
return self._make_request("DELETE", f"/api/sources/{source_id}")
|
|
|
|
# Insights API methods
|
|
def get_source_insights(self, source_id: str) -> List[Dict[Any, Any]]:
|
|
"""Get all insights for a specific source."""
|
|
result = self._make_request("GET", f"/api/sources/{source_id}/insights")
|
|
return result if isinstance(result, list) else [result]
|
|
|
|
def get_insight(self, insight_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Get a specific insight."""
|
|
return self._make_request("GET", f"/api/insights/{insight_id}")
|
|
|
|
def delete_insight(self, insight_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Delete a specific insight."""
|
|
return self._make_request("DELETE", f"/api/insights/{insight_id}")
|
|
|
|
def save_insight_as_note(
|
|
self, insight_id: str, notebook_id: Optional[str] = None
|
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Convert an insight to a note."""
|
|
data = {}
|
|
if notebook_id:
|
|
data["notebook_id"] = notebook_id
|
|
return self._make_request(
|
|
"POST", f"/api/insights/{insight_id}/save-as-note", json=data
|
|
)
|
|
|
|
def create_source_insight(
|
|
self, source_id: str, transformation_id: str, model_id: Optional[str] = None
|
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Create a new insight for a source by running a transformation."""
|
|
data = {"transformation_id": transformation_id}
|
|
if model_id:
|
|
data["model_id"] = model_id
|
|
return self._make_request(
|
|
"POST", f"/api/sources/{source_id}/insights", json=data
|
|
)
|
|
|
|
# Episode Profiles API methods
|
|
def get_episode_profiles(self) -> List[Dict[Any, Any]]:
|
|
"""Get all episode profiles."""
|
|
result = self._make_request("GET", "/api/episode-profiles")
|
|
return result if isinstance(result, list) else [result]
|
|
|
|
def get_episode_profile(self, profile_name: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Get a specific episode profile by name."""
|
|
return self._make_request("GET", f"/api/episode-profiles/{profile_name}")
|
|
|
|
def create_episode_profile(
|
|
self,
|
|
name: str,
|
|
description: str = "",
|
|
speaker_config: str = "",
|
|
outline_provider: str = "",
|
|
outline_model: str = "",
|
|
transcript_provider: str = "",
|
|
transcript_model: str = "",
|
|
default_briefing: str = "",
|
|
num_segments: int = 5,
|
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Create a new episode profile."""
|
|
data = {
|
|
"name": name,
|
|
"description": description,
|
|
"speaker_config": speaker_config,
|
|
"outline_provider": outline_provider,
|
|
"outline_model": outline_model,
|
|
"transcript_provider": transcript_provider,
|
|
"transcript_model": transcript_model,
|
|
"default_briefing": default_briefing,
|
|
"num_segments": num_segments,
|
|
}
|
|
return self._make_request("POST", "/api/episode-profiles", json=data)
|
|
|
|
def update_episode_profile(self, profile_id: str, **updates) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Update an episode profile."""
|
|
return self._make_request("PUT", f"/api/episode-profiles/{profile_id}", json=updates)
|
|
|
|
def delete_episode_profile(self, profile_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
"""Delete an episode profile."""
|
|
return self._make_request("DELETE", f"/api/episode-profiles/{profile_id}")
|
|
|
|
|
|
# Global client instance
|
|
api_client = APIClient()
|