import json import time import random from functools import wraps from typing import Any, Callable, Optional, Tuple, Type from .exceptions import ValidationError from .config import ( RETRY_MAX_ATTEMPTS, RETRY_BACKOFF_FACTOR, SEARCH_MODES, SEARCH_SOURCES, MODEL_MAPPINGS, RATE_LIMIT_MIN_DELAY, RATE_LIMIT_MAX_DELAY, ) from .logger import get_logger logger = get_logger("utils") def retry_with_backoff( max_attempts: int = RETRY_MAX_ATTEMPTS, backoff_factor: float = RETRY_BACKOFF_FACTOR, exceptions: Tuple[Type[Exception], ...] = (Exception,), on_retry: Optional[Callable[[int, Exception], None]] = None, ) -> Callable: def decorator(func: Callable) -> Callable: @wraps(func) def wrapper(*args: Any, **kwargs: Any) -> Any: attempt = 0 while attempt < max_attempts: try: return func(*args, **kwargs) except exceptions as e: attempt += 1 if attempt >= max_attempts: logger.error(f"Failed after {max_attempts} attempts: {e}") raise wait_time = backoff_factor ** attempt + random.uniform(0, 1) logger.warning( f"Attempt {attempt}/{max_attempts} failed: {e}. " f"Retrying in {wait_time:.2f}s..." ) if on_retry: on_retry(attempt, e) time.sleep(wait_time) raise Exception(f"Failed after {max_attempts} attempts") return wrapper return decorator def rate_limit( min_delay: float = RATE_LIMIT_MIN_DELAY, max_delay: float = RATE_LIMIT_MAX_DELAY, ) -> Callable: def decorator(func: Callable) -> Callable: last_call = [0.0] @wraps(func) def wrapper(*args: Any, **kwargs: Any) -> Any: delay = random.uniform(min_delay, max_delay) elapsed = time.time() - last_call[0] if elapsed < delay: sleep_time = delay - elapsed logger.debug(f"Rate limiting: waiting {sleep_time:.2f}s") time.sleep(sleep_time) last_call[0] = time.time() return func(*args, **kwargs) return wrapper return decorator def validate_search_params( mode: str, model: Optional[str], sources: list, own_account: bool = False, ) -> None: if mode not in SEARCH_MODES: raise ValidationError(f"Invalid mode '{mode}'. Must be one of: {', '.join(SEARCH_MODES)}") if model is not None: valid_models = list(MODEL_MAPPINGS.get(mode, {}).keys()) if model not in valid_models: raise ValidationError( f"Invalid model '{model}' for mode '{mode}'. " f"Valid models: {', '.join(str(m) for m in valid_models)}" ) if model is not None and not own_account: raise ValidationError( "Model selection requires an account with cookies. " "Initialize Client with cookies parameter." ) invalid_sources = [s for s in sources if s not in SEARCH_SOURCES] if invalid_sources: raise ValidationError( f"Invalid sources: {', '.join(invalid_sources)}. " f"Valid sources: {', '.join(SEARCH_SOURCES)}" ) if not sources: raise ValidationError("At least one source must be specified") def validate_query_limits( copilot_remaining: int, file_upload_remaining: int, mode: str, files_count: int, ) -> None: if mode in ["pro", "reasoning", "deep research"] and copilot_remaining <= 0: raise ValidationError( f"No remaining enhanced queries for mode '{mode}'. " f"Create a new account or use mode='auto'." ) if files_count > 0 and file_upload_remaining < files_count: raise ValidationError( f"Insufficient file uploads. Requested: {files_count}, " f"Available: {file_upload_remaining}" ) def sanitize_query(query: str) -> str: if not isinstance(query, str): raise ValidationError(f"Query must be string, got {type(query)}") query = query.strip() if not query: raise ValidationError("Query cannot be empty") if len(query) > 10000: raise ValidationError("Query is too long (max 10000 characters)") return query def parse_nested_json_response(content_json: dict) -> dict: if "text" in content_json and content_json["text"]: try: text_parsed = json.loads(content_json["text"]) if isinstance(text_parsed, list): for step in text_parsed: if step.get("step_type") == "FINAL": final_content = step.get("content", {}) if "answer" in final_content: try: answer_data = json.loads(final_content["answer"]) content_json["answer"] = answer_data.get("answer", "") content_json["chunks"] = answer_data.get("chunks", []) except (json.JSONDecodeError, TypeError): pass break content_json["text"] = text_parsed except (json.JSONDecodeError, TypeError, KeyError): pass return content_json