chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:10:23 +08:00
commit fbab2c6005
567 changed files with 114434 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
# ContextBuilder
A flexible and generic ContextBuilder class for the Open Notebook project that can handle any parameters and build context from sources, notebooks, insights, and notes.
## Features
- **Flexible Parameters**: Accepts any parameters via `**kwargs` for future extensibility
- **Priority-based Management**: Automatic prioritization and sorting of context items
- **Token Counting**: Built-in token counting and truncation to fit limits
- **Deduplication**: Automatic removal of duplicate items based on ID
- **Type-based Grouping**: Separates sources, notes, and insights in output
- **Async Support**: Fully async for database operations
## Basic Usage
```python
from open_notebook.utils.context_builder import ContextBuilder, ContextConfig
# Simple notebook context
builder = ContextBuilder(notebook_id="notebook:123")
context = await builder.build()
# Single source with insights
builder = ContextBuilder(
source_id="source:456",
include_insights=True,
max_tokens=2000
)
context = await builder.build()
```
## Convenience Functions
```python
from open_notebook.utils.context_builder import (
build_notebook_context,
build_source_context,
build_mixed_context
)
# Build notebook context
context = await build_notebook_context(
notebook_id="notebook:123",
max_tokens=5000
)
# Build single source context
context = await build_source_context(
source_id="source:456",
include_insights=True
)
# Build mixed context
context = await build_mixed_context(
source_ids=["source:1", "source:2"],
note_ids=["note:1", "note:2"],
max_tokens=3000
)
```
## Advanced Configuration
```python
from open_notebook.utils.context_builder import ContextConfig
# Custom configuration
config = ContextConfig(
sources={
"source:doc1": "insights",
"source:doc2": "full content",
"source:doc3": "not in" # Exclude
},
notes={
"note:summary": "full content",
"note:draft": "not in" # Exclude
},
include_insights=True,
max_tokens=3000,
priority_weights={
"source": 120, # Higher priority
"note": 80, # Medium priority
"insight": 100 # High priority
}
)
builder = ContextBuilder(
notebook_id="notebook:project",
context_config=config
)
context = await builder.build()
```
## Programmatic Item Management
```python
from open_notebook.utils.context_builder import ContextItem
builder = ContextBuilder()
# Add custom items
item = ContextItem(
id="source:important",
type="source",
content={"title": "Key Document", "summary": "..."},
priority=150 # Very high priority
)
builder.add_item(item)
# Apply management operations
builder.remove_duplicates()
builder.prioritize()
builder.truncate_to_fit(1000)
context = builder._format_response()
```
## Flexible Parameters
The ContextBuilder accepts any parameters via `**kwargs`, making it extensible for future features:
```python
builder = ContextBuilder(
notebook_id="notebook:123",
include_insights=True,
max_tokens=2000,
# Custom parameters for future extensions
user_id="user:456",
custom_filter="advanced",
experimental_feature=True
)
# Access custom parameters
user_id = builder.params.get('user_id')
```
## Output Format
The ContextBuilder returns a structured response:
```python
{
"sources": [...], # List of source contexts
"notes": [...], # List of note contexts
"insights": [...], # List of insight contexts
"total_tokens": 1234, # Total token count
"total_items": 10, # Total number of items
"notebook_id": "notebook:123", # If provided
"metadata": {
"source_count": 5,
"note_count": 3,
"insight_count": 2,
"config": {
"include_insights": true,
"include_notes": true,
"max_tokens": 2000
}
}
}
```
## Architecture
The ContextBuilder follows these design principles:
1. **Separation of Concerns**: Context building, item management, and formatting are separate
2. **Extensibility**: Uses `**kwargs` and flexible configuration for future features
3. **Performance**: Token-aware truncation and efficient deduplication
4. **Type Safety**: Proper type hints and data classes for structure
5. **Error Handling**: Graceful handling of missing items and database errors
## Integration
The ContextBuilder integrates seamlessly with the existing Open Notebook architecture:
- Uses existing domain models (`Source`, `Notebook`, `Note`)
- Leverages the repository pattern for database access
- Follows the same async patterns as other services
- Integrates with the token counting utilities
## Error Handling
The ContextBuilder handles errors gracefully:
- Missing notebooks/sources/notes are logged but don't stop execution
- Database errors are wrapped in `DatabaseOperationError`
- Invalid parameters raise `InvalidInputError`
- All errors include detailed context information
+72
View File
@@ -0,0 +1,72 @@
"""
Utils package for Open Notebook.
To avoid circular imports, import functions directly:
- from open_notebook.utils.context_builder import build_notebook_context, build_source_context
- from open_notebook.utils import token_count, compare_versions
- from open_notebook.utils.chunking import chunk_text, detect_content_type, ContentType
- from open_notebook.utils.embedding import generate_embedding, generate_embeddings
- from open_notebook.utils.encryption import encrypt_value, decrypt_value
"""
from .chunking import (
CHUNK_SIZE,
ContentType,
chunk_text,
detect_content_type,
detect_content_type_from_extension,
detect_content_type_from_heuristics,
)
from .embedding import (
generate_embedding,
generate_embeddings,
mean_pool_embeddings,
)
from .encryption import (
decrypt_value,
encrypt_value,
)
from .model_utils import full_model_dump
from .text_utils import (
clean_thinking_content,
parse_thinking_content,
remove_non_ascii,
remove_non_printable,
)
from .token_utils import token_cost, token_count
from .version_utils import (
compare_versions,
get_installed_version,
get_version_from_github,
)
__all__ = [
# Chunking
"CHUNK_SIZE",
"ContentType",
"chunk_text",
"detect_content_type",
"detect_content_type_from_extension",
"detect_content_type_from_heuristics",
# Embedding
"generate_embedding",
"generate_embeddings",
"mean_pool_embeddings",
# Text utils
"remove_non_ascii",
"remove_non_printable",
"parse_thinking_content",
"clean_thinking_content",
# Token utils
"token_count",
"token_cost",
# Version utils
"compare_versions",
"get_installed_version",
"get_version_from_github",
# Encryption utils
"decrypt_value",
"encrypt_value",
# Model utils
"full_model_dump",
]
+494
View File
@@ -0,0 +1,494 @@
"""
Chunking utilities for Open Notebook.
Provides content-type detection and smart text chunking for embedding operations.
Supports HTML, Markdown, and plain text with appropriate splitters for each type.
Key functions:
- detect_content_type(): Detects content type from file extension or content heuristics
- chunk_text(): Splits text into chunks using appropriate splitter for content type
Environment Variables:
OPEN_NOTEBOOK_CHUNK_SIZE: Maximum chunk size in tokens (default: 400)
OPEN_NOTEBOOK_CHUNK_OVERLAP: Overlap between chunks in tokens (default: 15% of CHUNK_SIZE)
OPEN_NOTEBOOK_MIN_CHUNK_SIZE: Minimum chunk size in tokens (default: 5)
"""
import os
import re
from enum import Enum
from pathlib import Path
from typing import List, Optional, Tuple
from langchain_text_splitters import (
HTMLHeaderTextSplitter,
MarkdownHeaderTextSplitter,
RecursiveCharacterTextSplitter,
)
from loguru import logger
from .token_utils import token_count
def _get_chunk_size() -> int:
"""Get chunk size from environment variable or use default."""
chunk_size_str = os.getenv("OPEN_NOTEBOOK_CHUNK_SIZE")
if chunk_size_str:
try:
chunk_size = int(chunk_size_str)
if chunk_size < 100:
logger.warning(
f"OPEN_NOTEBOOK_CHUNK_SIZE ({chunk_size}) is too small. "
f"Using minimum value of 100."
)
return 100
if chunk_size > 8192:
logger.warning(
f"OPEN_NOTEBOOK_CHUNK_SIZE ({chunk_size}) is very large. "
f"This may cause issues with some embedding models."
)
logger.info(f"Using custom chunk size: {chunk_size} tokens")
return chunk_size
except ValueError:
logger.warning(
f"Invalid OPEN_NOTEBOOK_CHUNK_SIZE value: '{chunk_size_str}'. "
f"Using default: 400"
)
return 400
def _get_chunk_overlap(chunk_size: int) -> int:
"""Get chunk overlap from environment variable or calculate default (15% of chunk size)."""
overlap_str = os.getenv("OPEN_NOTEBOOK_CHUNK_OVERLAP")
if overlap_str:
try:
overlap = int(overlap_str)
if overlap < 0:
logger.warning(
f"OPEN_NOTEBOOK_CHUNK_OVERLAP ({overlap}) cannot be negative. "
f"Using 0."
)
return 0
if overlap >= chunk_size:
logger.warning(
f"OPEN_NOTEBOOK_CHUNK_OVERLAP ({overlap}) cannot be >= chunk size ({chunk_size}). "
f"Using 15% of chunk size: {int(chunk_size * 0.15)}"
)
return int(chunk_size * 0.15)
logger.info(f"Using custom chunk overlap: {overlap} tokens")
return overlap
except ValueError:
logger.warning(
f"Invalid OPEN_NOTEBOOK_CHUNK_OVERLAP value: '{overlap_str}'. "
f"Using default: 15% of chunk size"
)
return int(chunk_size * 0.15)
def _get_min_chunk_size() -> int:
"""Get minimum chunk size from environment variable or use default.
Chunks below this token count are dropped. Some splitters (notably the
HTML header splitter on complex pages) can emit single-character or
punctuation-only chunks that produce useless or null embeddings —
llama.cpp's OpenAI-compatible endpoint, for example, returns null vector
elements for such inputs and crashes downstream parsing.
"""
raw = os.getenv("OPEN_NOTEBOOK_MIN_CHUNK_SIZE")
if raw is None:
return 5
try:
value = int(raw)
if value < 0:
logger.warning(
f"OPEN_NOTEBOOK_MIN_CHUNK_SIZE ({value}) cannot be negative. Using 0."
)
return 0
return value
except ValueError:
logger.warning(
f"Invalid OPEN_NOTEBOOK_MIN_CHUNK_SIZE value: '{raw}'. Using default: 5"
)
return 5
# Constants (computed at import time from environment variables)
CHUNK_SIZE = _get_chunk_size()
CHUNK_OVERLAP = _get_chunk_overlap(CHUNK_SIZE)
MIN_CHUNK_SIZE = _get_min_chunk_size()
HIGH_CONFIDENCE_THRESHOLD = 0.8 # Threshold for heuristics to override extension
logger.debug(
f"Chunking configuration: CHUNK_SIZE={CHUNK_SIZE}, "
f"CHUNK_OVERLAP={CHUNK_OVERLAP}, MIN_CHUNK_SIZE={MIN_CHUNK_SIZE}"
)
class ContentType(Enum):
"""Content type for chunking strategy selection."""
HTML = "html"
MARKDOWN = "markdown"
PLAIN = "plain"
# File extension mappings
_EXTENSION_TO_CONTENT_TYPE = {
# HTML
".html": ContentType.HTML,
".htm": ContentType.HTML,
".xhtml": ContentType.HTML,
# Markdown
".md": ContentType.MARKDOWN,
".markdown": ContentType.MARKDOWN,
".mdown": ContentType.MARKDOWN,
".mkd": ContentType.MARKDOWN,
# Plain text (explicit)
".txt": ContentType.PLAIN,
".text": ContentType.PLAIN,
# Code files (treat as plain)
".py": ContentType.PLAIN,
".js": ContentType.PLAIN,
".ts": ContentType.PLAIN,
".java": ContentType.PLAIN,
".c": ContentType.PLAIN,
".cpp": ContentType.PLAIN,
".go": ContentType.PLAIN,
".rs": ContentType.PLAIN,
".rb": ContentType.PLAIN,
".php": ContentType.PLAIN,
".sh": ContentType.PLAIN,
".bash": ContentType.PLAIN,
".zsh": ContentType.PLAIN,
".sql": ContentType.PLAIN,
".json": ContentType.PLAIN,
".yaml": ContentType.PLAIN,
".yml": ContentType.PLAIN,
".xml": ContentType.PLAIN,
".csv": ContentType.PLAIN,
".tsv": ContentType.PLAIN,
}
def detect_content_type_from_extension(
file_path: Optional[str],
) -> Optional[ContentType]:
"""
Detect content type from file extension.
Args:
file_path: Path to the file (can be full path or just filename)
Returns:
ContentType if extension is recognized, None otherwise
"""
if not file_path:
return None
try:
extension = Path(file_path).suffix.lower()
return _EXTENSION_TO_CONTENT_TYPE.get(extension)
except Exception:
return None
def detect_content_type_from_heuristics(text: str) -> Tuple[ContentType, float]:
"""
Detect content type using content heuristics.
Args:
text: The text content to analyze
Returns:
Tuple of (ContentType, confidence_score) where confidence is 0.0-1.0
"""
if not text or len(text) < 10:
return ContentType.PLAIN, 0.5
# Sample first 5000 chars for efficiency
sample = text[:5000]
# Check HTML first (most specific patterns)
html_score = _calculate_html_score(sample)
if html_score >= HIGH_CONFIDENCE_THRESHOLD:
return ContentType.HTML, html_score
# Check Markdown
markdown_score = _calculate_markdown_score(sample)
if markdown_score >= HIGH_CONFIDENCE_THRESHOLD:
return ContentType.MARKDOWN, markdown_score
# Return the higher scoring type, or PLAIN if both are low
if html_score > markdown_score and html_score > 0.3:
return ContentType.HTML, html_score
elif markdown_score > 0.3:
return ContentType.MARKDOWN, markdown_score
else:
return ContentType.PLAIN, 0.6
def _calculate_html_score(text: str) -> float:
"""Calculate confidence score for HTML content."""
score = 0.0
indicators = 0
# Strong indicators
if re.search(r"<!DOCTYPE\s+html", text, re.IGNORECASE):
score += 0.4
indicators += 1
if re.search(r"<html[\s>]", text, re.IGNORECASE):
score += 0.3
indicators += 1
# Structural tags
structural_tags = ["<head", "<body", "<div", "<span", "<p>", "<table", "<form"]
for tag in structural_tags:
if tag.lower() in text.lower():
score += 0.1
indicators += 1
if indicators >= 5:
break
# Header tags
if re.search(r"<h[1-6][\s>]", text, re.IGNORECASE):
score += 0.15
indicators += 1
# Closing tags pattern
if re.search(r"</\w+>", text):
score += 0.1
indicators += 1
return min(score, 1.0)
def _calculate_markdown_score(text: str) -> float:
"""Calculate confidence score for Markdown content."""
score = 0.0
indicators = 0
# Headers (# ## ###) - strong indicator
header_matches = len(re.findall(r"^#{1,6}\s+.+", text, re.MULTILINE))
if header_matches >= 3:
score += 0.35
indicators += 1
elif header_matches >= 1:
score += 0.2
indicators += 1
# Links [text](url) - strong indicator
link_matches = len(re.findall(r"\[.+?\]\(.+?\)", text))
if link_matches >= 2:
score += 0.25
indicators += 1
elif link_matches >= 1:
score += 0.15
indicators += 1
# Code blocks ``` - strong indicator
if re.search(r"^```", text, re.MULTILINE):
score += 0.2
indicators += 1
# Inline code `code`
if re.search(r"`[^`]+`", text):
score += 0.1
indicators += 1
# Lists (-, *, +, or numbered)
list_matches = len(re.findall(r"^[\*\-\+]\s+", text, re.MULTILINE))
list_matches += len(re.findall(r"^\d+\.\s+", text, re.MULTILINE))
if list_matches >= 3:
score += 0.15
indicators += 1
elif list_matches >= 1:
score += 0.08
indicators += 1
# Bold/italic
if re.search(r"\*\*.+?\*\*|__.+?__", text):
score += 0.1
indicators += 1
# Blockquotes
if re.search(r"^>\s+", text, re.MULTILINE):
score += 0.1
indicators += 1
return min(score, 1.0)
def detect_content_type(text: str, file_path: Optional[str] = None) -> ContentType:
"""
Detect content type using file extension (primary) and heuristics (fallback).
Strategy:
1. If file extension is available and recognized, use it as primary
2. If no extension or generic extension (.txt), use heuristics
3. Heuristics can override extension only with very high confidence
Args:
text: The text content
file_path: Optional file path for extension-based detection
Returns:
Detected ContentType
"""
# Try extension-based detection first
extension_type = detect_content_type_from_extension(file_path)
# Get heuristic-based detection
heuristic_type, confidence = detect_content_type_from_heuristics(text)
# If no extension or generic extension, use heuristics
if extension_type is None:
logger.debug(
f"No file extension, using heuristics: {heuristic_type.value} "
f"(confidence: {confidence:.2f})"
)
return heuristic_type
# If extension suggests plain text but heuristics are very confident, override
if extension_type == ContentType.PLAIN and confidence >= HIGH_CONFIDENCE_THRESHOLD:
logger.debug(
f"Extension suggests plain, but heuristics override with "
f"{heuristic_type.value} (confidence: {confidence:.2f})"
)
return heuristic_type
# Otherwise trust the extension
logger.debug(f"Using extension-based content type: {extension_type.value}")
return extension_type
def _get_html_splitter() -> HTMLHeaderTextSplitter:
"""Get HTML header splitter configured for h1, h2, h3."""
headers_to_split_on = [
("h1", "Header 1"),
("h2", "Header 2"),
("h3", "Header 3"),
]
return HTMLHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
def _get_markdown_splitter() -> MarkdownHeaderTextSplitter:
"""Get Markdown header splitter configured for #, ##, ###."""
headers_to_split_on = [
("#", "Header 1"),
("##", "Header 2"),
("###", "Header 3"),
]
return MarkdownHeaderTextSplitter(
headers_to_split_on=headers_to_split_on,
strip_headers=False,
)
def _get_plain_splitter() -> RecursiveCharacterTextSplitter:
"""Get plain text splitter using CHUNK_SIZE and CHUNK_OVERLAP constants."""
return RecursiveCharacterTextSplitter(
chunk_size=CHUNK_SIZE,
chunk_overlap=CHUNK_OVERLAP,
length_function=token_count,
separators=["\n\n", "\n", ". ", ", ", " ", ""],
)
def _apply_secondary_chunking(chunks: List[str]) -> List[str]:
"""
Apply secondary chunking to ensure no chunk exceeds CHUNK_SIZE tokens.
Used when primary splitters (HTML/Markdown) produce oversized chunks.
"""
result = []
secondary_splitter = _get_plain_splitter()
for chunk in chunks:
if token_count(chunk) > CHUNK_SIZE:
# Split oversized chunk
sub_chunks = secondary_splitter.split_text(chunk)
result.extend(sub_chunks)
else:
result.append(chunk)
return result
def chunk_text(
text: str,
content_type: Optional[ContentType] = None,
file_path: Optional[str] = None,
) -> List[str]:
"""
Split text into chunks using appropriate splitter for content type.
Args:
text: The text to chunk
content_type: Optional explicit content type (auto-detected if not provided)
file_path: Optional file path for content type detection
Returns:
List of text chunks, each approximately <= CHUNK_SIZE tokens
"""
if not text or not text.strip():
return []
# Short text doesn't need chunking
text_tokens = token_count(text)
if text_tokens <= CHUNK_SIZE:
return [text]
# Detect content type if not provided
if content_type is None:
content_type = detect_content_type(text, file_path)
logger.debug(f"Chunking text with content type: {content_type.value}")
# Select appropriate splitter
chunks: List[str]
if content_type == ContentType.HTML:
html_splitter = _get_html_splitter()
# HTML splitter returns Document objects
docs = html_splitter.split_text(text)
chunks = [
doc.page_content if hasattr(doc, "page_content") else str(doc)
for doc in docs
]
elif content_type == ContentType.MARKDOWN:
md_splitter = _get_markdown_splitter()
# Markdown splitter returns Document objects
docs = md_splitter.split_text(text)
chunks = [
doc.page_content if hasattr(doc, "page_content") else str(doc)
for doc in docs
]
else:
# Plain text - use recursive splitter directly
chunks = _get_plain_splitter().split_text(text)
# Apply secondary chunking if needed (for HTML/Markdown that may produce large chunks)
if content_type in (ContentType.HTML, ContentType.MARKDOWN):
chunks = _apply_secondary_chunking(chunks)
# Filter out empty chunks
chunks = [c.strip() for c in chunks if c and c.strip()]
# Drop chunks below the minimum token threshold. These are typically
# punctuation or single-character fragments left over from header-based
# splitters; embedding them is wasteful and some providers return null
# vector elements for such inputs (which then crash response parsing).
# Only filter when more than one chunk exists and at least one chunk
# would survive — never return an empty list because of this filter.
if MIN_CHUNK_SIZE > 0 and len(chunks) > 1:
kept = [c for c in chunks if token_count(c) >= MIN_CHUNK_SIZE]
if kept:
dropped = len(chunks) - len(kept)
if dropped > 0:
logger.debug(
f"Dropped {dropped} chunk(s) below MIN_CHUNK_SIZE={MIN_CHUNK_SIZE} tokens"
)
chunks = kept
logger.debug(f"Created {len(chunks)} chunks from {text_tokens} tokens")
return chunks
+208
View File
@@ -0,0 +1,208 @@
"""Context building for chat and podcast generation.
This is the single implementation behind:
- ``POST /api/chat/context`` (`api/routers/chat.py`) — assembles notebook
context from a source/note inclusion config, via
:func:`build_notebook_context`.
- the source-chat graph (`open_notebook/graphs/source_chat.py`) — assembles
a single source plus its insights under a token budget, via
:func:`build_source_context`.
The inclusion config uses string matching on human-readable status values
("not in context", "insights", "full content"). That protocol is shared with
the frontend — do not change it here.
"""
from __future__ import annotations
from typing import Any, Dict, Optional, Tuple
from loguru import logger
from open_notebook.domain.notebook import (
Note,
Notebook,
Source,
SourceInsight,
)
from open_notebook.exceptions import DatabaseOperationError, NotFoundError
from .token_utils import token_count
def _ensure_prefix(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 build_notebook_context(
notebook: Notebook,
context_config: Optional[Dict[str, Any]],
) -> Tuple[Dict[str, list], str]:
"""Assemble source/note context for a notebook.
With a config, each entry's status string decides inclusion: "not in"
skips it, "insights" includes the short source context, "full content"
includes the long context (notes only support "full content"). Without a
config, every source and note is included with its short context.
Failures on individual items are logged and skipped — one broken record
never fails the whole request.
Returns:
({"sources": [...], "notes": [...]}, concatenated str() of every
included context dict — used for token/char counting).
"""
context_data: Dict[str, list] = {"sources": [], "notes": []}
total_content = ""
if context_config:
for source_id, status in context_config.get("sources", {}).items():
if "not in" in status:
continue
try:
full_source_id = _ensure_prefix("source", source_id)
try:
source = await Source.get(full_source_id)
except Exception:
continue
if "insights" in status:
source_context = await source.get_context(context_size="short")
context_data["sources"].append(source_context)
total_content += str(source_context)
elif "full content" in status:
source_context = await source.get_context(context_size="long")
context_data["sources"].append(source_context)
total_content += str(source_context)
except Exception as e:
logger.warning(f"Error processing source {source_id}: {str(e)}")
continue
for note_id, status in context_config.get("notes", {}).items():
if "not in" in status:
continue
try:
full_note_id = _ensure_prefix("note", note_id)
note = await Note.get(full_note_id)
if not note:
continue
if "full content" in status:
note_context = note.get_context(context_size="long")
context_data["notes"].append(note_context)
total_content += str(note_context)
except Exception as e:
logger.warning(f"Error processing note {note_id}: {str(e)}")
continue
else:
# Default behavior - include all sources and notes with short context
sources = await notebook.get_sources()
try:
insights_by_source = await SourceInsight.get_for_sources(
[source.id for source in sources if source.id]
)
except Exception as e:
# Match the per-source fallback below: a hiccup fetching
# insights shouldn't fail the whole context request.
logger.warning(f"Error batch-fetching source insights: {str(e)}")
insights_by_source = {}
for source in sources:
try:
source_context = await source.get_context(
context_size="short",
insights=insights_by_source.get(source.id or "", []),
)
context_data["sources"].append(source_context)
total_content += str(source_context)
except Exception as e:
logger.warning(f"Error processing source {source.id}: {str(e)}")
continue
notes = await notebook.get_notes()
for note in notes:
try:
note_context = note.get_context(context_size="short")
context_data["notes"].append(note_context)
total_content += str(note_context)
except Exception as e:
logger.warning(f"Error processing note {note.id}: {str(e)}")
continue
return context_data, total_content
async def build_source_context(
source_id: str, max_tokens: Optional[int] = None
) -> Dict[str, Any]:
"""Assemble a single source's short context plus its insights.
Used by the source-chat graph. If `max_tokens` is given, insights are
dropped (last-fetched first) until the total fits — the source itself is
always kept.
Returns a dict with "sources", "notes" (always empty), "insights",
"total_tokens", "total_items" and per-type counts in "metadata".
"""
try:
sources: list = []
insights: list = []
item_tokens: list[int] = []
try:
full_source_id = _ensure_prefix("source", source_id)
source = await Source.get(full_source_id)
except NotFoundError:
source = None
if source:
source_context = await source.get_context(context_size="short")
sources.append(source_context)
item_tokens.append(token_count(str(source_context)))
for insight in await source.get_insights():
insight_content = {
"id": insight.id,
"source_id": source.id,
"insight_type": insight.insight_type,
"content": insight.content,
}
insights.append(insight_content)
item_tokens.append(token_count(str(insight_content)))
else:
logger.warning(f"Source {source_id} not found")
# Truncate to the token budget: drop insights from the end (the
# source, added first, is dropped only if it alone exceeds the budget).
total_tokens = sum(item_tokens)
if max_tokens:
while total_tokens > max_tokens and item_tokens:
total_tokens -= item_tokens.pop()
if insights:
insights.pop()
else:
sources.pop()
total_items = len(sources) + len(insights)
logger.info(f"Built context with {total_items} items, {total_tokens} tokens")
return {
"sources": sources,
"notes": [],
"insights": insights,
"total_tokens": total_tokens,
"total_items": total_items,
"metadata": {
"source_count": len(sources),
"note_count": 0,
"insight_count": len(insights),
},
}
except Exception as e:
logger.error(f"Error building context: {str(e)}")
raise DatabaseOperationError(f"Failed to build context: {str(e)}")
+269
View File
@@ -0,0 +1,269 @@
"""
Unified embedding utilities for Open Notebook.
Provides centralized embedding generation with support for:
- Single text embedding (with automatic chunking and mean pooling for large texts)
- Batch text embedding (multiple texts with automatic batching)
- Mean pooling for combining multiple embeddings into one
All embedding operations in the application should use these functions
to ensure consistent behavior and proper handling of large content.
"""
import asyncio
import os
from typing import List, Optional
import numpy as np
from loguru import logger
from .chunking import CHUNK_SIZE, ContentType, chunk_text
from .token_utils import token_count
def _get_embedding_batch_size() -> int:
"""
Read the embedding batch size from the environment.
This is intentionally configurable because provider limits vary widely, and
CPU-only local embedding endpoints often need smaller batches than cloud APIs.
"""
raw = os.getenv("OPEN_NOTEBOOK_EMBEDDING_BATCH_SIZE", "50").strip()
try:
value = int(raw)
if value < 1:
raise ValueError
return value
except ValueError:
logger.warning(
"Invalid OPEN_NOTEBOOK_EMBEDDING_BATCH_SIZE='{}'; falling back to 50",
raw,
)
return 50
EMBEDDING_BATCH_SIZE = _get_embedding_batch_size()
EMBEDDING_MAX_RETRIES = 3
EMBEDDING_RETRY_DELAY = 2 # seconds
async def mean_pool_embeddings(embeddings: List[List[float]]) -> List[float]:
"""
Combine multiple embeddings into a single embedding using mean pooling.
Algorithm:
1. Normalize each embedding to unit length
2. Compute element-wise mean
3. Normalize the result to unit length
This approach ensures the final embedding has the same properties as
individual embeddings (unit length) regardless of input count.
Args:
embeddings: List of embedding vectors (each is a list of floats)
Returns:
Single embedding vector (mean pooled and normalized)
Raises:
ValueError: If embeddings list is empty or embeddings have different dimensions
"""
if not embeddings:
raise ValueError("Cannot mean pool empty list of embeddings")
if len(embeddings) == 1:
# Single embedding - just normalize and return
arr = np.array(embeddings[0], dtype=np.float64)
norm = np.linalg.norm(arr)
if norm > 0:
arr = arr / norm
return arr.tolist()
# Convert to numpy array
arr = np.array(embeddings, dtype=np.float64)
# Verify all embeddings have same dimension
if arr.ndim != 2:
raise ValueError(f"Expected 2D array, got shape {arr.shape}")
# Normalize each embedding to unit length
norms = np.linalg.norm(arr, axis=1, keepdims=True)
# Avoid division by zero
norms = np.where(norms > 0, norms, 1.0)
normalized = arr / norms
# Compute mean
mean = np.mean(normalized, axis=0)
# Normalize the result
mean_norm = np.linalg.norm(mean)
if mean_norm > 0:
mean = mean / mean_norm
return mean.tolist()
async def generate_embeddings(
texts: List[str], command_id: Optional[str] = None
) -> List[List[float]]:
"""
Generate embeddings for multiple texts with automatic batching and retry.
Texts are split into batches of EMBEDDING_BATCH_SIZE to avoid exceeding
provider payload limits. Each batch is retried up to EMBEDDING_MAX_RETRIES
times on transient failures.
Args:
texts: List of text strings to embed
command_id: Optional command ID for error logging context
Returns:
List of embedding vectors, one per input text
Raises:
ValueError: If no embedding model is configured
RuntimeError: If embedding generation fails
"""
if not texts:
return []
# Lazy import to avoid circular dependency
from open_notebook.ai.models import model_manager
embedding_model = await model_manager.get_embedding_model()
if not embedding_model:
raise ValueError(
"No embedding model configured. Please configure one in the Models section."
)
model_name = getattr(embedding_model, "model_name", "unknown")
# Log text sizes for debugging
metrics: tuple[int, int, int, int] | None = None
def _get_size_metrics() -> tuple[int, int, int, int]:
nonlocal metrics
if metrics is None:
token_sizes = [token_count(t) for t in texts]
metrics = (
min(token_sizes),
max(token_sizes),
sum(token_sizes),
sum(len(t) for t in texts),
)
return metrics
logger.opt(lazy=True).debug(
"Generating embeddings for {} texts "
"(tokens: min={}, max={}, total={}; chars: total={})",
lambda: len(texts),
lambda: _get_size_metrics()[0],
lambda: _get_size_metrics()[1],
lambda: _get_size_metrics()[2],
lambda: _get_size_metrics()[3],
)
all_embeddings: List[List[float]] = []
total_batches = (len(texts) + EMBEDDING_BATCH_SIZE - 1) // EMBEDDING_BATCH_SIZE
for batch_idx in range(total_batches):
start = batch_idx * EMBEDDING_BATCH_SIZE
end = start + EMBEDDING_BATCH_SIZE
batch = texts[start:end]
for attempt in range(1, EMBEDDING_MAX_RETRIES + 1):
try:
batch_embeddings = await embedding_model.aembed(batch)
all_embeddings.extend(batch_embeddings)
break
except Exception as e:
cmd_context = f" (command: {command_id})" if command_id else ""
if attempt < EMBEDDING_MAX_RETRIES:
logger.debug(
f"Embedding batch {batch_idx + 1}/{total_batches} "
f"attempt {attempt}/{EMBEDDING_MAX_RETRIES} failed "
f"using model '{model_name}'{cmd_context}: {e}. Retrying..."
)
await asyncio.sleep(EMBEDDING_RETRY_DELAY)
else:
logger.debug(
f"Embedding batch {batch_idx + 1}/{total_batches} "
f"failed after {EMBEDDING_MAX_RETRIES} attempts "
f"using model '{model_name}'{cmd_context}: {e}"
)
raise RuntimeError(
f"Failed to generate embeddings using model '{model_name}' "
f"(batch {batch_idx + 1}/{total_batches}, "
f"{len(batch)} texts): {e}"
) from e
logger.debug(f"Generated {len(all_embeddings)} embeddings in {total_batches} batch(es)")
return all_embeddings
async def generate_embedding(
text: str,
content_type: Optional[ContentType] = None,
file_path: Optional[str] = None,
command_id: Optional[str] = None,
) -> List[float]:
"""
Generate a single embedding for text, handling large content via chunking and mean pooling.
For short text (<= CHUNK_SIZE tokens):
- Embeds directly and returns the embedding
For long text (> CHUNK_SIZE tokens):
- Chunks the text using appropriate splitter for content type
- Embeds all chunks in batches
- Combines embeddings via mean pooling
Args:
text: The text to embed
content_type: Optional explicit content type for chunking
file_path: Optional file path for content type detection
command_id: Optional command ID for error logging context
Returns:
Single embedding vector (list of floats)
Raises:
ValueError: If text is empty or no embedding model configured
RuntimeError: If embedding generation fails
"""
if not text or not text.strip():
raise ValueError("Cannot generate embedding for empty text")
text = text.strip()
text_tokens = token_count(text)
# Check if chunking is needed
if text_tokens <= CHUNK_SIZE:
# Short text - embed directly
logger.debug(f"Embedding short text ({text_tokens} tokens) directly")
embeddings = await generate_embeddings([text], command_id=command_id)
return embeddings[0]
# Long text - chunk and mean pool
logger.debug(f"Text exceeds chunk size ({text_tokens} tokens), chunking...")
chunks = chunk_text(text, content_type=content_type, file_path=file_path)
if not chunks:
raise ValueError("Text chunking produced no chunks")
if len(chunks) == 1:
# Single chunk after splitting
embeddings = await generate_embeddings(chunks, command_id=command_id)
return embeddings[0]
logger.debug(f"Embedding {len(chunks)} chunks and mean pooling")
# Embed all chunks in batches
embeddings = await generate_embeddings(chunks, command_id=command_id)
# Mean pool to get single embedding
pooled = await mean_pool_embeddings(embeddings)
logger.debug(f"Mean pooled {len(embeddings)} embeddings into single vector")
return pooled
+198
View File
@@ -0,0 +1,198 @@
"""
Field-level encryption for sensitive data using API keys.
This module provides encryption/decryption for API keys stored in the database.
Fernet uses AES-128-CBC with HMAC-SHA256 for authenticated encryption.
OPEN_NOTEBOOK_ENCRYPTION_KEY accepts **any string**. A Fernet key is derived
from it via SHA-256, so users can set a simple passphrase like
``OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret`` and it will work.
Usage:
# Encrypt before storing
encrypted = encrypt_value(api_key)
# Decrypt when reading
decrypted = decrypt_value(encrypted)
"""
import base64
import hashlib
import os
from pathlib import Path
from typing import Optional
from cryptography.fernet import Fernet, InvalidToken
from loguru import logger
def get_secret_from_env(var_name: str) -> Optional[str]:
"""
Get a secret from environment, supporting Docker secrets pattern.
Checks for VAR_FILE first (Docker secrets), then falls back to VAR.
Args:
var_name: Base name of the environment variable (e.g., "OPEN_NOTEBOOK_ENCRYPTION_KEY")
Returns:
The secret value, or None if not configured.
"""
# Check for _FILE variant first (Docker secrets)
file_path = os.environ.get(f"{var_name}_FILE")
if file_path:
try:
path = Path(file_path)
if path.exists() and path.is_file():
secret = path.read_text().strip()
if secret:
logger.debug(f"Loaded {var_name} from file: {file_path}")
return secret
else:
logger.warning(f"{var_name}_FILE points to empty file: {file_path}")
else:
logger.warning(f"{var_name}_FILE path does not exist: {file_path}")
except Exception as e:
logger.error(f"Failed to read {var_name} from file {file_path}: {e}")
# Fall back to direct environment variable
return os.environ.get(var_name)
def _get_or_create_encryption_key() -> str:
"""
Get encryption key from environment, requires explicit configuration.
Priority:
1. OPEN_NOTEBOOK_ENCRYPTION_KEY_FILE (Docker secrets)
2. OPEN_NOTEBOOK_ENCRYPTION_KEY (environment variable)
For production deployments, you MUST set OPEN_NOTEBOOK_ENCRYPTION_KEY explicitly!
Returns:
Encryption key string.
Raises:
ValueError: If no encryption key is configured.
"""
# First check environment/Docker secrets
key = get_secret_from_env("OPEN_NOTEBOOK_ENCRYPTION_KEY")
if key:
return key
raise ValueError(
"OPEN_NOTEBOOK_ENCRYPTION_KEY is not set. "
"Set this environment variable to any secret string to enable "
"encrypted storage of API keys in the database."
)
# Lazy-loaded encryption key: initialized on first use, not at import time.
# This prevents the entire app from crashing if the key is not yet configured
# when other modules import from this file.
_ENCRYPTION_KEY: Optional[str] = None
def _get_encryption_key() -> str:
"""Get the encryption key, initializing lazily on first call."""
global _ENCRYPTION_KEY
if _ENCRYPTION_KEY is None:
_ENCRYPTION_KEY = _get_or_create_encryption_key()
return _ENCRYPTION_KEY
def _ensure_fernet_key(key: str) -> str:
"""
Derive a valid Fernet key from an arbitrary string via SHA-256.
Any string is accepted as input. The key is derived by hashing it with
SHA-256 and encoding the result as URL-safe base64.
"""
derived = hashlib.sha256(key.encode()).digest()
return base64.urlsafe_b64encode(derived).decode()
def get_fernet() -> Fernet:
"""
Get Fernet instance with the configured encryption key.
Returns:
Fernet instance.
Raises:
ValueError: If encryption key is not configured.
"""
return Fernet(_ensure_fernet_key(_get_encryption_key()).encode())
def encrypt_value(value: str) -> str:
"""
Encrypt a string value using Fernet symmetric encryption.
Args:
value: The plain text string to encrypt.
Returns:
Base64-encoded encrypted string.
Raises:
ValueError: If encryption is not configured.
"""
fernet = get_fernet()
return fernet.encrypt(value.encode()).decode()
def looks_like_fernet_token(s: str) -> bool:
"""
Check if string looks like a Fernet encrypted token.
Fernet tokens are versioned (1 byte) + timestamp (8 bytes) + IV (16 bytes)
+ ciphertext (variable, multiple of 16 with PKCS7 padding) + HMAC (32 bytes).
Minimum decoded size is 73 bytes (1+8+16+16+32) for the smallest payload.
"""
if len(s) < 100: # Base64 of 73 bytes = ~100 chars minimum
return False
try:
decoded = base64.urlsafe_b64decode(s)
# Fernet: version(1) + timestamp(8) + IV(16) + ciphertext(>=16) + HMAC(32)
# Minimum 73 bytes, ciphertext must be multiple of 16 (AES block size)
if len(decoded) < 73:
return False
ciphertext_len = len(decoded) - 1 - 8 - 16 - 32
return ciphertext_len > 0 and ciphertext_len % 16 == 0
except Exception:
return False
def decrypt_value(value: str) -> str:
"""
Decrypt a Fernet-encrypted string value.
Handles graceful fallback for legacy unencrypted data.
Args:
value: The encrypted string (or plain text for legacy data).
Returns:
Decrypted plain text string, or original value if not encrypted.
Raises:
ValueError: If encryption is not configured or if decryption fails
for what appears to be encrypted data (wrong key).
"""
fernet = get_fernet()
try:
return fernet.decrypt(value.encode()).decode()
except InvalidToken:
if looks_like_fernet_token(value):
# Looks like encrypted data but failed to decrypt - likely wrong key
raise ValueError(
"Decryption failed: data appears to be encrypted but key is incorrect. "
"Check OPEN_NOTEBOOK_ENCRYPTION_KEY configuration."
)
# Not a valid token - treat as legacy plaintext
return value
except Exception as e:
logger.error(f"Decryption failed: {e}")
raise ValueError(f"Decryption failed: {str(e)}")
+103
View File
@@ -0,0 +1,103 @@
"""
Error classification utility for LLM provider errors.
Maps raw exceptions from AI providers/Esperanto/LangChain to user-friendly
error messages and appropriate exception types.
"""
from loguru import logger
from open_notebook.exceptions import (
AuthenticationError,
ConfigurationError,
ExternalServiceError,
NetworkError,
OpenNotebookError,
RateLimitError,
)
# Classification rules: (keywords, exception_class, user_message or None to pass through)
_CLASSIFICATION_RULES: list[tuple[list[str], type[OpenNotebookError], str | None]] = [
# Authentication errors
(
["authentication", "unauthorized", "invalid api key", "invalid_api_key", "401"],
AuthenticationError,
"Authentication failed. Please check your API key in Settings -> Credentials.",
),
# Rate limit errors
(
["rate limit", "rate_limit", "429", "too many requests", "quota exceeded"],
RateLimitError,
"Rate limit exceeded. Please wait a moment and try again.",
),
# Model not found (pass through original message)
(
["model not found", "does not exist", "model_not_found"],
ConfigurationError,
None,
),
# Configuration errors from provision.py (pass through)
(
["no model configured", "please go to settings"],
ConfigurationError,
None,
),
# Network errors
(
["connecterror", "timeoutexception", "connection refused", "connection error", "timed out", "timeout"],
NetworkError,
"Could not connect to the AI provider. Please check your network connection and provider URL.",
),
# Context length errors
(
["context length", "token limit", "maximum context", "context_length_exceeded", "max_tokens"],
ExternalServiceError,
"Content too large for the selected model. Try using a smaller selection or a model with a larger context window.",
),
# Payload too large errors
(
["413", "payload too large", "request entity too large"],
ExternalServiceError,
"The request payload is too large for the AI provider. Try reducing the content size or using a different model.",
),
# Provider availability errors
(
["500", "502", "503", "service unavailable", "overloaded", "internal server error"],
ExternalServiceError,
"The AI provider is temporarily unavailable. Please try again in a few minutes.",
),
]
def classify_error(exception: BaseException) -> tuple[type[OpenNotebookError], str]:
"""
Classify a raw exception into a user-friendly error type and message.
Args:
exception: Any exception from LLM providers/Esperanto/LangChain
Returns:
Tuple of (exception_class, user_friendly_message)
"""
error_str = str(exception).lower()
error_type_name = type(exception).__name__.lower()
combined = f"{error_type_name}: {error_str}"
for keywords, exc_class, message in _CLASSIFICATION_RULES:
for keyword in keywords:
if keyword in combined:
user_message = message if message is not None else _truncate(str(exception))
return exc_class, user_message
# Unclassified error - log for future improvement
logger.warning(
f"Unclassified LLM error ({type(exception).__name__}): {exception}"
)
return ExternalServiceError, f"AI service error: {_truncate(str(exception))}"
def _truncate(text: str, max_length: int = 200) -> str:
"""Truncate text to max_length to avoid leaking verbose internal details."""
if len(text) <= max_length:
return text
return text[:max_length] + "..."
+23
View File
@@ -0,0 +1,23 @@
import asyncio
from langchain_core.runnables import RunnableConfig
from loguru import logger
async def get_session_message_count(graph, session_id: str) -> int:
"""Get message count from LangGraph state, returns 0 on error."""
try:
# Use sync get_state() in a thread (SqliteSaver doesn't support async)
thread_state = await asyncio.to_thread(
graph.get_state,
config=RunnableConfig(configurable={"thread_id": session_id}),
)
if (
thread_state
and thread_state.values
and "messages" in thread_state.values
):
return len(thread_state.values["messages"])
except Exception as e:
logger.warning(f"Could not fetch message count for session {session_id}: {e}")
return 0
+15
View File
@@ -0,0 +1,15 @@
"""Utilities for working with Pydantic models."""
from pydantic import BaseModel
def full_model_dump(model):
"""Recursively dump Pydantic models nested inside dicts/lists to plain data."""
if isinstance(model, BaseModel):
return model.model_dump()
elif isinstance(model, dict):
return {k: full_model_dump(v) for k, v in model.items()}
elif isinstance(model, list):
return [full_model_dump(item) for item in model]
else:
return model
+145
View File
@@ -0,0 +1,145 @@
"""
Text utilities for Open Notebook.
Extracted from main utils to avoid circular imports.
"""
import re
import unicodedata
from typing import Tuple
# Patterns for matching thinking content in AI responses
# Standard pattern: <think>...</think>
THINK_PATTERN = re.compile(r"<think>(.*?)</think>", re.DOTALL)
# Pattern for malformed output: content</think> (missing opening tag)
THINK_PATTERN_NO_OPEN = re.compile(r"^(.*?)</think>", re.DOTALL)
def remove_non_ascii(text: str) -> str:
"""Remove non-ASCII characters from text."""
return re.sub(r"[^\x00-\x7F]+", "", text)
def remove_non_printable(text: str) -> str:
"""Remove non-printable characters from text."""
# Replace any special Unicode whitespace characters with a regular space
text = re.sub(r"[\u2000-\u200B\u202F\u205F\u3000]", " ", text)
# Replace unusual line terminators with a single newline
text = re.sub(r"[\u2028\u2029\r]", "\n", text)
# Remove control characters, except newlines and tabs
text = "".join(
char for char in text if unicodedata.category(char)[0] != "C" or char in "\n\t"
)
# Replace non-breaking spaces with regular spaces
text = text.replace("\xa0", " ").strip()
# Keep letters (including accented ones), numbers, spaces, newlines, tabs, and basic punctuation
return re.sub(r"[^\w\s.,!?\-\n\t]", "", text, flags=re.UNICODE)
def parse_thinking_content(content: str) -> Tuple[str, str]:
"""
Parse message content to extract thinking content from <think> tags.
Handles both well-formed tags and malformed output where the opening
<think> tag is missing but </think> is present.
Args:
content (str): The original message content
Returns:
Tuple[str, str]: (thinking_content, cleaned_content)
- thinking_content: Content from within <think> tags
- cleaned_content: Original content with <think> blocks removed
Example:
>>> content = "<think>Let me analyze this</think>Here's my answer"
>>> thinking, cleaned = parse_thinking_content(content)
>>> print(thinking)
"Let me analyze this"
>>> print(cleaned)
"Here's my answer"
"""
# Input validation
if not isinstance(content, str):
return "", str(content) if content is not None else ""
# Limit processing for very large content (100KB limit)
if len(content) > 100000:
return "", content
# Find all well-formed thinking blocks
thinking_matches = THINK_PATTERN.findall(content)
if thinking_matches:
# Join all thinking content with double newlines
thinking_content = "\n\n".join(match.strip() for match in thinking_matches)
# Remove all <think>...</think> blocks from the original content
cleaned_content = THINK_PATTERN.sub("", content)
# Clean up extra whitespace
cleaned_content = re.sub(r"\n\s*\n\s*\n", "\n\n", cleaned_content).strip()
return thinking_content, cleaned_content
# Handle malformed output: content</think> (missing opening tag)
# Some models like Nemotron output thinking without the opening <think> tag
malformed_match = THINK_PATTERN_NO_OPEN.match(content)
if malformed_match:
thinking_content = malformed_match.group(1).strip()
# Remove the thinking content and </think> tag
cleaned_content = content[malformed_match.end() :].strip()
return thinking_content, cleaned_content
return "", content
def clean_thinking_content(content: str) -> str:
"""
Remove thinking content from AI responses, returning only the cleaned content.
This is a convenience function for cases where you only need the cleaned
content and don't need access to the thinking process.
Args:
content (str): The original message content with potential <think> tags
Returns:
str: Content with <think> blocks removed and whitespace cleaned
Example:
>>> content = "<think>Let me think...</think>Here's the answer"
>>> clean_thinking_content(content)
"Here's the answer"
"""
_, cleaned_content = parse_thinking_content(content)
return cleaned_content
def extract_text_content(content) -> str:
"""Extract text from LLM response content.
Handles both plain string responses and structured content formats
(e.g. Gemini's envelope format):
[{'type': 'text', 'text': '...', 'extras': {...}}]
Args:
content: The content from an AI message, either a string or a list of parts.
Returns:
The extracted text content as a string.
"""
if isinstance(content, str):
return content
if isinstance(content, list):
text_parts = []
for part in content:
if isinstance(part, dict) and "text" in part:
text_parts.append(part["text"])
elif isinstance(part, str):
text_parts.append(part)
return "".join(text_parts)
return str(content)
+57
View File
@@ -0,0 +1,57 @@
"""
Token utilities for Open Notebook.
Handles token counting and cost calculations for language models.
"""
import os
from open_notebook.config import TIKTOKEN_CACHE_DIR
# Set tiktoken cache directory before importing tiktoken to ensure
# tokenizer encodings are cached persistently in the data folder
os.environ["TIKTOKEN_CACHE_DIR"] = TIKTOKEN_CACHE_DIR
def token_count(input_string: str) -> int:
"""
Count the number of tokens in the input string using the 'o200k_base' encoding.
Args:
input_string (str): The input string to count tokens for.
Returns:
int: The number of tokens in the input string.
"""
try:
import tiktoken
encoding = tiktoken.get_encoding("o200k_base")
# disallowed_special=() treats sequences like "<|endoftext|>" as ordinary
# text instead of raising ValueError. User/source content can legitimately
# contain these substrings, and we only need a token count here.
tokens = encoding.encode(input_string, disallowed_special=())
return len(tokens)
except (ImportError, OSError) as e:
# Fallback: handles ImportError (tiktoken not installed) AND network/OS
# errors such as urllib.error.URLError or ConnectionError raised in
# offline environments when the encoding file cannot be downloaded.
from loguru import logger
logger.warning(
"tiktoken unavailable, falling back to word-count estimation: {}", e
)
return int(len(input_string.split()) * 1.3)
def token_cost(token_count: int, cost_per_million: float = 0.150) -> float:
"""
Calculate the cost of tokens based on the token count and cost per million tokens.
Args:
token_count (int): The number of tokens.
cost_per_million (float): The cost per million tokens. Default is 0.150.
Returns:
float: The calculated cost for the given token count.
"""
return cost_per_million * (token_count / 1_000_000)
+136
View File
@@ -0,0 +1,136 @@
"""
URL validation shared by the credentials API and the AI provisioning layer.
Lives in open_notebook.utils (not api/) so it can be imported both by the API
layer (credential create/update, source-URL ingestion) and by open_notebook's
own AI layer (connection_tester.py, ModelManager) to re-validate a
provider-configured URL immediately before it is actually used - closing most
of the DNS-rebinding TOCTOU window left by validating only once, at save time.
"""
import asyncio
import ipaddress
import socket
from urllib.parse import urlparse
# AWS's IMDSv6 metadata endpoint. Unlike the IPv4 metadata address
# (169.254.169.254), this is a Unique Local Address, not link-local, so it is
# not caught by ip.is_link_local and must be checked explicitly.
_AWS_IMDS_V6_ADDRESS = ipaddress.ip_address("fd00:ec2::254")
async def validate_url(url: str, provider: str) -> None:
"""
Validate URL format for API endpoints.
This is a self-hosted application, so we allow:
- Private IPs (10.x, 172.16-31.x, 192.168.x) for self-hosted services
- Localhost for local services (Ollama, LM Studio, etc.)
We only block:
- Invalid schemes (must be http or https)
- Malformed URLs
- Link-local addresses (169.254.x.x) - used for cloud metadata endpoints
- AWS's IPv6 metadata address (fd00:ec2::254)
- Hostnames that resolve to any of the above
Args:
url: The URL to validate
provider: The provider name (for logging/context)
Raises:
ValueError: If the URL is invalid
"""
if not url or not url.strip():
return # Empty URLs handled elsewhere
try:
parsed = urlparse(url.strip())
# Validate scheme - only http/https allowed
if parsed.scheme not in ("http", "https"):
raise ValueError(
f"Invalid URL scheme: '{parsed.scheme}'. Only http and https are allowed."
)
# Extract hostname
hostname = parsed.hostname
if not hostname:
raise ValueError("Invalid URL: hostname could not be determined.")
# Try to parse as IP address to check for dangerous addresses
try:
ip = ipaddress.ip_address(hostname)
_reject_dangerous_ip(ip, hostname)
except ValueError as ve:
# Re-raise our own ValueErrors
if "Link-local" in str(ve) or "Invalid URL" in str(ve) or "metadata" in str(ve):
raise
# Not an IP address, it's a hostname - need to resolve and check
try:
# Resolve hostname to IP address. This is a blocking call -
# run it off the event loop so a slow/hanging DNS lookup
# doesn't stall every other concurrent request (this is
# called on the hot path of model provisioning, potentially
# once per chat message/transformation).
resolved_ips = await asyncio.to_thread(socket.getaddrinfo, hostname, None)
for family, _, _, _, sockaddr in resolved_ips:
ip_addr = sockaddr[0]
try:
parsed_ip = ipaddress.ip_address(ip_addr)
_reject_dangerous_ip(parsed_ip, hostname, resolved=True)
except ValueError as inner_ve:
if "link-local" in str(inner_ve).lower() or "metadata" in str(inner_ve).lower():
raise
# Skip non-IP addresses (e.g., IPv6 zones)
continue
except socket.gaierror:
# Could not resolve hostname - allow it since the URL may be
# valid in the deployment environment (e.g., Azure endpoints,
# internal DNS names). We only block link-local addresses.
pass
except ValueError:
raise
except Exception:
raise ValueError("Invalid URL format. Check server logs for details.")
def _reject_dangerous_ip(
ip: "ipaddress.IPv4Address | ipaddress.IPv6Address",
hostname: str,
resolved: bool = False,
) -> None:
"""Raise ValueError if `ip` is a link-local or cloud-metadata address."""
is_ipv4_mapped_link_local = (
hasattr(ip, "ipv4_mapped") and ip.ipv4_mapped and ip.ipv4_mapped.is_link_local
)
# Block link-local addresses (169.254.x.x / fe80::/10) - used for cloud
# metadata - including IPv4-mapped IPv6 addresses pointing to link-local
# (e.g. ::ffff:169.254.169.254 bypasses IPv6 is_link_local check).
if ip.is_link_local or is_ipv4_mapped_link_local:
if resolved:
raise ValueError(
f"Hostname '{hostname}' resolves to a link-local address (169.254.x.x) "
"which is not allowed for security reasons. These addresses are used "
"for cloud metadata endpoints."
)
raise ValueError(
"Link-local addresses (169.254.x.x) are not allowed for security reasons. "
"These addresses are used for cloud metadata endpoints."
)
# Block AWS's IMDSv6 metadata address - a Unique Local Address, not
# link-local, so it needs its own explicit check.
if ip == _AWS_IMDS_V6_ADDRESS:
if resolved:
raise ValueError(
f"Hostname '{hostname}' resolves to the AWS IMDSv6 metadata address "
"(fd00:ec2::254), which is not allowed for security reasons."
)
raise ValueError(
"The AWS IMDSv6 metadata address (fd00:ec2::254) is not allowed for "
"security reasons."
)
+153
View File
@@ -0,0 +1,153 @@
"""
Version utilities for Open Notebook.
Handles version comparison, GitHub version fetching, and package version management.
"""
from importlib.metadata import PackageNotFoundError, version
from urllib.parse import urlparse
import requests # type: ignore
import tomli
from packaging.version import parse as parse_version
async def get_version_from_github_async(repo_url: str, branch: str = "main") -> str:
"""
Fetch and parse the version from pyproject.toml in a public GitHub repository (async).
"""
from urllib.parse import urlparse
import httpx
import tomli
# Parse the GitHub URL
parsed_url = urlparse(repo_url)
if "github.com" not in parsed_url.netloc:
raise ValueError("Not a GitHub URL")
# Extract owner and repo name from path
path_parts = parsed_url.path.strip("/").split("/")
if len(path_parts) < 2:
raise ValueError("Invalid GitHub repository URL")
owner, repo = path_parts[0], path_parts[1]
# Construct raw content URL for pyproject.toml
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/pyproject.toml"
# Fetch the file with timeout using httpx
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(raw_url)
response.raise_for_status()
# Parse TOML content
pyproject_data = tomli.loads(response.text)
# Try to find version
try:
# Check tool.poetry.version
version_str = pyproject_data["tool"]["poetry"]["version"]
except KeyError:
try:
# Check project.version
version_str = pyproject_data["project"]["version"]
except KeyError:
raise KeyError("Version not found in pyproject.toml")
return version_str
def get_version_from_github(repo_url: str, branch: str = "main") -> str:
"""
Fetch and parse the version from pyproject.toml in a public GitHub repository.
Args:
repo_url (str): URL of the GitHub repository
branch (str): Branch name to fetch from (defaults to "main")
Returns:
str: Version string from pyproject.toml
Raises:
ValueError: If the URL is not a valid GitHub repository URL
requests.RequestException: If there's an error fetching the file
KeyError: If version information is not found in pyproject.toml
"""
# Parse the GitHub URL
parsed_url = urlparse(repo_url)
if "github.com" not in parsed_url.netloc:
raise ValueError("Not a GitHub URL")
# Extract owner and repo name from path
path_parts = parsed_url.path.strip("/").split("/")
if len(path_parts) < 2:
raise ValueError("Invalid GitHub repository URL")
owner, repo = path_parts[0], path_parts[1]
# Construct raw content URL for pyproject.toml
raw_url = (
f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/pyproject.toml"
)
# Fetch the file with timeout
response = requests.get(raw_url, timeout=10)
response.raise_for_status()
# Parse TOML content
pyproject_data = tomli.loads(response.text)
# Try to find version in different possible locations
try:
# Check project.version first (poetry style)
version = pyproject_data["tool"]["poetry"]["version"]
except KeyError:
try:
# Check project.version (standard style)
version = pyproject_data["project"]["version"]
except KeyError:
raise KeyError("Version not found in pyproject.toml")
return version
def get_installed_version(package_name: str) -> str:
"""
Get the version of an installed package.
Args:
package_name (str): Name of the installed package
Returns:
str: Version string of the installed package
Raises:
PackageNotFoundError: If the package is not installed
"""
try:
return version(package_name)
except PackageNotFoundError:
raise PackageNotFoundError(f"Package '{package_name}' not found")
def compare_versions(version1: str, version2: str) -> int:
"""
Compare two semantic versions.
Args:
version1 (str): First version string
version2 (str): Second version string
Returns:
int: -1 if version1 < version2
0 if version1 == version2
1 if version1 > version2
"""
v1 = parse_version(version1)
v2 = parse_version(version2)
if v1 < v2:
return -1
elif v1 > v2:
return 1
else:
return 0