mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-27 04:20:09 +00:00
367 lines
11 KiB
Python
367 lines
11 KiB
Python
"""FastMCP-compatible pagination implementation.
|
|
|
|
This module provides pagination utilities optimized for FastMCP with minimal latency.
|
|
"""
|
|
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Dict, Generic, List, Optional, TypeVar, Union
|
|
|
|
T = TypeVar("T")
|
|
|
|
|
|
@dataclass
|
|
class CursorData:
|
|
"""Cursor data structure for efficient pagination."""
|
|
|
|
# Primary cursor fields (indexed)
|
|
last_id: Optional[str] = None
|
|
last_timestamp: Optional[float] = None
|
|
offset: int = 0
|
|
|
|
# Metadata for validation and optimization
|
|
page_size: int = 100
|
|
sort_field: str = "id"
|
|
sort_order: str = "asc"
|
|
|
|
# Security and validation
|
|
created_at: float = field(default_factory=time.time)
|
|
expires_at: Optional[float] = None
|
|
checksum: Optional[str] = None
|
|
|
|
def to_cursor(self) -> str:
|
|
"""Convert to opaque cursor string."""
|
|
data = {
|
|
"id": self.last_id,
|
|
"ts": self.last_timestamp,
|
|
"o": self.offset,
|
|
"ps": self.page_size,
|
|
"sf": self.sort_field,
|
|
"so": self.sort_order,
|
|
"ca": self.created_at,
|
|
}
|
|
if self.expires_at:
|
|
data["ea"] = self.expires_at
|
|
|
|
# Add checksum for integrity
|
|
data_str = json.dumps(data, sort_keys=True, separators=(",", ":"))
|
|
data["cs"] = hashlib.md5(data_str.encode()).hexdigest()[:8]
|
|
|
|
# Encode as base64
|
|
final_str = json.dumps(data, separators=(",", ":"))
|
|
return base64.urlsafe_b64encode(final_str.encode()).decode().rstrip("=")
|
|
|
|
@classmethod
|
|
def from_cursor(cls, cursor: str) -> Optional["CursorData"]:
|
|
"""Parse cursor string back to CursorData."""
|
|
try:
|
|
# Add padding if needed
|
|
padding = 4 - (len(cursor) % 4)
|
|
if padding != 4:
|
|
cursor += "=" * padding
|
|
|
|
decoded = base64.urlsafe_b64decode(cursor.encode())
|
|
data = json.loads(decoded)
|
|
|
|
# Validate checksum
|
|
checksum = data.pop("cs", None)
|
|
if checksum:
|
|
data_str = json.dumps(
|
|
{k: v for k, v in data.items() if k != "cs"},
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
)
|
|
expected = hashlib.md5(data_str.encode()).hexdigest()[:8]
|
|
if checksum != expected:
|
|
return None
|
|
|
|
# Check expiration
|
|
expires_at = data.get("ea")
|
|
if expires_at and time.time() > expires_at:
|
|
return None
|
|
|
|
return cls(
|
|
last_id=data.get("id"),
|
|
last_timestamp=data.get("ts"),
|
|
offset=data.get("o", 0),
|
|
page_size=data.get("ps", 100),
|
|
sort_field=data.get("sf", "id"),
|
|
sort_order=data.get("so", "asc"),
|
|
created_at=data.get("ca", time.time()),
|
|
expires_at=expires_at,
|
|
)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
class FastMCPPaginator(Generic[T]):
|
|
"""High-performance paginator for FastMCP responses."""
|
|
|
|
def __init__(
|
|
self,
|
|
page_size: int = 100,
|
|
max_page_size: int = 1000,
|
|
cursor_ttl: int = 3600, # 1 hour
|
|
enable_prefetch: bool = False,
|
|
):
|
|
"""Initialize the paginator.
|
|
|
|
Args:
|
|
page_size: Default page size
|
|
max_page_size: Maximum allowed page size
|
|
cursor_ttl: Cursor time-to-live in seconds
|
|
enable_prefetch: Enable prefetching for next page
|
|
"""
|
|
self.page_size = page_size
|
|
self.max_page_size = max_page_size
|
|
self.cursor_ttl = cursor_ttl
|
|
self.enable_prefetch = enable_prefetch
|
|
self._cache: Dict[str, Any] = {}
|
|
|
|
def paginate_list(
|
|
self,
|
|
items: List[T],
|
|
cursor: Optional[str] = None,
|
|
page_size: Optional[int] = None,
|
|
sort_key: Optional[str] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Paginate a list with optimal performance.
|
|
|
|
Args:
|
|
items: List to paginate
|
|
cursor: Optional cursor from previous request
|
|
page_size: Override default page size
|
|
sort_key: Sort field for consistent ordering
|
|
|
|
Returns:
|
|
Dict with items and optional nextCursor
|
|
"""
|
|
# Parse cursor or create new
|
|
cursor_data = CursorData.from_cursor(cursor) if cursor else CursorData()
|
|
|
|
# Use provided page size or default
|
|
actual_page_size = min(
|
|
page_size or cursor_data.page_size or self.page_size, self.max_page_size
|
|
)
|
|
|
|
# Get starting position
|
|
start_idx = cursor_data.offset
|
|
|
|
# Validate bounds
|
|
if start_idx >= len(items):
|
|
return {"items": [], "hasMore": False}
|
|
|
|
# Slice the page
|
|
end_idx = min(start_idx + actual_page_size, len(items))
|
|
page_items = items[start_idx:end_idx]
|
|
|
|
# Build response
|
|
response = {
|
|
"items": page_items,
|
|
"pageInfo": {
|
|
"startIndex": start_idx,
|
|
"endIndex": end_idx,
|
|
"pageSize": len(page_items),
|
|
"totalItems": len(items),
|
|
},
|
|
}
|
|
|
|
# Create next cursor if more items exist
|
|
if end_idx < len(items):
|
|
next_cursor_data = CursorData(
|
|
offset=end_idx,
|
|
page_size=actual_page_size,
|
|
expires_at=time.time() + self.cursor_ttl if self.cursor_ttl else None,
|
|
)
|
|
response["nextCursor"] = next_cursor_data.to_cursor()
|
|
response["hasMore"] = True
|
|
else:
|
|
response["hasMore"] = False
|
|
|
|
return response
|
|
|
|
def paginate_query(
|
|
self,
|
|
query_func,
|
|
cursor: Optional[str] = None,
|
|
page_size: Optional[int] = None,
|
|
**query_params,
|
|
) -> Dict[str, Any]:
|
|
"""Paginate results from a query function.
|
|
|
|
This is optimized for database queries using indexed fields.
|
|
|
|
Args:
|
|
query_func: Function that accepts (last_id, last_timestamp, limit, **params)
|
|
cursor: Optional cursor
|
|
page_size: Override page size
|
|
**query_params: Additional query parameters
|
|
|
|
Returns:
|
|
Paginated response
|
|
"""
|
|
# Parse cursor
|
|
cursor_data = CursorData.from_cursor(cursor) if cursor else CursorData()
|
|
|
|
# Determine page size
|
|
limit = min(
|
|
page_size or cursor_data.page_size or self.page_size, self.max_page_size
|
|
)
|
|
|
|
# Execute query with cursor position
|
|
results = query_func(
|
|
last_id=cursor_data.last_id,
|
|
last_timestamp=cursor_data.last_timestamp,
|
|
limit=limit + 1, # Fetch one extra to detect more
|
|
**query_params,
|
|
)
|
|
|
|
# Check if there are more results
|
|
has_more = len(results) > limit
|
|
if has_more:
|
|
results = results[:limit] # Remove the extra item
|
|
|
|
# Build response
|
|
response = {
|
|
"items": results,
|
|
"pageInfo": {"pageSize": len(results), "hasMore": has_more},
|
|
}
|
|
|
|
# Create next cursor if needed
|
|
if has_more and results:
|
|
last_item = results[-1]
|
|
next_cursor_data = CursorData(
|
|
last_id=getattr(last_item, "id", None),
|
|
last_timestamp=getattr(last_item, "timestamp", None),
|
|
page_size=limit,
|
|
sort_field=cursor_data.sort_field,
|
|
sort_order=cursor_data.sort_order,
|
|
expires_at=time.time() + self.cursor_ttl if self.cursor_ttl else None,
|
|
)
|
|
response["nextCursor"] = next_cursor_data.to_cursor()
|
|
|
|
return response
|
|
|
|
|
|
class TokenAwarePaginator:
|
|
"""Paginator that respects token limits for LLM responses."""
|
|
|
|
def __init__(self, max_tokens: int = 20000):
|
|
"""Initialize token-aware paginator.
|
|
|
|
Args:
|
|
max_tokens: Maximum tokens per response
|
|
"""
|
|
self.max_tokens = max_tokens
|
|
self.paginator = FastMCPPaginator()
|
|
|
|
def paginate_by_tokens(
|
|
self, items: List[Any], cursor: Optional[str] = None, estimate_func=None
|
|
) -> Dict[str, Any]:
|
|
"""Paginate items based on token count.
|
|
|
|
Args:
|
|
items: Items to paginate
|
|
cursor: Optional cursor
|
|
estimate_func: Function to estimate tokens for an item
|
|
|
|
Returns:
|
|
Paginated response
|
|
"""
|
|
from hanzo_mcp.tools.common.truncate import estimate_tokens
|
|
|
|
# Default token estimation
|
|
if not estimate_func:
|
|
|
|
def estimate_func(x):
|
|
return estimate_tokens(json.dumps(x) if not isinstance(x, str) else x)
|
|
|
|
# Parse cursor
|
|
cursor_data = CursorData.from_cursor(cursor) if cursor else CursorData()
|
|
start_idx = cursor_data.offset
|
|
|
|
# Build page respecting token limit
|
|
page_items = []
|
|
current_tokens = 100 # Base overhead
|
|
current_idx = start_idx
|
|
|
|
while current_idx < len(items) and current_tokens < self.max_tokens:
|
|
item = items[current_idx]
|
|
item_tokens = estimate_func(item)
|
|
|
|
# Check if adding this item would exceed limit
|
|
if current_tokens + item_tokens > self.max_tokens and page_items:
|
|
break
|
|
|
|
page_items.append(item)
|
|
current_tokens += item_tokens
|
|
current_idx += 1
|
|
|
|
# Build response
|
|
response = {
|
|
"items": page_items,
|
|
"pageInfo": {
|
|
"itemCount": len(page_items),
|
|
"estimatedTokens": current_tokens,
|
|
"hasMore": current_idx < len(items),
|
|
},
|
|
}
|
|
|
|
# Add next cursor if needed
|
|
if current_idx < len(items):
|
|
next_cursor_data = CursorData(offset=current_idx)
|
|
response["nextCursor"] = next_cursor_data.to_cursor()
|
|
|
|
return response
|
|
|
|
|
|
# FastMCP integration helpers
|
|
def create_paginated_response(
|
|
items: Union[List[Any], Dict[str, Any], str],
|
|
cursor: Optional[str] = None,
|
|
page_size: int = 100,
|
|
use_token_limit: bool = True,
|
|
) -> Dict[str, Any]:
|
|
"""Create a paginated response compatible with FastMCP.
|
|
|
|
Args:
|
|
items: The items to paginate
|
|
cursor: Optional cursor from request
|
|
page_size: Items per page
|
|
use_token_limit: Whether to use token-based pagination
|
|
|
|
Returns:
|
|
FastMCP-compatible paginated response
|
|
"""
|
|
if use_token_limit:
|
|
paginator = TokenAwarePaginator()
|
|
|
|
# Convert different types to list
|
|
if isinstance(items, str):
|
|
# Split string by lines for pagination
|
|
items = items.split("\n")
|
|
elif isinstance(items, dict):
|
|
# Convert dict to list of key-value pairs
|
|
items = [{"key": k, "value": v} for k, v in items.items()]
|
|
|
|
return paginator.paginate_by_tokens(items, cursor)
|
|
else:
|
|
paginator = FastMCPPaginator(page_size=page_size)
|
|
|
|
# Handle different input types
|
|
if isinstance(items, list):
|
|
return paginator.paginate_list(items, cursor, page_size)
|
|
else:
|
|
# Convert to list first
|
|
if isinstance(items, str):
|
|
items = items.split("\n")
|
|
elif isinstance(items, dict):
|
|
items = [{"key": k, "value": v} for k, v in items.items()]
|
|
else:
|
|
items = [items]
|
|
|
|
return paginator.paginate_list(items, cursor, page_size)
|