diff --git a/src/aipass/api/.aipass/README.md b/src/aipass/api/.aipass/README.md deleted file mode 100644 index db729ac8..00000000 --- a/src/aipass/api/.aipass/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Branch Prompt - -AI context for `API`. The `aipass_local_prompt.md` file is injected every turn, telling the AI who you are and how to work in your branch. diff --git a/src/aipass/api/.aipass/aipass_local_prompt.md b/src/aipass/api/.aipass/aipass_local_prompt.md deleted file mode 100644 index c55fbffa..00000000 --- a/src/aipass/api/.aipass/aipass_local_prompt.md +++ /dev/null @@ -1,46 +0,0 @@ -# API Branch — Local Context - -## Identity - -API is the **centralized external API gateway** for AIPass. Provides authenticated service clients for external APIs. Consumers import ready-to-use clients — API owns the plumbing, consumers own the business logic. - -## Key Breadcrumbs - -- **Credentials live at** `~/.secrets/aipass/` — `google_creds.json`, `google_client_secret.json`, `.env` -- **Design rule:** If it's not auth, credentials, or service factory — it doesn't belong here. See DPLAN-0036 for the full rationale and old Telegram anti-pattern. -- **Provider pattern:** One module per provider (`openrouter_client.py`, `google_client.py`), one handler directory per provider (`openrouter/`, `google/`). Module orchestrates, handlers implement. -- **No default models/configs** — consumers provide their own. API provides the connection. -- **Thread-safe mode:** `get_drive_service(thread_safe=True)` loads fresh creds from disk per call for concurrent workers. -- **Google libs are optional deps** — guarded by `GOOGLE_AUTH_AVAILABLE` flag, commands fail explicitly with install instructions. -- **After building:** Run `drone @seedgo audit aipass @api` before reporting complete. - -## Commands - -| Command | Description | -|---|---| -| `get-key [provider]` | Retrieve API key (default: openrouter) | -| `validate [provider]` | Validate API key (default: openrouter) | -| `validate google` | Validate Google OAuth2 credentials | -| `reauth google` | Re-authenticate Google OAuth2 | -| `test` | Test OpenRouter connection | -| `models [--all]` | List available models | -| `status` | OpenRouter client status | -| `call "prompt" --model MODEL` | Make API call | -| `list-providers` | List available providers | -| `init` | Create .env template | -| `track [caller]` | Track generation usage | -| `stats` | Usage statistics | -| `session` | Session usage summary | -| `caller-usage ` | Usage by caller | -| `cleanup [days]` | Clean old usage data (default: 30) | - -## Architecture - -``` -api.py (entry, routing) - → modules/ (orchestration, CLI) - → handlers/ (business logic) -``` - -**Modules:** `api_key`, `openrouter_client`, `usage_tracker`, `google_client` -**Handlers:** `auth/`, `config/`, `openrouter/`, `usage/`, `json/`, `google/` diff --git a/src/aipass/api/.claude/README.md b/src/aipass/api/.claude/README.md deleted file mode 100644 index dd9e96b4..00000000 --- a/src/aipass/api/.claude/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Claude Code Settings - -Claude Code configuration for `API`. - -Contains `settings.local.json` with permission rules. Most branches are denied raw git commands and must use `drone @git` instead. diff --git a/src/aipass/api/.gitignore b/src/aipass/api/.gitignore deleted file mode 100644 index 9cf1dfc4..00000000 --- a/src/aipass/api/.gitignore +++ /dev/null @@ -1,14 +0,0 @@ -__pycache__/ -*.pyc -*.pyo -.env -*.egg-info/ -.coverage -htmlcov/ -.pytest_cache/ -.mypy_cache/ -dist/ -build/ -*.log -*.tmp -*.swp diff --git a/src/aipass/api/.seedgo/README.md b/src/aipass/api/.seedgo/README.md deleted file mode 100644 index 59fdc4be..00000000 --- a/src/aipass/api/.seedgo/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Standards Bypass - -Seedgo audit bypass config for `API`. - -When an audit flags a false positive that doesn't apply to your architecture, add a bypass entry in `bypass.json` with a reason explaining why it's justified. diff --git a/src/aipass/api/.seedgo/bypass.json b/src/aipass/api/.seedgo/bypass.json deleted file mode 100644 index 63cb4b9c..00000000 --- a/src/aipass/api/.seedgo/bypass.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "metadata": { - "version": "1.0.0", - "created": "2026-03-07T22:43:23.516578", - "description": "Standards bypass configuration for this branch" - }, - "bypass": [ - { - "file": "apps/modules/usage_tracker.py", - "standard": "deep_nesting", - "reason": "handle_command() depth 4 — standard command dispatcher pattern, depth is horizontal if/elif not vertical nesting" - }, - { - "file": "apps/handlers/usage/tracking.py", - "standard": "deep_nesting", - "reason": "track_usage() depth 4 — clean linear logic, depth from defensive try-except" - }, - { - "file": "apps/handlers/google/retry.py", - "standard": "deep_nesting", - "reason": "api_call_with_retry() depth 5 — standard retry-with-backoff pattern, nesting is inherent to retry loops" - }, - { - "file": "apps/handlers/openrouter/caller.py", - "standard": "deep_nesting", - "reason": "3 functions: get_caller_info() depth 5 (stack inspection), get_json_folder_path() depth 5 (caller pattern matching), detect_caller_category() depth 4 (path-to-category mapping) — all inherent complexity" - }, - { - "file": "apps/handlers/openrouter/client.py", - "standard": "deep_nesting", - "reason": "make_api_request() depth 4 — standard retry pattern with error handling" - }, - { - "file": "apps/handlers/auth/keys.py", - "standard": "deep_nesting", - "reason": "2 functions: _read_key_from_secrets() depth 5 (file read with validation guards), get_key_from_config() depth 4 (config navigation with nested dict structure)" - }, - { - "file": "apps/handlers/openrouter/client.py", - "standard": "handlers", - "reason": "Imports get_api_key from auth/keys — same-branch handler cross-package import needed for API key retrieval before making requests" - }, - { - "file": "apps/handlers/usage/tracking.py", - "standard": "handlers", - "reason": "Imports get_api_key from auth/keys — same-branch handler cross-package import needed for usage tracking with API key context" - }, - { - "file": "apps/handlers/config/provider.py", - "standard": "handlers", - "reason": "Imports load_json/save_json from json/json_handler — same-branch handler cross-package import for config file operations" - }, - { - "file": "apps/modules/google_client.py", - "standard": "documentation", - "reason": "False positive — get_google_service() has full Google-style docstring but multi-line signature (6 lines) exceeds checker's 5-line lookahead window" - }, - { - "file": "apps/handlers/json/json_handler.py", - "standard": "naming", - "reason": "json_handler.py is the system-wide default handler — standard name used across all 15 branches, cannot be renamed without breaking all imports" - }, - { - "file": "apps/handlers/google/auth.py", - "standard": "naming", - "reason": "False positive — effective_scopes is a local variable inside load_credentials() and run_oauth_flow(), not a module-level constant" - }, - { - "file": "apps/handlers/google/retry.py", - "standard": "naming", - "reason": "False positive — wait is a local variable inside api_call_with_retry() loop body, not a module-level constant" - }, - { - "file": "apps/modules/google_client.py", - "standard": "unused_function", - "reason": "Public API exports for cross-branch consumption (get_drive_service, authenticate_google, validate_google, reauth_google). Tested in test_google_client.py. Backup migration pending." - }, - { - "file": "apps/modules/openrouter_client.py", - "standard": "modules", - "reason": "make_call() is a CLI orchestrator — parses args then delegates to client.get_response(). All business logic lives in handlers/openrouter/client.py." - } - ], - "notes": { - "usage": "Add entries to 'bypass' list to exclude specific violations", - "example": { - "file": "apps/modules/logger.py", - "standard": "cli", - "reason": "Circular dependency - logger cannot import CLI", - "lines": [ - 146, - 177 - ], - "pattern": "if __name__ == '__main__'" - }, - "fields": { - "file": "Relative path from branch root (required)", - "standard": "Standard name: cli, imports, naming, etc. (required)", - "lines": "Optional - specific line numbers to bypass", - "pattern": "Optional - pattern to match (e.g. 'if __name__')", - "reason": "Required - why this bypass exists" - } - } -} diff --git a/src/aipass/api/README.md b/src/aipass/api/README.md deleted file mode 100644 index ef4c5d4a..00000000 --- a/src/aipass/api/README.md +++ /dev/null @@ -1,140 +0,0 @@ -[← Back to AIPass](../../../README.md) - -# API - -**Purpose:** Centralized external API gateway — authenticated service clients for all external APIs (OpenRouter, Google, future providers). -**Module:** `aipass.api` -**Last Updated:** 2026-04-07 - ---- - -## Overview - -### What I Do -- Provide authenticated service clients for external APIs (Google Drive, OpenRouter, etc.) -- Manage OAuth2 flows, credential storage, and token refresh -- Offer thread-safe service factories for concurrent consumers -- Handle API key management and validation across providers -- Provide SSL retry and connection resilience utilities - -### What I Don't Do -- Host business logic — consumers own what they do with the service -- Set default models or configs — consumers provide their own -- Manage application workflows, polling loops, or orchestration - -### How I Work -- **Entry Point:** `apps/api.py` -- auto-discovers and routes to modules -- **Pattern:** Standard AIPass 3-tier architecture (entry point / modules / handlers) -- **Design principle:** If it's not auth, credentials, or service factory — it doesn't belong here - ---- - -## Commands / Usage - -```bash -drone @api get-key # Retrieve API key for provider -drone @api validate # Validate API credentials and connection -drone @api validate google # Validate Google OAuth2 credentials -drone @api reauth google # Re-authenticate Google OAuth2 -drone @api test # Test OpenRouter connection status -drone @api models [--all] # List available models from provider -drone @api status # Check OpenRouter client status -drone @api call "prompt" --model MODEL # Make API call to model -drone @api list-providers # List available API providers -drone @api init # Initialize .env template -drone @api track # Track API usage metrics -drone @api stats # Display API usage statistics -drone @api session # Show session usage data -drone @api caller-usage # Show usage by caller module -drone @api cleanup [days] # Clean up old usage data *(not operational — fails with no data)* -drone @api --help # Full help output -drone @api --version # Show version -``` - -Running `drone @api` with no arguments displays module introspection (discovered modules and status). - ---- - -## Cross-Branch API - -```python -# LLM access (OpenRouter) -from aipass.api.apps.modules.openrouter_client import get_response -response = get_response(prompt="...", model="anthropic/claude-3.5-sonnet", caller="flow") - -# Google Drive (or any Google API) -from aipass.api.apps.modules.google_client import get_drive_service -service = get_drive_service() # Single-threaded -service = get_drive_service(thread_safe=True) # For concurrent workers - -# Any Google service -from aipass.api.apps.modules.google_client import get_google_service -service = get_google_service("calendar", "v3") - -# Retry utility for raw API calls -from aipass.api.apps.modules.google_client import api_call_with_retry -``` - ---- - -## Architecture - -``` -api/ -├── __init__.py # Public API exports -├── apps/ -│ ├── api.py # Entry point (module discovery, command routing) -│ ├── modules/ -│ │ ├── api_key.py # Key retrieval and validation logic -│ │ ├── openrouter_client.py # OpenRouter API client -│ │ ├── google_client.py # Google API services (Drive, Calendar, etc.) -│ │ └── usage_tracker.py # Usage metrics tracking -│ └── handlers/ -│ ├── auth/ -│ │ ├── env.py # Environment variable credential loading -│ │ └── keys.py # API key storage and retrieval -│ ├── config/ -│ │ └── provider.py # Provider configuration management -│ ├── google/ -│ │ ├── auth.py # OAuth2 lifecycle, credential I/O -│ │ ├── service_factory.py # Service object factory (single + thread-safe) -│ │ └── retry.py # SSL retry with exponential backoff -│ ├── json/ -│ │ └── json_handler.py # JSON operation logging -│ ├── openrouter/ -│ │ ├── caller.py # HTTP request execution -│ │ ├── client.py # OpenRouter client implementation -│ │ ├── models.py # Model discovery and listing -│ │ └── provision.py # Provider provisioning -│ └── usage/ -│ ├── aggregation.py # Usage data aggregation -│ ├── cleanup.py # Usage data cleanup -│ └── tracking.py # Usage event tracking -└── README.md -``` - ---- - -## Integration Points - -### Depends On -- `aipass.prax` -- structured logging via `system_logger` -- `aipass.cli` -- Rich console output formatting - -### Provides To -- All branches -- authenticated external API clients -- `@backup` -- Google Drive service (migration from self-contained auth) -- `@skills` -- future external API integrations (Telegram, Google services) -- System-wide API key management and credential validation - -### Credentials -- `~/.secrets/aipass/.env` -- API keys (OpenRouter, etc.) -- `~/.secrets/aipass/google_creds.json` -- Google OAuth2 tokens -- `~/.secrets/aipass/google_client_secret.json` -- Google OAuth app config - ---- - -*Last Updated: 2026-04-07* - ---- -[← Back to AIPass](../../../README.md) diff --git a/src/aipass/api/__init__.py b/src/aipass/api/__init__.py deleted file mode 100644 index f2c62315..00000000 --- a/src/aipass/api/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""API - External API integrations for AIPass.""" diff --git a/src/aipass/api/apps/README.md b/src/aipass/api/apps/README.md deleted file mode 100644 index 8d9316e1..00000000 --- a/src/aipass/api/apps/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Apps - -Application layer for `API`. - -- `api.py` — Entry point. Auto-discovers and routes commands to modules. -- `modules/` — Business logic and orchestration. One module per command. -- `handlers/` — Implementation details. Called by modules, never by CLI directly. -- `plugins/` — Scheduled tasks and extensions. diff --git a/src/aipass/api/apps/__init__.py b/src/aipass/api/apps/__init__.py deleted file mode 100644 index 803548c8..00000000 --- a/src/aipass/api/apps/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Apps package diff --git a/src/aipass/api/apps/api.py b/src/aipass/api/apps/api.py deleted file mode 100644 index b7c0d4a4..00000000 --- a/src/aipass/api/apps/api.py +++ /dev/null @@ -1,286 +0,0 @@ -# =================== AIPass ==================== -# Name: api.py -# Description: Entry point CLI for drone @api — LLM client via OpenRouter -# Version: 1.0.0 -# Created: 2026-03-08 -# Modified: 2026-03-08 -# ============================================= - -""" -api Branch - Main Orchestrator - -Modular architecture with auto-discovered modules. -Main handles routing, modules implement functionality. -""" - -# INFRASTRUCTURE IMPORT PATTERN -import sys -from pathlib import Path - -# Standard library imports -import importlib -from typing import Any, List - -# AIPass infrastructure imports -from aipass.prax.apps.modules.logger import system_logger as logger - -# CLI services for formatted output -from aipass.cli.apps.modules import console, header, error -from rich.panel import Panel -from rich.table import Table -from rich.columns import Columns - -# JSON handler for api tracking -from aipass.api.apps.handlers.json import json_handler - -# ============================================================================= -# CONSTANTS & CONFIG -# ============================================================================= - -# Module root -MODULE_ROOT = Path(__file__).parent - -# Modules directory -MODULES_DIR = MODULE_ROOT / "modules" - -# ============================================================================= -# MODULE DISCOVERY -# ============================================================================= - -def discover_modules() -> List[Any]: - """ - Auto-discover modules from modules/ directory - - Returns: - List of module objects with handle_command() function - """ - modules = [] - - if not MODULES_DIR.exists(): - logger.warning(f"Modules directory not found: {MODULES_DIR}") - return modules - - logger.info(f"[{Path(__file__).stem}] Discovering modules...") - - for file_path in MODULES_DIR.glob("*.py"): - # Skip __init__.py and private files - if file_path.name.startswith("_"): - continue - - module_name = file_path.stem - - try: - # Import module via pip namespace - module = importlib.import_module(f"aipass.api.apps.modules.{module_name}") - - # Check for required interface - if hasattr(module, 'handle_command'): - modules.append(module) - logger.info(f" [+] {module_name}") - else: - logger.warning(f" [!] {module_name} - missing handle_command()") - - except Exception as e: - logger.error(f" [-] {module_name} - import error: {e}") - - logger.info(f"[{Path(__file__).stem}] Discovered {len(modules)} modules") - return modules - -# ============================================================================= -# INTROSPECTION DISPLAY -# ============================================================================= - -def print_introspection(): - """Display discovered modules and available commands""" - console.print() - console.print("[bold cyan]API Branch - API Operations[/bold cyan]") - console.print() - console.print("[dim]Universal API client and key management[/dim]") - console.print() - - # Discover modules - modules = discover_modules() - - if not modules: - error("No modules discovered", suggestion="Run 'drone @api --help' for usage information") - console.print() - return - - console.print(f"[yellow]Discovered Modules:[/yellow] {len(modules)}") - console.print() - - for module in modules: - module_name = module.__name__.split('.')[-1] - console.print(f" [cyan]•[/cyan] {module_name}") - - console.print() - console.print("[dim]Run 'drone @api --help' for usage information[/dim]") - console.print() - - -# ============================================================================= -# DRONE COMPLIANCE - HELP SYSTEM -# ============================================================================= - -def print_help(): - """Display Rich-formatted help""" - - console.print() - header("API Branch - API Operations") - console.print() - - console.print("[dim]Universal API client and key management system[/dim]") - console.print() - console.print("─" * 70) - console.print() - - console.print("[bold cyan]WHAT IS API?[/bold cyan]") - console.print() - console.print("API Branch provides:") - console.print(" [green]✓[/green] OpenRouter API client integration") - console.print(" [green]✓[/green] API key management and validation") - console.print(" [green]✓[/green] Model discovery and availability") - console.print(" [green]✓[/green] Usage tracking and statistics") - console.print(" [green]✓[/green] Connection testing and diagnostics") - console.print() - - console.print("[bold cyan]AVAILABLE COMMANDS:[/bold cyan]") - console.print() - - table = Table(show_header=True, header_style="bold cyan", border_style="dim") - table.add_column("Command", style="green") - table.add_column("Description", style="white") - - table.add_row("get-key", "Retrieve API key for provider") - table.add_row("validate", "Validate API credentials and connection") - table.add_row("validate google", "Validate Google OAuth2 credentials") - table.add_row("reauth google", "Re-authenticate Google OAuth2") - table.add_row("test", "Test OpenRouter connection status") - table.add_row("models", "List available models from provider") - table.add_row("status", "Check OpenRouter client status") - table.add_row("call", "Make API call to model") - table.add_row("list-providers", "List available API providers") - table.add_row("init", "Initialize .env template") - table.add_row("track", "Track API usage metrics") - table.add_row("stats", "Display API usage statistics") - table.add_row("session", "Show session usage data") - table.add_row("caller-usage", "Show usage by caller module") - table.add_row("cleanup", "Clean up old usage data") - - console.print(table) - console.print() - console.print("─" * 70) - console.print() - - console.print("[bold cyan]USAGE:[/bold cyan]") - console.print() - - usage_examples = [ - "[yellow]Quick Commands:[/yellow]\n [dim]drone @api get-key[/dim]\n [dim]drone @api validate[/dim]", - "[yellow]Testing:[/yellow]\n [dim]drone @api test[/dim]\n [dim]drone @api models[/dim]", - "[yellow]Analytics:[/yellow]\n [dim]drone @api track[/dim]\n [dim]drone @api stats[/dim]" - ] - - console.print(Columns(usage_examples, equal=True, expand=True)) - console.print() - console.print("─" * 70) - console.print() - - console.print("[bold cyan]CONFIGURATION:[/bold cyan]") - console.print() - - config_text = """[bold]API configuration is managed through:[/bold] - - [green]•[/green] Environment variables for credentials - [green]•[/green] Configuration files in handlers/config/ - [green]•[/green] Provider-specific settings in handlers/""" - - console.print(Panel(config_text, border_style="cyan", padding=(1, 2))) - console.print() - console.print("─" * 70) - console.print() - - console.print("[dim]Commands: get-key, validate, test, models, status, call, list-providers, init, track, stats, session, caller-usage, cleanup[/dim]") - console.print() - - -# ============================================================================= -# COMMAND ROUTING -# ============================================================================= - -def route_command(command: str, args: List[str], modules: List[Any]) -> bool: - """ - Route command to appropriate module - - Args: - command: Command name (e.g., 'get-key', 'validate') - args: Additional command arguments - modules: List of discovered modules - - Returns: - True if command was handled, False otherwise - """ - for module in modules: - try: - if module.handle_command(command, args): - return True - except Exception as e: - logger.error(f"Module error: {e}") - - return False - -# ============================================================================= -# MAIN -# ============================================================================= - -def main(): - """Main entry point - routes commands to modules""" - - # Parse arguments directly from sys.argv - args = sys.argv[1:] - - # Show introspection when run without arguments - if len(args) == 0: - print_introspection() - json_handler.log_operation("api_introspection_displayed", {"trigger": "no_args"}) - return 0 - - # Show version - if args[0] in ['--version', '-V']: - console.print("API v1.0.0") - return 0 - - # Show help for explicit help flags - if args[0] in ['--help', '-h', 'help']: - print_help() - json_handler.log_operation("api_help_displayed", {"trigger": args[0]}) - return 0 - - # Discover modules - modules = discover_modules() - - if not modules: - logger.error("No modules found") - error("No modules found") - return 1 - - # Extract command and remaining args (matching seedgo pattern) - command = args[0] - remaining_args = args[1:] if len(args) > 1 else [] - - # Log api command attempt - json_handler.log_operation( - "api_command_attempted", - {"command": command, "modules_discovered": len(modules)} - ) - - # Route command to modules - if route_command(command, remaining_args, modules): - return 0 - else: - logger.warning(f"Unknown command: {command}") - error(f"Unknown command: {command}", suggestion="Run 'drone @api --help' for available commands") - return 1 - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/aipass/api/apps/extensions/__init__.py b/src/aipass/api/apps/extensions/__init__.py deleted file mode 100644 index 95322c94..00000000 --- a/src/aipass/api/apps/extensions/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Extensions package - Drop-in extensions for branch functionality diff --git a/src/aipass/api/apps/handlers/README.md b/src/aipass/api/apps/handlers/README.md deleted file mode 100644 index e2cbe0e5..00000000 --- a/src/aipass/api/apps/handlers/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Handlers - -Implementation details for `API`. - -Handlers do the actual work. They are called by modules, never directly by the CLI. Keep business logic in modules, implementation in handlers. diff --git a/src/aipass/api/apps/handlers/__init__.py b/src/aipass/api/apps/handlers/__init__.py deleted file mode 100644 index 82b8fd6b..00000000 --- a/src/aipass/api/apps/handlers/__init__.py +++ /dev/null @@ -1,77 +0,0 @@ -"""API handlers package - Security protected.""" - -import inspect -from pathlib import Path - -MY_BRANCH = "api" - - -def _find_real_caller(): - """Walk the stack to find the actual file that triggered this import.""" - stack = inspect.stack() - this_file = str(Path(__file__).resolve()) - - for frame_info in stack: - filename = frame_info.filename - if this_file in str(Path(filename).resolve()): - continue - if filename.startswith("<") or "importlib" in filename: - continue - import_line = None - if frame_info.code_context: - import_line = frame_info.code_context[0].strip() - return str(Path(filename).resolve()), import_line - return None, None - - -def _extract_branch_name(filepath: str) -> str: - """Extract branch name from a file path.""" - parts = Path(filepath).parts - for i, part in enumerate(parts): - if part in ("memory", "seedgo", ".vscode"): - if i + 1 < len(parts): - return parts[i + 1] - if part in ("aipass",) and i + 1 < len(parts) and parts[i + 1] == "apps": - return "aipass" - return "unknown" - - -def _guard_branch_access(): - """Block cross-branch handler imports.""" - caller_file, import_line = _find_real_caller() - - import os - if os.environ.get("AIPASS_DEBUG_GUARD"): - from aipass.prax import logger as _guard_logger - _guard_logger.info(f"[GUARD] caller_file = {caller_file}") - _guard_logger.info(f"[GUARD] import_line = {import_line}") - - if caller_file is None: - stack = inspect.stack() - for frame in stack: - if frame.filename in ("", ""): - return # Allow command-line Python through - return - - if f"/{MY_BRANCH}/" in caller_file: - return - - caller_branch = _extract_branch_name(caller_file) - caller_filename = Path(caller_file).name - blocked_import = import_line if import_line else "unknown" - - raise ImportError( - f"\n{'='*60}\n" - f"ACCESS DENIED: Cross-branch handler import blocked\n" - f"{'='*60}\n" - f" Caller branch: {caller_branch}\n" - f" Caller file: {caller_filename}\n" - f" Blocked: {blocked_import}\n\n" - f" Handlers are internal to their branch.\n" - f" Use the module API instead:\n" - f" from {MY_BRANCH}.apps.modules. import \n" - f"{'='*60}" - ) - - -_guard_branch_access() diff --git a/src/aipass/api/apps/handlers/auth/__init__.py b/src/aipass/api/apps/handlers/auth/__init__.py deleted file mode 100644 index 9d77ffe2..00000000 --- a/src/aipass/api/apps/handlers/auth/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Authentication Domain - -Handlers for API key management, validation, and credential storage. -Includes .env file operations and provider authentication. -""" -__version__ = "1.0.0" diff --git a/src/aipass/api/apps/handlers/auth/env.py b/src/aipass/api/apps/handlers/auth/env.py deleted file mode 100644 index 75000e86..00000000 --- a/src/aipass/api/apps/handlers/auth/env.py +++ /dev/null @@ -1,109 +0,0 @@ -# =================== AIPass ==================== -# Name: env.py -# Description: .env file operations -# Version: 0.2.0 -# Created: 2025-11-16 -# Modified: 2025-11-16 -# ============================================= - -""" -.env File Handler - -Manages .env file creation for API credential setup. - -Functions: - create_env_template() - Create .env template for provider -""" - -# Infrastructure -import os -from pathlib import Path - -# Standard library -from typing import Optional - -# Logging -from aipass.prax import logger - -# JSON handler -from aipass.api.apps.handlers.json import json_handler - - -# ============================================== -# ENV FILE CREATION -# ============================================== - -def create_env_template(provider: str = "openrouter", target_path: Optional[Path] = None) -> bool: - """ - Create .env template file with default placeholders. - - Creates a template .env file with commented instructions and - placeholder values for API keys. Will not overwrite existing files. - - Args: - provider: API provider name (default: 'openrouter') - target_path: Optional custom path (defaults to ~/.secrets/aipass/.env) - - Returns: - bool: True if template created successfully, False otherwise - - Example: - >>> if create_env_template('openrouter'): - ... print("Template created at ~/.secrets/aipass/.env") - """ - # Default to ~/.secrets/aipass/.env (cross-platform standard) - env_path = target_path or (Path.home() / ".secrets" / "aipass" / ".env") - - # Don't overwrite existing file - if env_path.exists(): - # .env file already exists - logger.info(f".env file already exists at {env_path}") - return True - - # Template content based on provider - if provider.lower() == "openrouter": - env_template = """# AIPass API Keys -# Add your API keys here - -# OpenRouter API Key (recommended - access to 323+ models) -OPENROUTER_API_KEY=sk-or-v1-your-key-here - -# Backup OpenAI API Key (if needed) -OPENAI_API_KEY=sk-your-openai-key-here - -# Other provider keys can be added as needed -""" - else: - # Generic template - env_template = f"""# AIPass API Keys -# Add your API keys here - -# {provider.upper()} API Key -{provider.upper()}_API_KEY=your-key-here - -# Other provider keys can be added as needed -""" - - try: - # Ensure parent directory exists with restricted permissions (owner-only) - env_path.parent.mkdir(parents=True, exist_ok=True) - os.chmod(env_path.parent, 0o700) - - # Write template - with open(env_path, 'w', encoding='utf-8') as f: - f.write(env_template) - - # Restrict file permissions to owner-read/write only - os.chmod(env_path, 0o600) - - # Created .env template - logger.info(f"Created .env template at {env_path}") - json_handler.log_operation("env_template_created", {"path": str(env_path), "provider": provider}) - return True - - except Exception as e: - # Failed to create .env template - logger.error(f"Failed to create .env template: {e}") - return False - - diff --git a/src/aipass/api/apps/handlers/auth/keys.py b/src/aipass/api/apps/handlers/auth/keys.py deleted file mode 100644 index bfd3d947..00000000 --- a/src/aipass/api/apps/handlers/auth/keys.py +++ /dev/null @@ -1,305 +0,0 @@ -# =================== AIPass ==================== -# Name: keys.py -# Description: API Key Management Handler -# Version: 2.0.0 -# Created: 2025-11-16 -# Modified: 2025-11-16 -# ============================================= - -""" -API Key Management Handler - -Handles API key retrieval and validation for multiple providers. -Keys are read from config JSON or directly from ~/.secrets/aipass/.env. - -Functions: - get_api_key() - Get validated API key - validate_key() - Validate key format for provider - get_key_from_config() - Retrieve key from config JSON - get_validation_rules() - Get provider-specific validation rules -""" - -# Standard library -import sys -from pathlib import Path -from typing import Optional, Dict, Any - -# Logging -from aipass.prax import logger - -# JSON handler -from aipass.api.apps.handlers.json import json_handler - - -# ============================================== -# CONSTANTS -# ============================================== - -# Navigate: keys.py -> auth/ -> handlers/ -> apps/ -> api/ -API_ROOT = Path(__file__).resolve().parent.parent.parent.parent -API_JSON_DIR = API_ROOT / "api_json" - -# Provider validation rules (embedded - no config dependency for core validation) -VALIDATION_RULES = { - "openrouter": { - "prefix": "sk-or-v1-", - "min_length": 40 - }, - "openai": { - "prefix": "sk-", - "min_length": 40 - }, - "anthropic": { - "prefix": "sk-ant-", - "min_length": 40 - }, - # Generic fallback - "generic": { - "min_length": 10 - } -} - - -# ============================================== -# KEY RETRIEVAL -# ============================================== - -def get_api_key(provider: str = "openrouter") -> Optional[str]: - """ - Get validated API key for provider. - - Sources (in order): - 1. Config JSON file (api_json/api_connect_config.json) - 2. Secrets file (~/.secrets/aipass/.env) - - Args: - provider: Provider name (default: 'openrouter') - - Returns: - str: Validated API key or None if not found/invalid - - Example: - >>> key = get_api_key('openrouter') - >>> if key: - ... print(f"Got key: {key[:20]}...") - """ - try: - source = "" - - # 1. Try config file - key = get_key_from_config(provider) - if key and validate_key(key, provider): - source = "config" - - # 2. Try secrets file - if not source: - key = _read_key_from_secrets(provider) - if key and validate_key(key, provider): - source = "secrets" - - if source: - json_handler.log_operation("key_retrieved", {"provider": provider, "source": source}) - return key - - # No valid key found - return None - - except Exception as e: - logger.error(f"Failed to get API key for provider '{provider}': {e}") - return None - - -def _read_key_from_secrets(provider: str) -> Optional[str]: - """ - Read API key directly from ~/.secrets/aipass/.env. - - Args: - provider: Provider name (e.g., 'openrouter') - - Returns: - str: API key value or None if not found - """ - secrets_path = Path.home() / ".secrets" / "aipass" / ".env" - if not secrets_path.exists(): - return None - - try: - env_var = f"{provider.upper()}_API_KEY" - with open(secrets_path, 'r', encoding='utf-8') as f: - for line in f: - line = line.strip() - if not line or line.startswith('#'): - continue - if '=' in line: - key, value = line.split('=', 1) - if key.strip() == env_var: - return value.strip() - return None - except Exception as e: - logger.warning(f"Error reading secrets file: {e}") - return None - - -def get_key_from_config(provider: str) -> Optional[str]: - """ - Retrieve API key from config JSON file. - - Reads from: /api_json/api_connect_config.json - - Args: - provider: Provider name (e.g., 'openrouter') - - Returns: - str: API key from config or None if not found - - Example: - >>> key = get_key_from_config('openrouter') - """ - try: - config_path = API_JSON_DIR / "api_connect_config.json" - - if not config_path.exists(): - # Config file not found - return None - - import json - with open(config_path, 'r', encoding='utf-8') as f: - config = json.load(f) - - # Navigate config structure - if "config" in config: - providers = config["config"].get("providers", {}) - if provider in providers: - key = providers[provider].get("api_key", "") - if key: - return key - - # No key in config file - return None - - except Exception as e: - # Error reading config - logger.error(f"Error reading config for provider '{provider}': {e}") - return None - - - -# ============================================== -# KEY VALIDATION -# ============================================== - -def validate_key(key: str, provider: str = "openrouter") -> bool: - """ - Validate API key format for provider. - - Checks: - - Key is non-empty string - - Matches provider prefix (if required) - - Meets minimum length requirement - - Args: - key: API key to validate - provider: Provider name for validation rules - - Returns: - bool: True if key passes validation - - Example: - >>> key = "sk-or-v1-abc123..." - >>> if validate_key(key, 'openrouter'): - ... print("Valid key") - """ - # Basic validation - if not key or not isinstance(key, str): - # Invalid key type - return False - - # Strip whitespace - key = key.strip() - - # Get validation rules - rules = get_validation_rules(provider) - - # Check prefix if specified - if "prefix" in rules: - if not key.startswith(rules["prefix"]): - # Key missing required prefix - return False - - # Check minimum length - if "min_length" in rules: - if len(key) < rules["min_length"]: - # Key too short - return False - - # Key passed validation - return True - - -def get_validation_rules(provider: str) -> Dict[str, Any]: - """ - Get validation rules for provider. - - Returns provider-specific rules or generic fallback. - - Args: - provider: Provider name - - Returns: - dict: Validation rules (prefix, min_length) - - Example: - >>> rules = get_validation_rules('openrouter') - >>> print(rules['prefix']) - sk-or- - """ - return VALIDATION_RULES.get(provider, VALIDATION_RULES["generic"]) - - -# ============================================== -# KEY FORMAT CHECKING -# ============================================== - -def diagnose_key(provider: str = "openrouter") -> str: - """ - Diagnose why get_api_key() returned None. - - Checks all sources for a raw key (skipping validation) and explains - exactly why it failed — missing entirely, wrong prefix, too short, etc. - - Args: - provider: Provider name (default: 'openrouter') - - Returns: - str: Human-readable explanation of the key issue - - Example: - >>> if not get_api_key('openrouter'): - ... print(diagnose_key('openrouter')) - """ - # Check all sources for raw key (without validation) - key = get_key_from_config(provider) - source = "config" - - if not key: - key = _read_key_from_secrets(provider) - source = "secrets" - - if not key: - secrets_path = Path.home() / ".secrets" / "aipass" / ".env" - return f"API key for {provider} not found. Expected at {secrets_path}. Run drone @api setup to configure." - - # Key exists but failed validation — explain why - key = key.strip() - rules = get_validation_rules(provider) - - if "prefix" in rules and not key.startswith(rules["prefix"]): - actual_prefix = key[:len(rules["prefix"])] if len(key) >= len(rules["prefix"]) else key[:6] - return f"Key found ({source}) but invalid — expected prefix '{rules['prefix']}', got '{actual_prefix}...'" - - if "min_length" in rules and len(key) < rules["min_length"]: - return f"Key found ({source}) but too short — {len(key)} chars, need {rules['min_length']}+" - - return f"Key found ({source}) but failed validation" - - diff --git a/src/aipass/api/apps/handlers/config/__init__.py b/src/aipass/api/apps/handlers/config/__init__.py deleted file mode 100644 index 6b424d15..00000000 --- a/src/aipass/api/apps/handlers/config/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Configuration Domain - -Handlers for provider configuration management. -Load, validate, and update API provider settings. -""" -__version__ = "1.0.0" diff --git a/src/aipass/api/apps/handlers/config/provider.py b/src/aipass/api/apps/handlers/config/provider.py deleted file mode 100644 index d26111f9..00000000 --- a/src/aipass/api/apps/handlers/config/provider.py +++ /dev/null @@ -1,117 +0,0 @@ -# =================== AIPass ==================== -# Name: provider.py -# Description: Provider Configuration Handler -# Version: 2.0.0 -# Created: 2025-11-16 -# Modified: 2025-11-16 -# ============================================= - -""" -Provider Configuration Handler - -Manages provider configuration for API access: -- Deep merge configuration updates -- Provider defaults and validation rules -""" - -# Standard library -from typing import Dict, Any, Optional - -# JSON handler -from aipass.api.apps.handlers.json import json_handler - -# Logging -from aipass.prax import logger - -# ============================================= -# CONSTANTS -# ============================================= - -# Default provider configurations -# NOTE: No default_model - callers must specify their own model from their branch config -PROVIDER_DEFAULTS = { - "openrouter": { - "api_key": "", - "base_url": "https://openrouter.ai/api/v1", - "temperature": 0.7, - "timeout_seconds": 30 - }, - "openai": { - "api_key": "", - "base_url": "https://api.openai.com/v1", - "temperature": 0.7, - "timeout_seconds": 30 - } -} - -# Provider validation rules -VALIDATION_RULES = { - "openrouter": { - "prefix": "sk-or-v1-", - "min_length": 40 - }, - "openai": { - "prefix": "sk-", - "min_length": 40 - } -} - -# ============================================= -# CONFIGURATION MERGING -# ============================================= - -def merge_configs(base: Dict[str, Any], updates: Dict[str, Any]) -> Dict[str, Any]: - """ - Deep merge two configuration dictionaries - - Recursively merges 'updates' into 'base', preserving nested structures. - Modifies 'base' in-place and also returns it for convenience. - - For nested dicts: recursively merges - For other types: updates overwrites base - - Args: - base: Base configuration dict (modified in-place) - updates: Updates to merge in - - Returns: - The merged base dict (same object as input) - - Example: - base = {"a": 1, "b": {"c": 2, "d": 3}} - updates = {"b": {"c": 99}, "e": 4} - merge_configs(base, updates) - # base is now: {"a": 1, "b": {"c": 99, "d": 3}, "e": 4} - """ - for key, value in updates.items(): - if isinstance(value, dict) and key in base and isinstance(base[key], dict): - # Recursively merge nested dicts - merge_configs(base[key], value) - else: - # Overwrite with new value - base[key] = value - - json_handler.log_operation("config_merged", {"keys_updated": len(updates)}) - return base - - -# ============================================= -# VALIDATION HELPERS -# ============================================= - -def get_validation_rules(provider: str) -> Optional[Dict[str, Any]]: - """ - Get validation rules for provider - - Args: - provider: Provider name - - Returns: - Validation rules dict or None if not defined - """ - rules = VALIDATION_RULES.get(provider) - if rules is None: - logger.info(f"No validation rules found for provider: {provider}") - return rules - - diff --git a/src/aipass/api/apps/handlers/google/__init__.py b/src/aipass/api/apps/handlers/google/__init__.py deleted file mode 100644 index 17362827..00000000 --- a/src/aipass/api/apps/handlers/google/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -Google API Domain - -Handlers for Google service authentication, credential management, -and service object factories. Provides authenticated clients for -Google APIs (Drive, Calendar, etc.) to consuming branches. -""" -__version__ = "1.0.0" - -from . import auth as auth -from . import service_factory as service_factory -from . import retry as retry diff --git a/src/aipass/api/apps/handlers/google/auth.py b/src/aipass/api/apps/handlers/google/auth.py deleted file mode 100644 index 745c008f..00000000 --- a/src/aipass/api/apps/handlers/google/auth.py +++ /dev/null @@ -1,254 +0,0 @@ -# =================== AIPass ==================== -# Name: auth.py -# Description: Google OAuth2 authentication and credential management -# Version: 1.0.0 -# Created: 2026-03-14 -# Modified: 2026-03-14 -# ============================================= -# pyright: reportMissingImports=false, reportInvalidTypeForm=false, reportOptionalMemberAccess=false, reportOptionalCall=false - -""" -Google OAuth2 Authentication Handler - -Manages the OAuth2 lifecycle for Google API access: -- Load/save credentials from ~/.secrets/aipass/ -- Token refresh for expired credentials -- Full OAuth2 consent flow for new authentication -- Re-authentication when tokens are revoked - -This is pure auth plumbing — no business logic. -Consumers get authenticated credentials, they decide what to do with them. -""" - -import os -from pathlib import Path -from typing import Optional - -# Logging -from aipass.prax import logger - -# JSON handler -from aipass.api.apps.handlers.json import json_handler - -# ============================================= -# CONSTANTS -# ============================================= - -# Default scopes per service — consumers can override -DEFAULT_SCOPES = { - "drive": ["https://www.googleapis.com/auth/drive.file"], - "calendar": ["https://www.googleapis.com/auth/calendar.readonly"], -} - -# Credential storage — AIPass standard location -SECRETS_DIR = Path.home() / ".secrets" / "aipass" -CREDS_PATH = SECRETS_DIR / "google_creds.json" -CLIENT_SECRET_PATH = SECRETS_DIR / "google_client_secret.json" - - -# ============================================= -# GOOGLE API AVAILABILITY -# ============================================= - -try: - from google.oauth2.credentials import Credentials - from google.auth.transport.requests import Request - from google_auth_oauthlib.flow import InstalledAppFlow - GOOGLE_AUTH_AVAILABLE = True -except ImportError as e: - logger.warning(f"Google auth libraries not available: {e}") - GOOGLE_AUTH_AVAILABLE = False - Credentials = None # type: ignore[assignment, misc] - Request = None # type: ignore[assignment, misc] - InstalledAppFlow = None # type: ignore[assignment, misc] - - -# ============================================= -# CREDENTIAL OPERATIONS -# ============================================= - - -def is_available() -> bool: - """Check if Google auth libraries are installed.""" - return GOOGLE_AUTH_AVAILABLE - - -def load_credentials(scopes: Optional[list] = None) -> Optional["Credentials"]: - """Load saved OAuth2 credentials from disk. - - Args: - scopes: OAuth2 scopes to validate against. - Defaults to Drive scopes if not provided. - - Returns: - Credentials object if found and loadable, None otherwise. - """ - if not GOOGLE_AUTH_AVAILABLE: - return None - - if not CREDS_PATH.exists(): - return None - - effective_scopes = scopes or DEFAULT_SCOPES["drive"] - - try: - creds = Credentials.from_authorized_user_file(str(CREDS_PATH), effective_scopes) - json_handler.log_operation("credentials_loaded", {"source": str(CREDS_PATH)}) - return creds - except Exception as e: - logger.error(f"Failed to load credentials from {CREDS_PATH}: {e}") - return None - - -def refresh_credentials(creds: "Credentials") -> bool: - """Attempt to refresh expired credentials. - - Args: - creds: Expired Credentials object with a refresh token. - - Returns: - True if refresh succeeded, False otherwise. - """ - if not GOOGLE_AUTH_AVAILABLE: - return False - - if not creds or not creds.expired or not creds.refresh_token: - return False - - try: - creds.refresh(Request()) - _save_credentials(creds) - return True - except Exception as e: - logger.error(f"Failed to refresh credentials: {e}") - return False - - -def run_oauth_flow( - scopes: Optional[list] = None, port: int = 0, open_browser: bool = True, -) -> Optional["Credentials"]: - """Run the full OAuth2 consent flow. - - Requires google_client_secret.json at ~/.secrets/aipass/. - Opens a local server for the OAuth callback. - - Args: - scopes: OAuth2 scopes to request. - port: Local server port (0 = auto-assign). - open_browser: Whether to auto-open the consent page. - - Returns: - Credentials object if flow succeeded, None otherwise. - """ - if not GOOGLE_AUTH_AVAILABLE: - return None - - if not CLIENT_SECRET_PATH.exists(): - return None - - effective_scopes = scopes or DEFAULT_SCOPES["drive"] - - try: - flow = InstalledAppFlow.from_client_secrets_file( - str(CLIENT_SECRET_PATH), effective_scopes - ) - creds = flow.run_local_server(port=port, open_browser=open_browser) - _save_credentials(creds) - return creds - except Exception as e: - logger.error(f"OAuth flow failed: {e}") - return None - - -def authenticate(scopes: Optional[list] = None) -> Optional["Credentials"]: - """Full authentication lifecycle: load → refresh → OAuth flow. - - Tries in order: - 1. Load existing valid credentials - 2. Refresh expired credentials - 3. Run full OAuth2 consent flow - - Args: - scopes: OAuth2 scopes. Defaults to Drive scopes. - - Returns: - Valid Credentials object, or None if all methods fail. - """ - if not GOOGLE_AUTH_AVAILABLE: - return None - - # Step 1: Load existing - creds = load_credentials(scopes) - - if creds and creds.valid: - return creds - - # Step 2: Refresh expired - if creds and creds.expired and creds.refresh_token: - if refresh_credentials(creds): - return creds - - # Step 3: Full OAuth flow - return run_oauth_flow(scopes=scopes) - - -def reauth( - scopes: Optional[list] = None, port: int = 8085, open_browser: bool = False, -) -> Optional["Credentials"]: - """Force re-authentication via OAuth flow (console mode). - - Used when existing credentials are revoked or corrupted. - Defaults to console-friendly settings (no browser, fixed port). - - Args: - scopes: OAuth2 scopes. - port: Local server port for callback. - open_browser: Whether to auto-open browser. - - Returns: - Fresh Credentials object, or None on failure. - """ - # Try refresh first — maybe token just expired - creds = load_credentials(scopes) - if creds and creds.expired and creds.refresh_token: - if refresh_credentials(creds): - return creds - - # Force new flow - return run_oauth_flow(scopes=scopes, port=port, open_browser=open_browser) - - -def validate_credentials(scopes: Optional[list] = None) -> bool: - """Check if valid Google credentials exist. - - Args: - scopes: OAuth2 scopes to validate against. - - Returns: - True if valid (or refreshable) credentials exist. - """ - creds = load_credentials(scopes) - if not creds: - return False - - if creds.valid: - return True - - if creds.expired and creds.refresh_token: - return refresh_credentials(creds) - - return False - - -# ============================================= -# INTERNAL HELPERS -# ============================================= - - -def _save_credentials(creds: "Credentials") -> None: - """Save credentials to the standard secrets path with restricted permissions.""" - SECRETS_DIR.mkdir(parents=True, exist_ok=True) - os.chmod(SECRETS_DIR, 0o700) - with open(CREDS_PATH, "w", encoding="utf-8") as f: - f.write(creds.to_json()) - os.chmod(CREDS_PATH, 0o600) diff --git a/src/aipass/api/apps/handlers/google/retry.py b/src/aipass/api/apps/handlers/google/retry.py deleted file mode 100644 index 44aca88a..00000000 --- a/src/aipass/api/apps/handlers/google/retry.py +++ /dev/null @@ -1,90 +0,0 @@ -# =================== AIPass ==================== -# Name: retry.py -# Description: Google API retry logic with SSL error handling -# Version: 1.0.0 -# Created: 2026-03-14 -# Modified: 2026-03-14 -# ============================================= - -""" -Google API Retry Handler - -Provides exponential backoff retry for Google API calls, -with specific handling for transient SSL/connection errors. - -Extracted from backup's drive_sync_client.py — generic enough -for any Google API consumer, not just Drive. - -Usage: - from aipass.api.apps.handlers.google.retry import api_call_with_retry - - result = api_call_with_retry( - service.files().list(q="..."), - max_retries=3, - rebuild_service_fn=my_rebuild_fn, - ) -""" - -import ssl -import time -from typing import Any, Callable, Optional - -# JSON handler -from aipass.api.apps.handlers.json import json_handler - - -def is_ssl_error(exc: Exception) -> bool: - """Check if an exception is a transient SSL/connection error. - - Args: - exc: The caught exception. - - Returns: - True if the error is a transient SSL/connection issue. - """ - if isinstance(exc, (ssl.SSLError, BrokenPipeError, ConnectionResetError)): - return True - - ssl_keywords = ( - "DECRYPTION_FAILED_OR_BAD_RECORD_MAC", - "WRONG_VERSION_NUMBER", - "EOF occurred", - "ssl.SSLError", - "BrokenPipeError", - "ConnectionReset", - ) - msg = str(exc) - return any(kw in msg for kw in ssl_keywords) - - -def api_call_with_retry( - request: Any, max_retries: int = 3, rebuild_service_fn: Optional[Callable] = None, -) -> Any: - """Execute a Google API request with exponential backoff on SSL errors. - - Args: - request: A Google API request object (has .execute() method). - max_retries: Maximum number of retry attempts. - rebuild_service_fn: Optional callback to rebuild the service - on SSL failure (e.g. to get a fresh connection). - Called with no arguments, return value is ignored. - - Returns: - The API response. - - Raises: - The original exception if retries are exhausted or - the error is not an SSL/connection issue. - """ - for attempt in range(max_retries + 1): - try: - return request.execute() - except Exception as e: - if attempt < max_retries and is_ssl_error(e): - wait = 2 ** attempt - json_handler.log_operation("api_retry_attempted", {"attempt": attempt + 1, "wait_seconds": wait}) - time.sleep(wait) - if rebuild_service_fn: - rebuild_service_fn() - continue - raise diff --git a/src/aipass/api/apps/handlers/google/service_factory.py b/src/aipass/api/apps/handlers/google/service_factory.py deleted file mode 100644 index a2fbd94a..00000000 --- a/src/aipass/api/apps/handlers/google/service_factory.py +++ /dev/null @@ -1,123 +0,0 @@ -# =================== AIPass ==================== -# Name: service_factory.py -# Description: Google API service object factory -# Version: 1.0.0 -# Created: 2026-03-14 -# Modified: 2026-03-14 -# ============================================= -# pyright: reportMissingImports=false, reportOptionalCall=false - -""" -Google API Service Factory - -Builds authenticated Google API service objects (Drive, Calendar, etc.). -Supports both single-threaded and thread-safe modes. - -Thread-safe mode loads fresh credentials from disk per call, -avoiding token refresh races in concurrent operations. -This pattern was extracted from backup's drive_sync_client.py. - -Usage: - from aipass.api.apps.handlers.google.service_factory import ( - build_service, build_thread_safe_service - ) - - # Single-threaded - service = build_service("drive", "v3") - - # Thread-safe (for concurrent workers) - service = build_thread_safe_service("drive", "v3") -""" - -from typing import Optional - -from aipass.prax import logger -from aipass.api.apps.handlers.google import auth as auth -from aipass.api.apps.handlers.json import json_handler - -# ============================================= -# GOOGLE API AVAILABILITY -# ============================================= - -try: - from googleapiclient.discovery import build - GOOGLE_BUILD_AVAILABLE = True -except ImportError as e: - logger.warning(f"Google API client library not available: {e}") - GOOGLE_BUILD_AVAILABLE = False - build = None # type: ignore[assignment] - - -# ============================================= -# SERVICE FACTORIES -# ============================================= - - -def build_service( - service_name: str = "drive", version: str = "v3", scopes: Optional[list] = None, -) -> Optional[object]: - """Build an authenticated Google API service object. - - Uses the full auth lifecycle (load → refresh → OAuth flow). - - Args: - service_name: Google API service (e.g. "drive", "calendar", "sheets"). - version: API version (e.g. "v3", "v3"). - scopes: OAuth2 scopes. Defaults to service-specific defaults from auth module. - - Returns: - Authenticated service object, or None if auth/build fails. - """ - if not GOOGLE_BUILD_AVAILABLE or not auth.is_available(): - return None - - creds = auth.authenticate(scopes=scopes) - if not creds: - return None - - try: - service = build(service_name, version, credentials=creds) - json_handler.log_operation("build_service", {"service": service_name, "version": version}) - return service - except Exception as e: - logger.error(f"Failed to build Google {service_name} service: {e}") - return None - - -def build_thread_safe_service( - service_name: str = "drive", version: str = "v3", scopes: Optional[list] = None, -) -> Optional[object]: - """Build an isolated service instance for use in a worker thread. - - Loads fresh credentials from disk to avoid sharing credential state - (token refresh races) and creates a fully isolated HTTP/SSL connection. - - Args: - service_name: Google API service name. - version: API version. - scopes: OAuth2 scopes. - - Returns: - Isolated authenticated service object, or None on failure. - """ - if not GOOGLE_BUILD_AVAILABLE or not auth.is_available(): - return None - - # Load fresh credentials from disk — no shared state - creds = auth.load_credentials(scopes=scopes) - if not creds: - return None - - # Refresh if expired - if creds.expired and creds.refresh_token: - if not auth.refresh_credentials(creds): - return None - - if not creds.valid: - return None - - try: - return build(service_name, version, credentials=creds) - except Exception as e: - logger.error(f"Failed to build thread-safe Google {service_name} service: {e}") - return None diff --git a/src/aipass/api/apps/handlers/json/__init__.py b/src/aipass/api/apps/handlers/json/__init__.py deleted file mode 100644 index 287ee1a5..00000000 --- a/src/aipass/api/apps/handlers/json/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""JSON Handlers - Universal JSON operations for API branch""" diff --git a/src/aipass/api/apps/handlers/json/json_handler.py b/src/aipass/api/apps/handlers/json/json_handler.py deleted file mode 100755 index c158575c..00000000 --- a/src/aipass/api/apps/handlers/json/json_handler.py +++ /dev/null @@ -1,250 +0,0 @@ -# =================== AIPass ==================== -# Name: json_handler.py -# Description: JSON Auto-Creating Handler -# Version: 1.1.0 -# Created: 2025-11-21 -# Modified: 2025-11-21 -# ============================================= - -import json -from pathlib import Path -from datetime import datetime -from typing import Dict, Any, Optional -import inspect - -# Logging -from aipass.prax import logger - -# Infrastructure - -# Constants — package-relative paths -# Navigate: json_handler.py -> json/ -> handlers/ -> apps/ -> api/ -API_ROOT = Path(__file__).resolve().parent.parent.parent.parent -API_JSON_DIR = API_ROOT / "api_json" - - -def _get_caller_module_name() -> str: - """ - Auto-detect calling module name from call stack - - Returns: - Module name (e.g., "imports_standard" from imports_standard.py) - """ - try: - stack = inspect.stack() - # Skip frames: [0]=this function, [1]=log_operation, [2]=actual caller - if len(stack) > 2: - caller_frame = stack[2] - caller_path = Path(caller_frame.filename) - module_name = caller_path.stem - - # Validate module name - if module_name and not module_name.startswith('_'): - return module_name - - # Fallback - return "unknown" - except Exception as e: - logger.warning(f"Failed to detect module name: {e}") - return "unknown" - - -def _create_default(json_type: str, module_name: str) -> Any: - """Create default JSON structure for a given type.""" - today = datetime.now().date().isoformat() - - if json_type == "config": - return { - "module_name": module_name, - "version": "1.0.0", - "timestamp": today, - "config": { - "auto_save": True, - "enabled": True, - }, - } - - if json_type == "data": - return { - "module_name": module_name, - "created": today, - "last_updated": today, - "operations_total": 0, - "operations_successful": 0, - "operations_failed": 0, - } - - if json_type == "log": - return [] - - raise ValueError(f"Unknown json_type: {json_type}") - - -def validate_json_structure(data: Any, json_type: str) -> bool: - """Validate JSON structure matches expected type""" - if json_type == "config": - if not isinstance(data, dict): - return False - required = ["module_name", "version", "config"] - return all(key in data for key in required) - - elif json_type == "data": - if not isinstance(data, dict): - return False - required = ["created", "last_updated"] - return all(key in data for key in required) - - elif json_type == "log": - return isinstance(data, list) - - return False - - -def get_json_path(module_name: str, json_type: str) -> Path: - """Get path for module JSON file""" - filename = f"{module_name}_{json_type}.json" - return API_JSON_DIR / filename - - -def ensure_json_exists(module_name: str, json_type: str) -> bool: - """Ensure JSON file exists, create from template if missing""" - API_JSON_DIR.mkdir(parents=True, exist_ok=True) - - json_path = get_json_path(module_name, json_type) - - if json_path.exists(): - try: - with open(json_path, 'r', encoding='utf-8') as f: - data = json.load(f) - - if validate_json_structure(data, json_type): - return True - else: - logger.warning(f"Corrupted JSON structure at {json_path}, regenerating") - except Exception as e: - logger.warning(f"Unreadable JSON at {json_path}, regenerating: {e}") - - template = _create_default(json_type, module_name) - - with open(json_path, 'w', encoding='utf-8') as f: - json.dump(template, f, indent=2, ensure_ascii=False) - return True - - -def load_json(module_name: str, json_type: str) -> Optional[Any]: - """Load JSON file, auto-create if missing""" - if not ensure_json_exists(module_name, json_type): - return None - - json_path = get_json_path(module_name, json_type) - - try: - with open(json_path, 'r', encoding='utf-8') as f: - return json.load(f) - except Exception as e: - logger.error(f"Failed to load JSON from {json_path}: {e}") - return None - - -def save_json(module_name: str, json_type: str, data: Any) -> bool: - """Save JSON file""" - json_path = get_json_path(module_name, json_type) - - if not validate_json_structure(data, json_type): - return False - - if json_type == "data" and isinstance(data, dict): - data["last_updated"] = datetime.now().date().isoformat() - - try: - with open(json_path, 'w', encoding='utf-8') as f: - json.dump(data, f, indent=2, ensure_ascii=False) - return True - except Exception as e: - logger.error(f"Failed to save JSON to {json_path}: {e}") - return False - - -def ensure_module_jsons(module_name: str) -> bool: - """Ensure all 3 JSON files exist for a module""" - ensure_json_exists(module_name, "config") - ensure_json_exists(module_name, "data") - ensure_json_exists(module_name, "log") - return True - - -def log_operation(operation: str, data: Dict[str, Any] | None = None, module_name: str | None = None) -> bool: - """ - Add entry to module log with automatic rotation - - Auto-detects calling module if module_name not provided. - Implements config-controlled log limits to prevent unbounded growth. - When max_log_entries is reached, removes oldest entries (FIFO). - - Args: - operation: Operation name to log - data: Optional data dict - module_name: Optional module name (auto-detected if not provided) - - Returns: - True if successful, False otherwise - """ - # Auto-detect module name if not provided - if module_name is None: - module_name = _get_caller_module_name() - - ensure_module_jsons(module_name) - - # Load config to get max_log_entries - config = load_json(module_name, "config") - max_entries = 100 # Default - if config and "config" in config: - max_entries = config["config"].get("max_log_entries", 100) - - # Load existing log - log = load_json(module_name, "log") - if log is None: - log = [] - - # Create new entry - entry = { - "timestamp": datetime.now().isoformat(), - "operation": operation - } - - if data: - entry["data"] = data # type: ignore[assignment] - - # Add new entry - log.append(entry) - - # Rotate if exceeds max (keep most recent entries) - if len(log) > max_entries: - log = log[-max_entries:] - - return save_json(module_name, "log", log) - - -if __name__ == "__main__": - from rich.console import Console - from rich.panel import Panel - - console = Console() - - console.print() - console.print(Panel.fit( - "[bold cyan]JSON HANDLER - Working Implementation[/bold cyan]", - border_style="bright_blue" - )) - console.print() - console.print("[yellow]TESTING:[/yellow] Creating API JSONs...") - - # Test auto-creation - log_operation("test_operation", {"test": "data"}, "api") - - console.print() - console.print(f"[green]Check {API_JSON_DIR}/ for created files:[/green]") - console.print(" [dim]•[/dim] api_config.json") - console.print(" [dim]•[/dim] api_data.json") - console.print(" [dim]•[/dim] api_log.json") - console.print() diff --git a/src/aipass/api/apps/handlers/openrouter/__init__.py b/src/aipass/api/apps/handlers/openrouter/__init__.py deleted file mode 100644 index 0b5696c2..00000000 --- a/src/aipass/api/apps/handlers/openrouter/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -OpenRouter Domain - -Handlers for OpenRouter LLM API client operations. -Client creation, caller detection, model fetching, and config provisioning. -""" -__version__ = "1.0.0" diff --git a/src/aipass/api/apps/handlers/openrouter/caller.py b/src/aipass/api/apps/handlers/openrouter/caller.py deleted file mode 100644 index 8de96cb5..00000000 --- a/src/aipass/api/apps/handlers/openrouter/caller.py +++ /dev/null @@ -1,224 +0,0 @@ -# =================== AIPass ==================== -# Name: caller.py -# Description: OpenRouter Caller Detection Handler -# Version: 1.0.0 -# Created: 2025-11-16 -# Modified: 2025-11-16 -# ============================================= - -""" -OpenRouter Caller Detection Handler - -Stack-based caller detection with JSON folder path resolution. -Supports flow, prax, and skills module detection. - -Usage: - from aipass.api.apps.handlers.openrouter.caller import get_caller_info - - caller_info = get_caller_info() - if caller_info: - caller_name = caller_info['caller_name'] - json_folder = caller_info['json_folder'] -""" - -# Standard library imports -import inspect -from pathlib import Path -from typing import Dict, Any, Optional, Tuple - -# Logging -from aipass.prax import logger - -# JSON handler -from aipass.api.apps.handlers.json import json_handler - -# ============================================= -# CONFIGURATION -# ============================================= - -MODULE_NAME = "openrouter.caller" -MODULE_VERSION = "1.0.0" - -CALLER_PATTERNS = { - "flow": "flow_json", - "prax": "prax_json", - "skills": "{category}_json", -} - -# ============================================= -# CALLER DETECTION FUNCTIONS -# ============================================= - -def get_caller_info() -> Optional[Dict[str, Any]]: - """ - Detect calling module via stack inspection. - - Returns dict with: caller_name, caller_path, json_folder, category, detection_method - Returns None if detection fails. - """ - try: - stack = inspect.stack() - - for frame_info in stack[1:]: - frame_path = Path(frame_info.filename) - - if "flow" in frame_path.parts: - result = _detect_flow_caller(frame_path) - json_handler.log_operation("caller_detected", {"caller": result.get("caller_name"), "category": "flow"}) - return result - elif "prax" in frame_path.parts: - result = _detect_prax_caller(frame_path) - json_handler.log_operation("caller_detected", {"caller": result.get("caller_name"), "category": "prax"}) - return result - elif any("skills" in part for part in frame_path.parts): - result = _detect_skills_caller(frame_path) - json_handler.log_operation("caller_detected", {"caller": result.get("caller_name"), "category": "skills"}) - return result - - logger.info(f"[{MODULE_NAME}] Could not detect caller from stack trace") - return None - - except Exception as e: - logger.error(f"Caller detection failed: {e}") - return None - - -def detect_caller_from_stack() -> Tuple[Optional[str], Optional[Path]]: - """ - Compatibility wrapper for provision handler. - - Returns: - Tuple of (caller_name, json_folder_path) or (None, None) - """ - caller_info = get_caller_info() - if caller_info: - return caller_info.get('caller_name'), caller_info.get('json_folder') - return None, None - - -def detect_caller_category(caller_path: Path) -> str: - """Categorize caller based on file path.""" - try: - path_parts = caller_path.parts - - if "flow" in path_parts: - return "flow" - elif "prax" in path_parts: - return "prax" - elif any("skills" in part for part in path_parts): - return "skills" - else: - return "unknown" - - except Exception as e: - logger.error(f"Failed to detect category for {caller_path}: {e}") - return "unknown" - - -# ============================================= -# INTERNAL DETECTION HELPERS -# ============================================= - -def _detect_flow_caller(frame_path: Path) -> Dict[str, Any]: - """Detect flow module caller from stack frame path.""" - try: - flow_index = frame_path.parts.index("flow") - flow_path = Path(*frame_path.parts[:flow_index + 1]) - json_folder_path = flow_path / "flow_json" - caller_name = frame_path.stem - - logger.info(f"[{MODULE_NAME}] Detected flow caller: {caller_name}") - - return { - "caller_name": caller_name, - "caller_path": frame_path, - "json_folder": json_folder_path, - "category": "flow", - "detection_method": "stack" - } - - except Exception as e: - logger.error(f"Failed to detect flow caller: {e}") - return _create_fallback_info(frame_path) - - -def _detect_prax_caller(frame_path: Path) -> Dict[str, Any]: - """Detect prax module caller from stack frame path.""" - try: - prax_index = frame_path.parts.index("prax") - prax_path = Path(*frame_path.parts[:prax_index + 1]) - json_folder_path = prax_path / "prax_json" - caller_name = frame_path.stem - - logger.info(f"[{MODULE_NAME}] Detected prax caller: {caller_name}") - - return { - "caller_name": caller_name, - "caller_path": frame_path, - "json_folder": json_folder_path, - "category": "prax", - "detection_method": "stack" - } - - except Exception as e: - logger.error(f"Failed to detect prax caller: {e}") - return _create_fallback_info(frame_path) - - -def _detect_skills_caller(frame_path: Path) -> Dict[str, Any]: - """ - Detect skills module caller from stack frame path. - Skills have category subdirectories (e.g., /skills/skills_api/skill.py) - """ - try: - for i, part in enumerate(frame_path.parts): - if "skills" in part: - skills_path = Path(*frame_path.parts[:i + 2]) - category = frame_path.parts[i + 1] if i + 1 < len(frame_path.parts) else "skills_api" - json_folder_path = skills_path / f"{category}_json" - caller_name = frame_path.stem - - logger.info(f"[{MODULE_NAME}] Detected skills caller: {caller_name} (category: {category})") - - return { - "caller_name": caller_name, - "caller_path": frame_path, - "json_folder": json_folder_path, - "category": "skills", - "skills_category": category, - "detection_method": "stack" - } - - logger.info(f"[{MODULE_NAME}] Could not find skills directory in path: {frame_path}") - return _create_fallback_info(frame_path) - - except Exception as e: - logger.error(f"Failed to detect skills caller: {e}") - return _create_fallback_info(frame_path) - - -def _create_fallback_info(frame_path: Path) -> Dict[str, Any]: - """Create fallback caller info when detection fails.""" - caller_name = frame_path.stem - category = detect_caller_category(frame_path) - - logger.info(f"[{MODULE_NAME}] Using fallback detection for: {caller_name}") - - return { - "caller_name": caller_name, - "caller_path": frame_path, - "json_folder": None, - "category": category, - "detection_method": "fallback" - } - - -# ============================================= -# MODULE INITIALIZATION -# ============================================= - -def _initialize(): - """Initialize caller detection module.""" - logger.info(f"[{MODULE_NAME}] Caller detection handler loaded (v{MODULE_VERSION})") - -_initialize() diff --git a/src/aipass/api/apps/handlers/openrouter/client.py b/src/aipass/api/apps/handlers/openrouter/client.py deleted file mode 100644 index e93314ed..00000000 --- a/src/aipass/api/apps/handlers/openrouter/client.py +++ /dev/null @@ -1,375 +0,0 @@ -# =================== AIPass ==================== -# Name: client.py -# Description: OpenRouter Client Handler -# Version: 3.0.0 -# Created: 2025-11-15 -# Modified: 2025-11-15 -# ============================================= -# pyright: reportInvalidTypeForm=false, reportOptionalCall=false - -""" -OpenRouter Client Handler - -Business logic for OpenRouter API client creation and request execution. -OpenRouter API client creation and request execution. - -Functions: -- get_response() - Main API call with tracking integration -- create_client() - Create OpenAI SDK client configured for OpenRouter -- make_api_request() - Execute API request and handle errors -- extract_response() - Extract text and metadata from API response - -Configuration: -- base_url: https://openrouter.ai/api/v1 -- Uses OpenAI SDK with OpenRouter endpoint -- Supports all 323+ OpenRouter models -- Connection pooling via client caching - -Standards: -- Uses prax logger for output (NO print() or console.print()) -- Uses logger.info() for system logging -- Integrates with auth/keys, caller detection, usage tracking handlers -- Standalone functions (no classes) -- Complete error handling with graceful failures -""" - -# INFRASTRUCTURE IMPORT PATTERN - -# Standard library imports -import time -from typing import Optional, Dict, List, Any - -# Logging -from aipass.prax import logger - -# OpenAI SDK for OpenRouter compatibility -try: - from openai import OpenAI - OPENAI_AVAILABLE = True -except ImportError as e: - logger.error(f"OpenAI SDK not available. Install with: pip install openai: {e}") - OpenAI = None # type: ignore[assignment,misc] - OPENAI_AVAILABLE = False - -# Handler imports -from aipass.api.apps.handlers.auth.keys import get_api_key -from aipass.api.apps.handlers.openrouter.caller import get_caller_info -from aipass.api.apps.handlers.openrouter.provision import ensure_caller_config -from aipass.api.apps.handlers.usage.tracking import track_usage - -# JSON handler -from aipass.api.apps.handlers.json import json_handler - -# ============================================= -# CONFIGURATION -# ============================================= - -OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" -DEFAULT_TIMEOUT = 30 -# NOTE: No default model - callers must specify their own model from their branch config - -# HTTP headers for OpenRouter -OPENROUTER_HEADERS = { - "HTTP-Referer": "https://aipass.local", - "X-Title": "AIPass API Client" -} - -# Client cache for connection pooling -_client_cache: Dict[str, OpenAI] = {} -MAX_CACHED_CLIENTS = 5 - -# ============================================= -# CLIENT CREATION -# ============================================= - -def create_client(api_key: str, base_url: str = OPENROUTER_BASE_URL, timeout: int = DEFAULT_TIMEOUT) -> Optional[OpenAI]: - """ - Create OpenAI SDK client configured for OpenRouter. - - Args: - api_key: OpenRouter API key - base_url: OpenRouter base URL (default: https://openrouter.ai/api/v1) - timeout: Request timeout in seconds (default: 30) - - Returns: - OpenAI client instance or None on failure - - Example: - >>> api_key = get_api_key("openrouter") - >>> client = create_client(api_key) - >>> if client: - ... # Use client for requests - """ - if not OPENAI_AVAILABLE: - logger.error("OpenAI SDK not installed. Run: pip install openai") - return None - - if not api_key: - logger.error("API key required for client creation") - return None - - try: - # Create OpenAI client with OpenRouter configuration - client = OpenAI( - base_url=base_url, - api_key=api_key, - timeout=timeout, - default_headers=OPENROUTER_HEADERS - ) - - logger.info(f"Created OpenRouter client - base_url: {base_url}, timeout: {timeout}s") - json_handler.log_operation("client_initialized", {"base_url": base_url, "timeout": timeout}) - return client - - except Exception as e: - logger.error(f"Error creating OpenRouter client: {e}") - return None - - -def get_cached_client(api_key: str, base_url: str = OPENROUTER_BASE_URL, timeout: int = DEFAULT_TIMEOUT) -> Optional[OpenAI]: - """ - Get cached OpenAI client or create new one if not cached. - Implements connection pooling for better performance. - - Args: - api_key: OpenRouter API key - base_url: OpenRouter base URL - timeout: Request timeout in seconds - - Returns: - OpenAI client instance or None on failure - - Note: - Cache is limited to MAX_CACHED_CLIENTS (5) to prevent memory growth. - Oldest clients are removed when cache is full. - """ - global _client_cache - - # Check if we have a cached client for this API key - if api_key in _client_cache: - cached_client = _client_cache[api_key] - # Verify cached client is still valid - if cached_client and cached_client.api_key == api_key: - logger.info("Using cached OpenRouter client") - return cached_client - - # Create new client - client = create_client(api_key, base_url, timeout) - - if not client: - return None - - # Cache the client (limit cache size) - if len(_client_cache) >= MAX_CACHED_CLIENTS: - # Remove oldest client (first key in dict) - oldest_key = next(iter(_client_cache)) - del _client_cache[oldest_key] - logger.info(f"Removed oldest cached client - cache limit: {MAX_CACHED_CLIENTS}") - - _client_cache[api_key] = client - logger.info("Cached new OpenRouter client") - - return client - - -# ============================================= -# API REQUEST EXECUTION -# ============================================= - -def make_api_request(client: OpenAI, messages: List[Dict], model: str, retries: int = 1, **kwargs) -> Optional[Any]: - """ - Execute API request via OpenRouter with retry logic. - - Args: - client: OpenAI client instance - messages: Chat messages in OpenAI format [{"role": "user", "content": "..."}] - model: Model identifier (e.g., "anthropic/claude-3.5-sonnet") - retries: Number of retries on failure (default: 1, so 2 total attempts) - **kwargs: Additional OpenAI API parameters (temperature, max_tokens, etc.) - - Returns: - OpenAI response object or None on failure - """ - if not client or not messages or not model: - logger.warning(f"make_api_request() called with missing params — client={bool(client)}, messages={bool(messages)}, model={bool(model)}") - return None - - api_params = { - "model": model, - "messages": messages, - **kwargs - } - - last_error = None - for attempt in range(1 + retries): - try: - response = client.chat.completions.create(**api_params) - if attempt > 0: - logger.info(f"API request succeeded on retry {attempt} for model {model}") - return response - except Exception as e: - last_error = e - if attempt < retries: - delay = 1.0 * (attempt + 1) # 1s, 2s, ... - logger.info(f"API request failed for {model} (attempt {attempt + 1}/{1 + retries}): {e} — retrying in {delay:.0f}s") - time.sleep(delay) - - logger.error(f"API request failed for {model} after {1 + retries} attempts: {last_error}") - return None - - -def extract_response(response: Any) -> Optional[Dict[str, Any]]: - """ - Extract text content and metadata from API response. - - Args: - response: OpenAI response object - - Returns: - Dict with 'content' (str), 'id' (str), 'model' (str) or None on failure - - Example: - >>> response = make_api_request(client, messages, model) - >>> data = extract_response(response) - >>> if data: - ... print(data['content']) - ... track_usage(caller, data['id'], data['model'], api_key) - """ - if not response: - return None - - try: - # Validate response structure - if not hasattr(response, 'choices') or not response.choices: - logger.warning("Response missing 'choices' or choices is empty") - return None - - if not hasattr(response.choices[0], 'message'): - logger.warning("Response choice missing 'message' attribute") - return None - - # Extract content - content = response.choices[0].message.content - - if not content: - logger.warning("Response has no content") - return None - - # Extract metadata - result = { - "content": content, - "id": response.id if hasattr(response, 'id') else None, - "model": response.model if hasattr(response, 'model') else None, - "finish_reason": response.choices[0].finish_reason if hasattr(response.choices[0], 'finish_reason') else None - } - - logger.info(f"Extracted response - length: {len(content)} chars, id: {result['id']}") - return result - - except Exception as e: - logger.error(f"Failed to extract response: {e}") - return None - - -# ============================================= -# MAIN API CALL -# ============================================= - -def get_response(prompt: str, caller: Optional[str] = None, model: Optional[str] = None, **kwargs) -> Optional[Dict[str, Any]]: - """ - Main API call - get response from OpenRouter with full tracking integration. - - This is the primary entry point that integrates all handlers: - - Detects caller automatically if not provided - - Retrieves API key via auth/keys handler - - Creates/caches client - - Makes API request - - Extracts response - - Tracks usage via usage/tracking handler - - Args: - prompt: User prompt text - caller: Module making the request (auto-detected if not provided) - model: Model to use (required - caller must provide from branch config) - **kwargs: Additional OpenAI API parameters - - Returns: - Dict with 'content', 'id', 'model' or None on failure - - Example: - >>> response = get_response("What is Python?", caller="cli", model="anthropic/claude-3.5-sonnet") - >>> if response: - ... print(response['content']) - """ - # Step 1: Detect caller if not provided - if not caller: - caller_info = get_caller_info() - if caller_info and caller_info.get("caller_name"): - caller = caller_info["caller_name"] - logger.info(f"Auto-detected caller: {caller}") - else: - logger.warning("Could not detect caller - using 'unknown'") - caller = "unknown" - - # Step 1b: Ensure caller has config (auto-provision if missing) - try: - ensure_caller_config(caller) - except Exception as e: - logger.warning(f"Caller config provisioning failed (non-blocking): {e}") - - # Step 2: Require model from caller - no defaults - if not model: - logger.error("No model specified.") - logger.warning("Callers must provide their own model via branch config (e.g., flow_json/openrouter_config.json)") - return None - - # Step 3: Get API key - api_key = get_api_key("openrouter") - if not api_key: - logger.error("No OpenRouter API key available") - return None - - # Step 4: Get or create client - client = get_cached_client(api_key) - if not client: - return None - - # Step 5: Convert prompt to messages format - messages = [{"role": "user", "content": prompt}] - - # Step 6: Make API request - response = make_api_request(client, messages, model, **kwargs) - if not response: - return None - - # Step 7: Extract response - result = extract_response(response) - if not result: - return None - - # Step 8: Track usage (if response has ID) - if result.get("id"): - try: - track_usage(result["id"], caller if caller else "unknown", model, api_key) - except Exception as e: - logger.warning(f"Usage tracking failed: {e}") - - logger.info(f"Successfully got response - caller: {caller}, model: {model}, length: {len(result['content'])} chars") - return result - - -# ============================================= -# CLEANUP -# ============================================= - -def get_cache_stats() -> Dict[str, Any]: - """ - Get statistics about the client cache. - - Returns: - Dict with cache size info (keys are never exposed — they are raw API keys) - """ - return { - "cached_clients": len(_client_cache), - "max_cache_size": MAX_CACHED_CLIENTS, - } diff --git a/src/aipass/api/apps/handlers/openrouter/models.py b/src/aipass/api/apps/handlers/openrouter/models.py deleted file mode 100644 index ad75939a..00000000 --- a/src/aipass/api/apps/handlers/openrouter/models.py +++ /dev/null @@ -1,115 +0,0 @@ -# =================== AIPass ==================== -# Name: models.py -# Description: OpenRouter Model Management -# Version: 1.0.0 -# Created: 2025-11-16 -# Modified: 2025-11-16 -# ============================================= - -""" -OpenRouter Model Management Handler - -Business logic for querying OpenRouter models: -- Fetch all available models from OpenRouter API -- Parse model data and capabilities -""" - -# Standard library imports -from typing import Dict, List - -# Third-party imports -import requests - -# Logging -from aipass.prax import logger - -# JSON handler -from aipass.api.apps.handlers.json import json_handler - - -# ============================================= -# CONSTANTS -# ============================================= - -OPENROUTER_API_URL = "https://openrouter.ai/api/v1/models" -DEFAULT_TIMEOUT = 10 -MODULE_NAME = "openrouter.models" - - -# ============================================= -# CORE FUNCTIONS -# ============================================= - -def fetch_models_from_api(api_key: str) -> List[Dict]: - """ - Query OpenRouter models endpoint and parse response - - Makes HTTP request to OpenRouter API and extracts model data. - Handles authentication, timeouts, and error responses. - - Args: - api_key: Valid OpenRouter API key - - Returns: - List of model dictionaries, empty list on failure - - Raises: - No exceptions raised - returns empty list on all errors - """ - try: - # Prepare request headers - headers = { - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json" - } - - # Make API request - logger.info(f"[{MODULE_NAME}] Requesting models from OpenRouter API") - response = requests.get( # type: ignore[attr-defined] - OPENROUTER_API_URL, - headers=headers, - timeout=DEFAULT_TIMEOUT - ) - - # Check response status - if response.status_code != 200: - logger.info(f"[{MODULE_NAME}] API request failed with status {response.status_code}") - logger.error(f"OpenRouter API error: {response.status_code}") - return [] - - # Parse JSON response - data = response.json() - - # Extract models from response - if "data" in data and isinstance(data["data"], list): - models = data["data"] - logger.info(f"[{MODULE_NAME}] Successfully parsed {len(models)} models") - json_handler.log_operation("models_fetched", {"count": len(models)}) - return models - else: - logger.info(f"[{MODULE_NAME}] Invalid response format - no 'data' field") - return [] - - except requests.exceptions.Timeout: - logger.info(f"[{MODULE_NAME}] API request timeout after {DEFAULT_TIMEOUT}s") - logger.error("Request timeout - OpenRouter API not responding") - return [] - - except requests.exceptions.RequestException as e: - logger.info(f"[{MODULE_NAME}] Network error: {e}") - logger.error(f"Network error: {e}") - return [] - - except ValueError as e: - logger.info(f"[{MODULE_NAME}] JSON parse error: {e}") - logger.error("Invalid JSON response from API") - return [] - - except Exception as e: - logger.info(f"[{MODULE_NAME}] Unexpected error fetching models: {e}") - logger.error(f"Error: {e}") - return [] - - - - diff --git a/src/aipass/api/apps/handlers/openrouter/provision.py b/src/aipass/api/apps/handlers/openrouter/provision.py deleted file mode 100644 index 9e17fe8a..00000000 --- a/src/aipass/api/apps/handlers/openrouter/provision.py +++ /dev/null @@ -1,278 +0,0 @@ -# =================== AIPass ==================== -# Name: provision.py -# Description: Caller Auto-Provisioning Handler -# Version: 1.0.0 -# Created: 2025-11-16 -# Modified: 2025-11-16 -# ============================================= - -""" -Caller Auto-Provisioning Handler - -Internal-only handler — not wired as a CLI command. -Called automatically by client.get_response() via ensure_caller_config(). - -Business logic for provisioning OpenRouter API configs: -- Auto-create caller API configurations -- Provision JSON folder structure -- Set default model/temperature/max_tokens -- Initialize caller-specific tracking files -- Ensure caller has complete 3-file JSON structure -""" - -import json -from datetime import datetime -from pathlib import Path -from typing import Dict, Any, Optional - -from aipass.prax import logger - -from aipass.api.apps.handlers.openrouter.caller import detect_caller_from_stack - -# JSON handler -from aipass.api.apps.handlers.json import json_handler - - -# =========================================== -# JSON UTILITIES -# =========================================== - -def read_json(file_path: Path) -> Optional[Dict[str, Any]]: - """ - Read JSON file safely - - Args: - file_path: Path to JSON file - - Returns: - Parsed JSON dict or None on error - """ - try: - if not file_path.exists(): - return None - - with open(file_path, 'r', encoding='utf-8') as f: - return json.load(f) - except Exception as e: - logger.error(f"Failed to read {file_path}: {e}") - return None - - -def write_json(file_path: Path, data: Dict[str, Any]) -> bool: - """ - Write JSON file safely with formatting - - Args: - file_path: Path to JSON file - data: Dict to write as JSON - - Returns: - True if successful, False otherwise - """ - try: - file_path.parent.mkdir(parents=True, exist_ok=True) - - with open(file_path, 'w', encoding='utf-8') as f: - json.dump(data, f, indent=2, ensure_ascii=False) - - return True - except Exception as e: - logger.error(f"Failed to write {file_path}: {e}") - return False - - -# =========================================== -# DEFAULT CONFIGURATION -# =========================================== - -def get_default_caller_config() -> Dict[str, Any]: - """ - Get default config template for new callers - - NOTE: No default ai_model - callers must set their own model in their branch config - - Returns: - Dict with default OpenRouter configuration (model must be set by caller) - """ - return { - "module_name": "openrouter", - "timestamp": datetime.now().isoformat(), - "config": { - "ai_model": "", # Caller must set their own model - "ai_temperature": 0.7, - "ai_max_tokens": 4000, - "enabled": True - } - } - - -def get_default_caller_data() -> Dict[str, Any]: - """ - Get default data template for tracking caller usage - - Returns: - Dict with initial usage tracking data - """ - return { - "module_name": "openrouter", - "timestamp": datetime.now().isoformat(), - "data": { - "total_requests": 0, - "successful_requests": 0, - "failed_requests": 0, - "models_used": {}, - "last_request": None - } - } - - -def get_default_caller_log() -> Dict[str, Any]: - """ - Get default log template for caller operations - - Returns: - Dict with empty log structure - """ - return { - "module_name": "openrouter", - "timestamp": datetime.now().isoformat(), - "logs": [] - } - - -# =========================================== -# PROVISIONING FUNCTIONS -# =========================================== - -def provision_json_folder(json_folder: Path) -> bool: - """ - Create JSON folder structure if missing - - Args: - json_folder: Path to caller's JSON folder - - Returns: - True if folder exists or created, False on error - """ - try: - if json_folder.exists(): - return True - - json_folder.mkdir(parents=True, exist_ok=True) - logger.info(f"Created JSON folder: {json_folder}") - - return True - except Exception as e: - logger.error(f"Failed to create JSON folder: {e}") - return False - - -def create_caller_config(caller: str, json_folder: Path) -> Dict[str, Any]: - """ - Create new caller configuration with defaults - - Creates complete 3-file JSON structure: - - openrouter_skill_config.json (API settings) - - openrouter_skill_data.json (usage tracking) - - openrouter_skill_log.json (operation log) - - Args: - caller: Name of calling module - json_folder: Path to caller's JSON folder - - Returns: - Dict with created config or empty dict on error - """ - try: - # Ensure JSON folder exists - if not provision_json_folder(json_folder): - return {} - - # Create config file - config_file = json_folder / "openrouter_skill_config.json" - config = get_default_caller_config() - - if not write_json(config_file, config): - return {} - - logger.info(f"Created API config for {caller}: {config_file}") - logger.info(f"Created config: {config_file.name}") - - # Create data file - data_file = json_folder / "openrouter_skill_data.json" - data = get_default_caller_data() - - if write_json(data_file, data): - logger.info(f"Created data file for {caller}: {data_file}") - logger.info(f"Created data: {data_file.name}") - - # Create log file - log_file = json_folder / "openrouter_skill_log.json" - log_data = get_default_caller_log() - - if write_json(log_file, log_data): - logger.info(f"Created log file for {caller}: {log_file}") - logger.info(f"Created log: {log_file.name}") - - logger.info(f"Auto-provisioned OpenRouter config for '{caller}'") - logger.warning("Reload config and retry request") - json_handler.log_operation("provider_provisioned", {"caller": caller}) - - return config - - except Exception as e: - logger.error(f"Config creation failed: {e}") - return {} - - -def ensure_caller_config(caller: str | None = None) -> Dict[str, Any]: - """ - Ensure caller has API configuration, create if missing - - Auto-detects caller if not provided. Creates complete 3-file - JSON structure with default OpenRouter settings. - - Args: - caller: Optional caller name (auto-detected if None) - - Returns: - Dict with config or empty dict if unable to provision - """ - try: - # Auto-detect caller if not provided - json_folder = None - if not caller: - detected_caller, json_folder = detect_caller_from_stack() - if detected_caller: - caller = detected_caller - logger.info(f"Auto-detected caller: {caller}") - else: - logger.warning("Could not detect caller module") - return {} - - # Get JSON folder path if not already detected - if not json_folder: - _, json_folder = detect_caller_from_stack() - if not json_folder: - logger.error(f"Could not find JSON folder for '{caller}'") - return {} - - # Check if config already exists - config_file = json_folder / "openrouter_skill_config.json" - - if config_file.exists(): - config = read_json(config_file) - if config: - logger.info(f"Using existing config for {caller}") - return config - else: - logger.warning(f"Config file corrupted for {caller}, regenerating") - - # Create new config - return create_caller_config(caller, json_folder) - - except Exception as e: - logger.error(f"Config provisioning failed: {e}") - return {} - - diff --git a/src/aipass/api/apps/handlers/usage/__init__.py b/src/aipass/api/apps/handlers/usage/__init__.py deleted file mode 100644 index dfb934ec..00000000 --- a/src/aipass/api/apps/handlers/usage/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Usage Tracking Domain - -Handlers for API usage monitoring and cost tracking. -Query generation metrics, aggregate statistics, and data cleanup. -""" -__version__ = "1.0.0" diff --git a/src/aipass/api/apps/handlers/usage/aggregation.py b/src/aipass/api/apps/handlers/usage/aggregation.py deleted file mode 100644 index 2f9bca31..00000000 --- a/src/aipass/api/apps/handlers/usage/aggregation.py +++ /dev/null @@ -1,188 +0,0 @@ -# =================== AIPass ==================== -# Name: aggregation.py -# Description: Usage Aggregation Handler -# Version: 1.0.0 -# Created: 2025-11-15 -# Modified: 2025-11-15 -# ============================================= - -""" -Usage Aggregation Handler - -Business logic for aggregating usage statistics: -- Calculate per-caller stats from usage data -- Daily/monthly rollups and summaries -- Cost, token, and latency aggregation -- Model usage tracking and breakdown - -Functions: get_caller_usage(), get_session_summary() -""" - -from pathlib import Path - -# Standard library imports -from typing import Dict, Any, Optional - -# Standard library for JSON operations -import json - -# Logging -from aipass.prax import logger - -# JSON handler -from aipass.api.apps.handlers.json import json_handler - - -# ============================================= -# MODULE CONSTANTS -# ============================================= - -MODULE_NAME = "aggregation" -DATA_FILE = "usage_tracker_data.json" # Standard 3-file pattern -# Navigate: aggregation.py -> usage/ -> handlers/ -> apps/ -> api/ -API_JSON_DIR = Path(__file__).resolve().parent.parent.parent.parent / "api_json" - - -# ============================================= -# AGGREGATION FUNCTIONS -# ============================================= - -def get_overall_stats() -> Dict[str, Any]: - """ - Aggregate usage statistics across all callers. - - Returns: - Dict with total_requests, total_cost, total_tokens, callers (count), - models_used (set of model names). - Returns empty dict {} if no data found. - """ - try: - data_path = API_JSON_DIR / DATA_FILE - if not data_path.exists(): - logger.info(f"[{MODULE_NAME}] No usage data file found") - return {} - - with open(data_path, 'r', encoding='utf-8') as f: - data = json.load(f) - - if not data or "data" not in data: - logger.info(f"[{MODULE_NAME}] No usage data available") - return {} - - usage_by_caller = data["data"].get("usage_by_caller", {}) - - if not usage_by_caller: - logger.info(f"[{MODULE_NAME}] No caller usage data found") - return {} - - total_requests = 0 - total_cost = 0.0 - total_tokens = 0 - models_used: set = set() - - for caller_data in usage_by_caller.values(): - total_requests += caller_data.get("requests", 0) - total_cost += caller_data.get("total_cost", 0.0) - total_tokens += caller_data.get("total_tokens", 0) - for model in caller_data.get("models_used", []): - models_used.add(model) - - result = { - "total_requests": total_requests, - "total_cost": total_cost, - "total_tokens": total_tokens, - "callers": len(usage_by_caller), - "models_used": sorted(models_used), - } - - logger.info(f"[{MODULE_NAME}] Overall stats: {total_requests} requests across {len(usage_by_caller)} callers") - json_handler.log_operation("get_overall_stats", {"total_requests": total_requests}) - return result - - except Exception as e: - logger.error(f"[{MODULE_NAME}] Failed to get overall stats: {e}") - return {} - - -def get_caller_usage(caller: str) -> Dict[str, Any]: - """ - Calculate usage statistics for specific caller - - Args: - caller: Module name that made API calls - - Returns: - Dict with requests, total_cost, total_tokens, models_used, last_request - Returns empty dict {} if no data found - """ - try: - # Load usage data from JSON - data_path = API_JSON_DIR / DATA_FILE - if not data_path.exists(): - logger.info(f"[{MODULE_NAME}] No usage data file found") - return {} - - with open(data_path, 'r', encoding='utf-8') as f: - data = json.load(f) - - if not data or "data" not in data: - logger.info(f"[{MODULE_NAME}] No usage data available") - return {} - - # Extract caller-specific data - usage_by_caller = data["data"].get("usage_by_caller", {}) - caller_data = usage_by_caller.get(caller, {}) - - if not caller_data: - logger.info(f"[{MODULE_NAME}] No usage data found for caller: {caller}") - return {} - - logger.info(f"[{MODULE_NAME}] Retrieved usage stats for {caller}: {caller_data.get('requests', 0)} requests") - json_handler.log_operation("get_caller_usage", {"caller": caller, "requests": caller_data.get("requests", 0)}) - return caller_data - - except Exception as e: - logger.error(f"[{MODULE_NAME}] Failed to get caller usage for {caller}: {e}") - return {} - - -def get_session_summary(session_id: Optional[str] = None) -> Dict[str, Any]: - """ - Aggregate current session usage totals - - Args: - session_id: Optional session identifier (unused, for future support) - - Returns: - Dict with start_time, total_requests, total_cost, total_tokens - Returns empty dict {} if no session data found - """ - try: - # Load usage data from JSON - data_path = API_JSON_DIR / DATA_FILE - if not data_path.exists(): - logger.info(f"[{MODULE_NAME}] No session data file found") - return {} - - with open(data_path, 'r', encoding='utf-8') as f: - data = json.load(f) - - if not data or "data" not in data: - logger.info(f"[{MODULE_NAME}] No session data available") - return {} - - # Extract session summary - session_data = data["data"].get("current_session", {}) - - if not session_data: - logger.info(f"[{MODULE_NAME}] No session summary found") - return {} - - logger.info(f"[{MODULE_NAME}] Retrieved session summary: {session_data.get('total_requests', 0)} requests") - return session_data - - except Exception as e: - logger.error(f"[{MODULE_NAME}] Failed to get session summary: {e}") - return {} - - diff --git a/src/aipass/api/apps/handlers/usage/cleanup.py b/src/aipass/api/apps/handlers/usage/cleanup.py deleted file mode 100644 index 07eacc4a..00000000 --- a/src/aipass/api/apps/handlers/usage/cleanup.py +++ /dev/null @@ -1,126 +0,0 @@ -# =================== AIPass ==================== -# Name: cleanup.py -# Description: Usage data retention and cleanup -# Version: 0.1.0 -# Created: 2025-11-16 -# Modified: 2025-11-16 -# ============================================= - -""" -Usage Data Cleanup Handler - -Manages data retention policies and cleanup operations. -Removes old generation tracking data based on retention rules. -""" - -# Infrastructure -from pathlib import Path - -# Standard library -import json -from datetime import datetime, timedelta -from typing import Optional, Dict, List - -# Logging -from aipass.prax import logger - -# JSON handler -from aipass.api.apps.handlers.json import json_handler - -# Default retention period -DEFAULT_RETENTION_DAYS = 30 - - -def _read_json(file_path: Path) -> Optional[Dict]: - """Read JSON file with error handling.""" - try: - if not file_path.exists(): - return None - with open(file_path, 'r', encoding='utf-8') as f: - return json.load(f) - except Exception as e: - logger.error(f"Failed to read JSON from {file_path}: {e}") - return None - - -def _write_json(file_path: Path, data: Dict) -> bool: - """Write JSON file with error handling.""" - try: - file_path.parent.mkdir(parents=True, exist_ok=True) - with open(file_path, 'w', encoding='utf-8') as f: - json.dump(data, f, indent=2, ensure_ascii=False) - return True - except Exception as e: - logger.error(f"Failed to write JSON to {file_path}: {e}") - return False - - -def cleanup_old_data(data_file_path: Path, retention_days: int = DEFAULT_RETENTION_DAYS) -> int: - """ - Remove usage data older than retention period. - - Args: - data_file_path: Path to the usage data JSON file - retention_days: Number of days to retain data (default: 30) - - Returns: - int: Number of generation entries cleaned up - """ - try: - cutoff_date = datetime.now() - timedelta(days=retention_days) - data = _read_json(data_file_path) - if not data: - return 0 - - # Extract the actual data content (handle wrapper structure) - data_content = data.get("data", data) - - # Identify and remove old generation tracking entries - old_generations = _identify_old_generations( - data_content.get("generation_tracking", {}), - cutoff_date - ) - - if not old_generations: - return 0 - - for gen_id in old_generations: - del data_content["generation_tracking"][gen_id] - - # Update wrapper if needed - if "data" in data: - data["data"] = data_content - data["timestamp"] = datetime.now().isoformat() - - _write_json(data_file_path, data) - logger.info(f"Cleaned up {len(old_generations)} generation entries") - logger.info(f"Cleaned up {len(old_generations)} generation entries older than {retention_days} days") - json_handler.log_operation("usage_cleanup", {"generations_removed": len(old_generations), "retention_days": retention_days}) - - return len(old_generations) - - except Exception as e: - logger.error(f"Cleanup failed: {e}") - raise - - -def _identify_old_generations(generation_tracking: Dict, cutoff_date: datetime) -> List[str]: - """Identify generation IDs older than cutoff date.""" - old_generations = [] - - for gen_id, gen_data in generation_tracking.items(): - try: - timestamp_str = gen_data.get("timestamp") - if not timestamp_str: - old_generations.append(gen_id) - continue - - gen_date = datetime.fromisoformat(timestamp_str) - if gen_date < cutoff_date: - old_generations.append(gen_id) - - except (ValueError, TypeError) as e: - logger.warning(f"Invalid timestamp for generation {gen_id}, marking for cleanup: {e}") - old_generations.append(gen_id) - - return old_generations diff --git a/src/aipass/api/apps/handlers/usage/tracking.py b/src/aipass/api/apps/handlers/usage/tracking.py deleted file mode 100644 index 66616bf4..00000000 --- a/src/aipass/api/apps/handlers/usage/tracking.py +++ /dev/null @@ -1,313 +0,0 @@ -# =================== AIPass ==================== -# Name: tracking.py -# Description: Usage Tracking Handler -# Version: 1.0.0 -# Created: 2025-11-16 -# Modified: 2025-11-16 -# ============================================= - -""" -Usage Tracking Handler - -Business logic for tracking API usage from OpenRouter: -- Query OpenRouter /generation endpoint for real metrics -- Retrieve cost, tokens (prompt + completion), latency data -- Store generation tracking data with newest-first ordering -- Handle HTTP requests with proper error handling - -Functions: track_usage(), get_generation_metrics(), store_usage_data() -""" - -from pathlib import Path - -# Standard library imports -import json -import requests -import time -from datetime import datetime -from typing import Dict, Any, Optional - -# Logging -from aipass.prax import logger - -# JSON handler -from aipass.api.apps.handlers.json import json_handler - -# ============================================= -# MODULE CONSTANTS -# ============================================= - -MODULE_NAME = "tracking" -DATA_FILE = "usage_tracker_data.json" # Standard 3-file pattern -# Navigate: tracking.py -> usage/ -> handlers/ -> apps/ -> api/ -API_JSON_DIR = Path(__file__).resolve().parent.parent.parent.parent / "api_json" - -# OpenRouter API configuration -OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" -GENERATION_ENDPOINT = f"{OPENROUTER_BASE_URL}/generation" - -# Default configuration values -DEFAULT_GENERATION_CHECK_DELAY = 2 # seconds to wait before querying metrics -DEFAULT_REQUEST_TIMEOUT = 30 # seconds for HTTP request timeout - - -# ============================================= -# CORE TRACKING FUNCTIONS -# ============================================= - -def track_usage(generation_id: str, caller: str, model: str = "unknown", api_key: Optional[str] = None) -> Dict[str, Any]: - """ - Track API usage for generation ID by querying OpenRouter metrics - - This is the main entry point for usage tracking. It: - 1. Waits for OpenRouter to process the generation - 2. Queries the /generation endpoint for real metrics - 3. Stores the usage data with newest-first ordering - - Args: - generation_id: OpenRouter generation ID from API response - caller: Module name that made the API call (e.g., "flow_mbank") - model: Model name used for the request (default: "unknown") - api_key: OpenRouter API key (optional, loads from config if not provided) - - Returns: - Dict with success status and metrics or error message - Example: {"success": True, "metrics": {...}} or {"success": False, "error": "..."} - """ - try: - # Tracking usage for caller - generation - - # Get API key if not provided - if not api_key: - # Import here to avoid circular dependencies - try: - from aipass.api.apps.handlers.auth.keys import get_api_key - api_key = get_api_key("openrouter") - except Exception as e: - logger.error(f"[{MODULE_NAME}] Failed to load API key: {e}") - return {"success": False, "error": "No API key available"} - - if not api_key: - # No API key available for tracking - return {"success": False, "error": "No API key available"} - - # Wait for OpenRouter to process the generation - time.sleep(DEFAULT_GENERATION_CHECK_DELAY) - - # Query OpenRouter for real metrics - metrics = get_generation_metrics(generation_id, api_key) - - if not metrics: - # Failed to retrieve generation metrics - return {"success": False, "error": "Failed to retrieve generation metrics"} - - # Store the usage data - if store_usage_data(caller, model, generation_id, metrics): - # Successfully tracked usage - json_handler.log_operation("usage_tracked", {"caller": caller, "model": model, "generation_id": generation_id}) - return {"success": True, "metrics": metrics} - else: - # Failed to store usage data - return {"success": False, "error": "Failed to store usage data"} - - except Exception as e: - logger.error(f"[{MODULE_NAME}] Usage tracking failed: {e}") - return {"success": False, "error": str(e)} - - -def get_generation_metrics(generation_id: str, api_key: str) -> Optional[Dict[str, Any]]: - """ - Query OpenRouter /generation endpoint for real usage metrics - - Makes HTTP GET request to: - https://openrouter.ai/api/v1/generation?id={generation_id} - - Args: - generation_id: OpenRouter generation ID - api_key: OpenRouter API key for authentication - - Returns: - Dict with metrics: - - total_cost: Total cost in USD - - tokens_prompt: Number of prompt tokens - - tokens_completion: Number of completion tokens - - generation_time: Generation time in milliseconds - - latency: Total latency in milliseconds - - provider_name: Provider that served the request - - Returns None if request fails or data is invalid - """ - try: - # Set up request headers - headers = { - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json" - } - - # Query the generation endpoint - response = requests.get( # type: ignore[attr-defined] - GENERATION_ENDPOINT, - params={"id": generation_id}, - headers=headers, - timeout=DEFAULT_REQUEST_TIMEOUT - ) - - # Check response status - if response.status_code == 200: - data = response.json() - - # Validate response structure - if not data or "data" not in data: - logger.warning(f"[{MODULE_NAME}] Invalid response structure from OpenRouter for generation {generation_id}") - return None - - # Extract metrics from response - metrics = data["data"] - result = { - "total_cost": float(metrics.get("total_cost", 0)), - "tokens_prompt": int(metrics.get("tokens_prompt", 0)), - "tokens_completion": int(metrics.get("tokens_completion", 0)), - "generation_time": int(metrics.get("generation_time", 0)), - "latency": int(metrics.get("latency", 0)), - "provider_name": metrics.get("provider_name", "unknown") - } - - # Retrieved metrics for generation_id - return result - - else: - logger.warning(f"[{MODULE_NAME}] OpenRouter API returned status {response.status_code} for generation {generation_id}") - return None - - except requests.exceptions.Timeout as e: - logger.warning(f"[{MODULE_NAME}] Request timeout querying generation: {e}") - return None - - except requests.exceptions.RequestException as e: - logger.error(f"[{MODULE_NAME}] Request error querying generation: {e}") - return None - - except (ValueError, KeyError) as e: - logger.error(f"[{MODULE_NAME}] Error parsing metrics: {e}") - return None - - except Exception as e: - logger.error(f"[{MODULE_NAME}] Unexpected error getting generation metrics: {e}") - return None - - -def store_usage_data(caller: str, model: str, generation_id: str, metrics: Dict[str, Any]) -> bool: - """ - Store usage data with aggregation and newest-first ordering - - Updates: - - current_session: Total requests, cost, tokens - - usage_by_caller: Per-caller statistics and model tracking - - daily_totals: Daily aggregated statistics - - generation_tracking: Individual generation details (newest first) - - Args: - caller: Module name that made the call - model: Model name used - generation_id: OpenRouter generation ID - metrics: Usage metrics from get_generation_metrics() - - Returns: - True if successfully stored, False on error - """ - try: - # Ensure API JSON directory exists - API_JSON_DIR.mkdir(parents=True, exist_ok=True) - data_path = API_JSON_DIR / DATA_FILE - - # Load current data or create initial structure - if data_path.exists(): - with open(data_path, 'r', encoding='utf-8') as f: - data_wrapper = json.load(f) - current_data = data_wrapper.get("data", {}) - else: - current_data = { - "current_session": { - "start_time": datetime.now().isoformat(), - "total_requests": 0, - "total_cost": 0.0, - "total_tokens": 0 - }, - "usage_by_caller": {}, - "daily_totals": {}, - "monthly_totals": {}, - "generation_tracking": {} - } - - # Calculate total tokens - total_tokens = metrics["tokens_prompt"] + metrics["tokens_completion"] - - # Update session totals - current_data["current_session"]["total_requests"] += 1 - current_data["current_session"]["total_cost"] += metrics["total_cost"] - current_data["current_session"]["total_tokens"] += total_tokens - - # Update per-caller tracking - if caller not in current_data["usage_by_caller"]: - current_data["usage_by_caller"][caller] = { - "requests": 0, - "total_cost": 0.0, - "total_tokens": 0, - "models_used": {}, - "last_request": None - } - - caller_data = current_data["usage_by_caller"][caller] - caller_data["requests"] += 1 - caller_data["total_cost"] += metrics["total_cost"] - caller_data["total_tokens"] += total_tokens - caller_data["last_request"] = datetime.now().isoformat() - - # Track models used by caller - if model not in caller_data["models_used"]: - caller_data["models_used"][model] = 0 - caller_data["models_used"][model] += 1 - - # Update daily totals - today = datetime.now().date().isoformat() - if today not in current_data["daily_totals"]: - current_data["daily_totals"][today] = { - "requests": 0, - "cost": 0.0, - "tokens": 0 - } - - current_data["daily_totals"][today]["requests"] += 1 - current_data["daily_totals"][today]["cost"] += metrics["total_cost"] - current_data["daily_totals"][today]["tokens"] += total_tokens - - # Store generation details with newest-first ordering - new_entry = { - "timestamp": datetime.now().isoformat(), - "caller": caller, - "model": model, - "usage_data": metrics - } - - # Create new dict with new entry first, then existing entries - current_tracking = current_data["generation_tracking"] - current_data["generation_tracking"] = {generation_id: new_entry, **current_tracking} - - # Save updated data with proper wrapper structure - data_wrapper = { - "module_name": "api_usage", - "timestamp": datetime.now().isoformat(), - "data": current_data - } - - with open(data_path, 'w', encoding='utf-8') as f: - json.dump(data_wrapper, f, indent=2, ensure_ascii=False) - - # Stored usage data for caller - return True - - except Exception as e: - logger.error(f"[{MODULE_NAME}] Failed to store usage data: {e}") - return False - - diff --git a/src/aipass/api/apps/modules/README.md b/src/aipass/api/apps/modules/README.md deleted file mode 100644 index b7be611c..00000000 --- a/src/aipass/api/apps/modules/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Modules - -Business logic for `API`. One module per command. - -Modules orchestrate work by calling handlers. They are the public API of the branch — drone routes commands here. diff --git a/src/aipass/api/apps/modules/__init__.py b/src/aipass/api/apps/modules/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/aipass/api/apps/modules/api_key.py b/src/aipass/api/apps/modules/api_key.py deleted file mode 100644 index c98742c7..00000000 --- a/src/aipass/api/apps/modules/api_key.py +++ /dev/null @@ -1,233 +0,0 @@ -# =================== AIPass ==================== -# Name: api_key.py -# Description: API Key Management Module -# Version: 1.0.0 -# Created: 2025-11-15 -# Modified: 2025-11-15 -# ============================================= - -""" -API Key Management Module - -Orchestrates API key and credential operations: -- Get/validate keys -- List providers -- Initialize .env template -""" - -import sys -from pathlib import Path - -from typing import List -from aipass.prax.apps.modules.logger import system_logger as logger -from aipass.cli.apps.modules import console, header, success, error -from aipass.api.apps.handlers.json import json_handler -from aipass.api.apps.handlers.auth import keys, env - - -def print_introspection(): - """Show module introspection - connected handlers and capabilities""" - console.print() - header("API Key Module Introspection") - console.print() - - console.print("[cyan]Purpose:[/cyan] API key management and validation") - console.print() - - console.print("[cyan]Connected Handlers:[/cyan]") - console.print(" • api.apps.handlers.auth.keys") - console.print(" • api.apps.handlers.auth.env (template creation)") - console.print(" • api.apps.handlers.config.provider") - console.print(" • api.apps.handlers.json.json_handler") - console.print() - - console.print("[cyan]Available Workflows:[/cyan]") - console.print(" • get_key() - Retrieve API key") - console.print(" • validate_key() - Validate credentials") - console.print(" • list_providers() - Show providers") - console.print(" • init_env() - Initialize configuration") - console.print() - - -def handle_command(command: str, args: List[str]) -> bool: - """ - Handle API key management commands - - Args: - command: Command name - args: Command arguments - - Returns: - True if command was handled, False otherwise - """ - try: - if command not in ["get-key", "validate", "list-providers", "init"]: - return False - - # Help gate - if args and args[0] in ("--help", "-h", "help"): - print_help() - return True - - # Log operation - json_handler.log_operation(f"api_key_{command}", {"command": command}) - - # Route all commands before introspection gate - if command == "list-providers": - list_providers() - return True - if command == "init": - init_env() - return True - if command == "get-key": - get_key(args) - return True - if command == "validate": - validate_key(args) - return True - - # NO-ARGS GATE (seedgo standard) — only for unrecognized subcommands - if not args: - print_introspection() - return True - - return True - except Exception as e: - logger.error(f"Error in api_key.handle_command: {e}") - raise - - -def get_key(args: List[str]): - """Orchestrate key retrieval workflow""" - provider_name = args[0] if args else "openrouter" - - header(f"Get API Key - {provider_name}") - console.print() - - # Call handler to get key - api_key = keys.get_api_key(provider_name) - - if api_key: - success(f"API key retrieved for {provider_name}") - masked = api_key[:6] + "****" + api_key[-4:] if len(api_key) > 10 else "****" - console.print(f" Key: {masked}") - else: - error(f"Failed to retrieve API key for {provider_name}") - - -def validate_key(args: List[str]): - """Orchestrate key validation workflow""" - provider_name = args[0] if args else "openrouter" - - header(f"Validate API Key - {provider_name}") - console.print() - - # Get key from handler - api_key = keys.get_api_key(provider_name) - - if not api_key: - error(f"No API key found for {provider_name}") - return - - # Validate via handler - is_valid = keys.validate_key(api_key, provider_name) - - if is_valid: - success(f"API key for {provider_name} is valid") - else: - error(f"API key for {provider_name} is invalid") - - -def list_providers(): - """Orchestrate list providers workflow""" - from aipass.api.apps.handlers.config.provider import PROVIDER_DEFAULTS - - header("Available Providers") - console.print() - - for provider_name in sorted(PROVIDER_DEFAULTS): - console.print(f" - {provider_name}") - console.print() - - -def init_env(): - """Orchestrate initialization workflow""" - header("Initialize API Configuration") - console.print() - - env_path = Path.home() / ".secrets" / "aipass" / ".env" - - if env_path.exists(): - success(f"Environment file already exists at {env_path}") - return - - # Create .env template via handler - if env.create_env_template(): - success(f"Environment template created at {env_path}") - else: - error("Failed to create environment template") - - -def print_help(): - """Print help output for API key management""" - import argparse - - parser = argparse.ArgumentParser( - prog="drone @api", - description='API Key Management Module - Manage API keys and credentials', - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -COMMANDS: - get-key - Retrieve API key for a provider - validate - Validate API key - list-providers - List available providers - init - Initialize .env template - -USAGE: - drone @api [args] - drone @api --help - -EXAMPLES: - # Get key for provider - drone @api get-key openrouter - - # Validate key - drone @api validate openrouter - - # List providers - drone @api list-providers - - # Initialize environment - drone @api init - """ - ) - console.print(parser.format_help()) - - -if __name__ == "__main__": - """Standalone execution mode""" - args = sys.argv[1:] - - # Show introspection when run without arguments - if len(args) == 0: - print_introspection() - sys.exit(0) - - # Show help for explicit help flags - if args[0] in ['--help', '-h', 'help']: - print_help() - sys.exit(0) - - # Execute command - command = args[0] - remaining_args = args[1:] if len(args) > 1 else [] - - if handle_command(command, remaining_args): - sys.exit(0) - else: - console.print() - console.print(f"[red]Unknown command: {command}[/red]") - console.print() - console.print("Run [dim]drone @api --help[/dim] for available commands") - console.print() - sys.exit(1) diff --git a/src/aipass/api/apps/modules/google_client.py b/src/aipass/api/apps/modules/google_client.py deleted file mode 100644 index 05e11710..00000000 --- a/src/aipass/api/apps/modules/google_client.py +++ /dev/null @@ -1,364 +0,0 @@ -# =================== AIPass ==================== -# Name: google_client.py -# Description: Google API Client Module — public API for Google services -# Version: 1.0.0 -# Created: 2026-03-14 -# Modified: 2026-03-14 -# ============================================= - -""" -Google API Client Module - -Public API for Google service access across AIPass. -Consumers import from here — never directly from handlers. - -Provides: -- get_drive_service() → Authenticated Google Drive v3 client -- get_google_service() → Any Google API service (Calendar, Sheets, etc.) -- authenticate_google() → Run OAuth2 flow and return credentials -- validate_google() → Check if valid credentials exist -- reauth_google() → Force re-authentication - -Consumer pattern: - from aipass.api.apps.modules.google_client import get_drive_service - service = get_drive_service() - service.files().list(...).execute() - -Thread-safe pattern (for concurrent workers): - service = get_drive_service(thread_safe=True) -""" - -import sys -from typing import List, Optional - -from aipass.prax.apps.modules.logger import system_logger as logger # noqa: F811 -from aipass.cli.apps.modules import console, header, success, error, warning -from aipass.api.apps.handlers.json import json_handler -import aipass.api.apps.handlers.google.auth as google_auth -import aipass.api.apps.handlers.google.service_factory as google_factory -import aipass.api.apps.handlers.google.retry as google_retry - - -# ============================================= -# MODULE INTROSPECTION -# ============================================= - - -def print_introspection() -> None: - """Show module introspection — connected handlers and capabilities.""" - console.print() - header("Google Client Module Introspection") - console.print() - - console.print("[cyan]Purpose:[/cyan] Google API authentication and service factory") - console.print() - - console.print("[cyan]Connected Handlers:[/cyan]") - console.print(" - api.apps.handlers.google.auth") - console.print(" - api.apps.handlers.google.service_factory") - console.print(" - api.apps.handlers.google.retry") - console.print() - - console.print("[cyan]Available Workflows:[/cyan]") - console.print(" - get_drive_service() - Get authenticated Drive client") - console.print(" - get_google_service() - Get any Google API service") - console.print(" - authenticate_google() - Run OAuth2 authentication") - console.print(" - validate_google() - Check credential status") - console.print(" - reauth_google() - Force re-authentication") - console.print() - - available = google_auth.is_available() - status = "[green]installed[/green]" if available else "[red]missing[/red]" - console.print(f"[cyan]Google Libraries:[/cyan] {status}") - - has_creds = google_auth.CREDS_PATH.exists() - cred_status = "[green]found[/green]" if has_creds else "[yellow]not configured[/yellow]" - console.print(f"[cyan]Credentials:[/cyan] {cred_status}") - - has_secret = google_auth.CLIENT_SECRET_PATH.exists() - secret_status = "[green]found[/green]" if has_secret else "[yellow]not configured[/yellow]" - console.print(f"[cyan]Client Secret:[/cyan] {secret_status}") - console.print() - - -def print_help() -> None: - """Print module help.""" - import argparse - - parser = argparse.ArgumentParser( - prog="drone @api", - description="Google Client - Google API authentication and service access", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -COMMANDS (via drone @api): - validate google - Check Google OAuth2 credentials - reauth google - Re-run OAuth2 flow for Google - -CROSS-BRANCH API: - from aipass.api.apps.modules.google_client import get_drive_service - service = get_drive_service() - -CREDENTIAL SETUP: - 1. Get OAuth client secret from Google Cloud Console - 2. Save as: ~/.secrets/aipass/google_client_secret.json - 3. Run: drone @api reauth google - 4. Complete OAuth consent in browser - 5. Credentials saved to: ~/.secrets/aipass/google_creds.json - """ - ) - console.print(parser.format_help()) - - -# ============================================= -# COMMAND HANDLING (drone @api validate google, etc.) -# ============================================= - - -def handle_command(command: str, args: List[str]) -> bool: - """Handle Google client commands routed via drone. - - Args: - command: Command name (e.g. "validate", "reauth") - args: Command arguments — first arg should be "google" - - Returns: - True if command was handled, False to pass through. - """ - # NO-ARGS GATE (seedgo standard) - if not args: - if command == "google": - print_introspection() - return True - return False - - # Only handle commands with "google" as the provider argument - if args[0] != "google": - return False - - # Help gate — only fires after confirming this is a google command - remaining = args[1:] if len(args) > 1 else [] - if remaining and remaining[0] in ("--help", "-h", "help"): - print_help() - return True - - if command == "validate": - _cmd_validate() - return True - elif command == "reauth": - _cmd_reauth() - return True - - return False - - -# ============================================= -# CLI COMMAND IMPLEMENTATIONS -# ============================================= - - -def _cmd_validate() -> None: - """Validate Google OAuth2 credentials.""" - header("Validate Google Credentials") - console.print() - - if not google_auth.is_available(): - error( - "Google auth libraries not installed", - suggestion="pip install google-auth google-auth-oauthlib google-api-python-client", - ) - return - - if not google_auth.CLIENT_SECRET_PATH.exists(): - error( - "Client secret not found", - suggestion=f"Save OAuth client secret to: {google_auth.CLIENT_SECRET_PATH}", - ) - return - - if google_auth.validate_credentials(): - success("Google credentials are valid") - json_handler.log_operation("google_validate", {"status": "valid"}) - else: - warning("No valid Google credentials found") - console.print() - console.print("[dim]Run 'drone @api reauth google' to authenticate[/dim]") - json_handler.log_operation("google_validate", {"status": "invalid"}) - - -def _cmd_reauth() -> None: - """Force Google re-authentication via OAuth flow.""" - header("Google Re-Authentication") - console.print() - - if not google_auth.is_available(): - error( - "Google auth libraries not installed", - suggestion="pip install google-auth google-auth-oauthlib google-api-python-client", - ) - return - - if not google_auth.CLIENT_SECRET_PATH.exists(): - error( - "Client secret not found", - suggestion=f"Save OAuth client secret to: {google_auth.CLIENT_SECRET_PATH}", - ) - return - - warning("Starting OAuth2 flow...") - console.print("[dim]A browser window may open for Google consent.[/dim]") - console.print() - - creds = google_auth.reauth() - - if creds: - success("Google re-authentication successful") - console.print(f"[dim]Credentials saved to: {google_auth.CREDS_PATH}[/dim]") - json_handler.log_operation("google_reauth", {"status": "success"}) - else: - error("Google re-authentication failed") - json_handler.log_operation("google_reauth", {"status": "failed"}) - - -# ============================================= -# PUBLIC API — Cross-branch imports -# ============================================= - - -def get_drive_service(thread_safe: bool = False) -> object: - """Get an authenticated Google Drive v3 service object. - - Args: - thread_safe: If True, builds an isolated service instance - with fresh credentials from disk (for concurrent workers). - - Returns: - Authenticated Drive v3 service object. - - Raises: - RuntimeError: If authentication fails or libraries unavailable. - """ - return get_google_service("drive", "v3", thread_safe=thread_safe) - - -def get_google_service( - service_name: str = "drive", - version: str = "v3", - scopes: Optional[list] = None, - thread_safe: bool = False, -) -> object: - """Get an authenticated Google API service object. - - Supports any Google API: Drive, Calendar, Sheets, Gmail, etc. - - Args: - service_name: Google API service (e.g. "drive", "calendar"). - version: API version (e.g. "v3"). - scopes: OAuth2 scopes. Uses service-specific defaults if not provided. - thread_safe: If True, builds an isolated instance for concurrent use. - - Returns: - Authenticated service object. - - Raises: - RuntimeError: If authentication fails or libraries unavailable. - """ - if not google_auth.is_available(): - raise RuntimeError( - "Google auth libraries not installed. " - "Install: pip install google-auth google-auth-oauthlib google-api-python-client" - ) - - if thread_safe: - service = google_factory.build_thread_safe_service( - service_name, version, scopes - ) - else: - service = google_factory.build_service(service_name, version, scopes) - - if not service: - raise RuntimeError( - f"Failed to authenticate with Google {service_name} API. " - "Run 'drone @api reauth google' to set up credentials." - ) - - return service - - -def authenticate_google(scopes: Optional[list] = None) -> bool: - """Run Google OAuth2 authentication. - - Args: - scopes: OAuth2 scopes to request. - - Returns: - True if authentication succeeded. - """ - creds = google_auth.authenticate(scopes=scopes) - return creds is not None - - -def validate_google(scopes: Optional[list] = None) -> bool: - """Check if valid Google credentials exist. - - Args: - scopes: OAuth2 scopes to validate against. - - Returns: - True if valid credentials exist. - """ - return google_auth.validate_credentials(scopes=scopes) - - -def reauth_google(scopes: Optional[list] = None) -> bool: - """Force Google re-authentication. - - Args: - scopes: OAuth2 scopes to request. - - Returns: - True if re-authentication succeeded. - """ - creds = google_auth.reauth(scopes=scopes) - return creds is not None - - -# Re-export retry utility for consumers that make raw API calls - - -def api_call_with_retry(*args, **kwargs): - """Execute API call with retry logic for SSL and transient errors.""" - return google_retry.api_call_with_retry(*args, **kwargs) - - -def is_ssl_error(error): - """Check if an error is an SSL-related error.""" - return google_retry.is_ssl_error(error) - - -# ============================================= -# STANDALONE EXECUTION -# ============================================= - -if __name__ == "__main__": - args = sys.argv[1:] - - if len(args) == 0: - print_introspection() - sys.exit(0) - - if args[0] in ["--help", "-h", "help"]: - print_help() - sys.exit(0) - - command = args[0] - remaining_args = args[1:] if len(args) > 1 else [] - - if handle_command(command, remaining_args): - sys.exit(0) - else: - console.print() - console.print(f"[red]Unknown command: {command}[/red]") - console.print() - console.print("Run [dim]drone @api --help[/dim] for available commands") - console.print() - sys.exit(1) diff --git a/src/aipass/api/apps/modules/openrouter_client.py b/src/aipass/api/apps/modules/openrouter_client.py deleted file mode 100644 index d1a7e07f..00000000 --- a/src/aipass/api/apps/modules/openrouter_client.py +++ /dev/null @@ -1,380 +0,0 @@ -# =================== AIPass ==================== -# Name: openrouter_client.py -# Description: OpenRouter Client Module -# Version: 1.0.0 -# Created: 2025-11-15 -# Modified: 2025-11-15 -# ============================================= - -""" -OpenRouter Client Module - -Orchestrates LLM API client operations: -- Test connections -- Make API calls -- List models -- Check status -""" - -import sys - -from typing import List -from aipass.prax.apps.modules.logger import system_logger as logger -from aipass.cli.apps.modules import console, header, success, error -from aipass.api.apps.handlers.json import json_handler -from aipass.api.apps.handlers.auth import keys -from aipass.api.apps.handlers.openrouter import client, models - - -def print_introspection(): - """Show module introspection - connected handlers and capabilities""" - console.print() - header("OpenRouter Client Module Introspection") - console.print() - - console.print("[cyan]Purpose:[/cyan] OpenRouter LLM API client operations") - console.print() - - console.print("[cyan]Connected Handlers:[/cyan]") - console.print(" • api.apps.handlers.auth.keys") - console.print(" • api.apps.handlers.openrouter.client") - console.print(" • api.apps.handlers.openrouter.models") - console.print(" • api.apps.handlers.json.json_handler") - console.print() - - console.print("[cyan]Available Workflows:[/cyan]") - console.print(" • test_connection() - Test connection") - console.print(" • make_call() - Make API call") - console.print(" • list_models() - List models") - console.print(" • check_status() - Check status") - console.print() - - -def print_help(): - """Print module help with argparse""" - import argparse - - parser = argparse.ArgumentParser( - prog="drone @api", - description="OpenRouter Client - Manage LLM API connections", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -COMMANDS: - test - Test OpenRouter connection - call - Make API call to model - models - List available models - status - Check connection status - -USAGE: - drone @api test - drone @api call [--model MODEL] - drone @api models - drone @api status - -ARGUMENTS: - prompt - Prompt to send to the model - --model - Model to use (optional) - -EXAMPLES: - # Test OpenRouter connection - drone @api test - - # Make an API call - drone @api call "What is AI?" --model gpt-4 - - # List available models - drone @api models - - # Check connection status - drone @api status - """ - ) - - subparsers = parser.add_subparsers(dest="command", help="Available commands") - - # test command - subparsers.add_parser("test", help="Test OpenRouter connection") - - # call command - call_parser = subparsers.add_parser("call", help="Make API call to model") - call_parser.add_argument("prompt", help="Prompt to send") - call_parser.add_argument("--model", help="Model to use") - - # models command - subparsers.add_parser("models", help="List available models") - - # status command - subparsers.add_parser("status", help="Check connection status") - - console.print(parser.format_help()) - - -def handle_command(command: str, args: List[str]) -> bool: - """ - Handle OpenRouter client commands - - Args: - command: Command name - args: Command arguments - - Returns: - True if command was handled, False otherwise - """ - try: - if command not in ["test", "call", "models", "status"]: - return False - - # Help gate - if args and args[0] in ("--help", "-h", "help"): - print_help() - return True - - # Log operation - json_handler.log_operation(f"openrouter_{command}", {"command": command}) - - # Route all commands before introspection gate - if command == "test": - test_connection() - return True - if command == "models": - list_models(args) - return True - if command == "status": - check_status() - return True - if command == "call": - make_call(args) - return True - - # NO-ARGS GATE (seedgo standard) — only for unrecognized subcommands - if not args: - print_introspection() - return True - - return True - except Exception as e: - logger.error(f"Error in openrouter_client.handle_command: {e}") - raise - - -def test_connection(): - """Orchestrate connection test workflow""" - header("Test OpenRouter Connection") - console.print() - - console.print("[dim]Testing connection...[/dim]") - - # Get API key via handler - api_key = keys.get_api_key("openrouter") - - if not api_key: - diagnosis = keys.diagnose_key("openrouter") - error(diagnosis) - return - - # Real API ping — hit /models endpoint - model_list = models.fetch_models_from_api(api_key) - - if model_list: - success(f"Connection successful — {len(model_list)} models available") - else: - error("Connection failed — could not reach OpenRouter API") - - -def make_call(args: List[str]): - """Orchestrate API call workflow""" - header("OpenRouter API Call") - console.print() - - if not args: - error("Prompt required", suggestion="drone @api call \"your prompt\" --model MODEL") - return - - # Parse args: first non-flag arg is prompt, --model MODEL is optional - prompt = None - model = None - i = 0 - while i < len(args): - if args[i] == "--model" and i + 1 < len(args): - model = args[i + 1] - i += 2 - elif prompt is None: - prompt = args[i] - i += 1 - else: - i += 1 - - if not prompt: - error("Prompt required", suggestion="drone @api call \"your prompt\" --model MODEL") - return - - if not model: - error("Model required", suggestion="drone @api call \"your prompt\" --model anthropic/claude-3.5-sonnet") - return - - console.print(f"[dim]Calling {model}...[/dim]") - - response = client.get_response(prompt, caller="cli", model=model) - - if response: - success(f"Response received ({len(response['content'])} chars)") - console.print() - console.print(response["content"]) - else: - error("API call failed") - - -def list_models(args: List[str] | None = None): - """Orchestrate list models workflow""" - header("Available Models") - console.print() - - show_all = args and "--all" in args - - # Get API key via handler - api_key = keys.get_api_key("openrouter") - - if not api_key: - diagnosis = keys.diagnose_key("openrouter") - error(diagnosis) - return - - console.print("[dim]Fetching available models...[/dim]") - - # Call handler to fetch models - model_list = models.fetch_models_from_api(api_key) - - if not model_list: - error("Failed to fetch models") - return - - success(f"Found {len(model_list)} models") - console.print() - - # Format as table - display_count = len(model_list) if show_all else min(10, len(model_list)) - - console.print(f" {'Model':<50} {'Context':>10} {'$/prompt':>10} {'$/compl':>10}") - console.print(f" {'─' * 50} {'─' * 10} {'─' * 10} {'─' * 10}") - - for model_data in model_list[:display_count]: - model_id = model_data.get("id", "unknown") - context = model_data.get("context_length", 0) - pricing = model_data.get("pricing", {}) - prompt_cost = pricing.get("prompt", "0") - completion_cost = pricing.get("completion", "0") - - # Format context length - if context >= 1_000_000: - ctx_str = f"{context // 1_000_000}M" - elif context >= 1_000: - ctx_str = f"{context // 1_000}k" - else: - ctx_str = str(context) - - # Format pricing - if str(prompt_cost) == "0" and str(completion_cost) == "0": - p_str = "free" - c_str = "free" - else: - p_str = f"${prompt_cost}" - c_str = f"${completion_cost}" - - console.print(f" {model_id:<50} {ctx_str:>10} {p_str:>10} {c_str:>10}") - - if not show_all and len(model_list) > 10: - console.print() - console.print(f" [dim]Showing 10 of {len(model_list)} — use --all for full list[/dim]") - - -def check_status(): - """Orchestrate status check workflow""" - header("OpenRouter Client Status") - console.print() - - # Key status - api_key = keys.get_api_key("openrouter") - - if api_key: - masked = api_key[:8] + "..." + api_key[-4:] - console.print(f" [cyan]Key configured:[/cyan] [green]yes[/green]") - console.print(f" [cyan]Key:[/cyan] {masked}") - else: - console.print(f" [cyan]Key configured:[/cyan] [red]no[/red]") - diagnosis = keys.diagnose_key("openrouter") - console.print(f" [cyan]Reason:[/cyan] {diagnosis}") - - console.print(f" [cyan]Provider:[/cyan] OpenRouter") - console.print(f" [cyan]Base URL:[/cyan] https://openrouter.ai/api/v1") - - # OpenAI SDK availability - try: - import openai # noqa: F401 - console.print(f" [cyan]OpenAI SDK:[/cyan] [green]available[/green]") - except ImportError: - logger.warning("OpenAI SDK not installed") - console.print(f" [cyan]OpenAI SDK:[/cyan] [red]missing[/red]") - - # Client cache stats - cache_stats = client.get_cache_stats() - console.print(f" [cyan]Cached clients:[/cyan] {cache_stats['cached_clients']}/{cache_stats['max_cache_size']}") - console.print() - - -# ============================================= -# PUBLIC API - Re-export handler functions -# ============================================= - -def get_response(prompt: str, caller: str | None = None, model: str | None = None, **kwargs): - """ - Public API: Get response from OpenRouter - - This is a re-export of the handler function for cross-branch access. - Flow and other branches should use this module-level function instead - of importing directly from handlers. - - Args: - prompt: User prompt text - caller: Module making the request (auto-detected if not provided) - model: Model to use (required - caller must provide from branch config) - **kwargs: Additional OpenAI API parameters - - Returns: - Dict with 'content', 'id', 'model' or None on failure - - Example: - >>> from aipass.api.apps.modules.openrouter_client import get_response - >>> response = get_response("Hello", caller="flow", model="anthropic/claude-3.5-sonnet") - >>> if response: - ... console.print(response['content']) - """ - return client.get_response(prompt, caller, model, **kwargs) - - -if __name__ == "__main__": - """Standalone execution mode""" - args = sys.argv[1:] - - # Show introspection when run without arguments - if len(args) == 0: - print_introspection() - sys.exit(0) - - # Show help for explicit help flags - if args[0] in ['--help', '-h', 'help']: - print_help() - sys.exit(0) - - # Execute command - command = args[0] - remaining_args = args[1:] if len(args) > 1 else [] - - if handle_command(command, remaining_args): - sys.exit(0) - else: - console.print() - console.print(f"[red]Unknown command: {command}[/red]") - console.print() - console.print("Run [dim]drone @api --help[/dim] for available commands") - console.print() - sys.exit(1) diff --git a/src/aipass/api/apps/modules/usage_tracker.py b/src/aipass/api/apps/modules/usage_tracker.py deleted file mode 100644 index 56b174de..00000000 --- a/src/aipass/api/apps/modules/usage_tracker.py +++ /dev/null @@ -1,311 +0,0 @@ -# =================== AIPass ==================== -# Name: usage_tracker.py -# Description: Usage Tracking Module -# Version: 1.0.0 -# Created: 2025-11-15 -# Modified: 2025-11-15 -# ============================================= - -""" -Usage Tracking Module - -Orchestrates API usage monitoring operations: -- Track generation usage -- Display statistics -- Session summaries -- Cleanup old data -""" - -import sys -from pathlib import Path - -from typing import List -from aipass.prax.apps.modules.logger import system_logger as logger -from aipass.cli.apps.modules import console, header, success, error, warning -from aipass.api.apps.handlers.json import json_handler -from aipass.api.apps.handlers.usage import tracking, aggregation, cleanup -from aipass.api.apps.handlers.usage.cleanup import DEFAULT_RETENTION_DAYS - - -def print_introspection(): - """Show module introspection - connected handlers and capabilities""" - console.print() - header("Usage Tracker Module Introspection") - console.print() - - console.print("[cyan]Purpose:[/cyan] API usage monitoring and cost tracking") - console.print() - - console.print("[cyan]Connected Handlers:[/cyan]") - console.print(" • api.apps.handlers.usage.tracking") - console.print(" • api.apps.handlers.usage.aggregation") - console.print(" • api.apps.handlers.usage.cleanup") - console.print(" • api.apps.handlers.json.json_handler") - console.print() - - console.print("[cyan]Available Workflows:[/cyan]") - console.print(" • track_usage() - Track usage") - console.print(" • show_stats() - Show statistics") - console.print(" • show_session() - Show session") - console.print(" • show_caller_usage() - Caller stats") - console.print(" • cleanup_data() - Clean old data") - console.print() - - -def print_help(): - """Print module help with argparse""" - import argparse - - parser = argparse.ArgumentParser( - prog="drone @api", - description="Usage Tracker - Monitor API usage and costs", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -COMMANDS: - track - Track API usage - stats - Show usage statistics - session - Show session data - caller-usage - Show usage by caller - cleanup - Clean up old usage data - -USAGE: - drone @api track - drone @api stats - drone @api session - drone @api caller-usage - drone @api cleanup [days] - -ARGUMENTS: - caller - Caller identifier - days - Number of days to retain (default: 30) - -EXAMPLES: - # Track usage for a caller - drone @api track my_application - - # Show usage statistics - drone @api stats - - # Show session data - drone @api session - - # Show usage for specific caller - drone @api caller-usage my_application - - # Cleanup data older than 60 days - drone @api cleanup 60 - """ - ) - - subparsers = parser.add_subparsers(dest="command", help="Available commands") - - # track command - track_parser = subparsers.add_parser("track", help="Track API usage") - track_parser.add_argument("caller", help="Caller identifier") - - # stats command - subparsers.add_parser("stats", help="Show usage statistics") - - # session command - subparsers.add_parser("session", help="Show session data") - - # caller-usage command - caller_parser = subparsers.add_parser("caller-usage", help="Show usage by caller") - caller_parser.add_argument("caller", help="Caller identifier") - - # cleanup command - cleanup_parser = subparsers.add_parser("cleanup", help="Clean up old usage data") - cleanup_parser.add_argument("days", nargs="?", default=str(DEFAULT_RETENTION_DAYS), help=f"Days to retain (default: {DEFAULT_RETENTION_DAYS})") - - console.print(parser.format_help()) - - -def handle_command(command: str, args: List[str]) -> bool: - """ - Handle usage tracking commands - - Args: - command: Command name - args: Command arguments - - Returns: - True if command was handled, False otherwise - """ - try: - if command not in ["track", "stats", "session", "caller-usage", "cleanup"]: - return False - - # Help gate - if args and args[0] in ("--help", "-h", "help"): - print_help() - return True - - # Log operation - json_handler.log_operation(f"usage_{command}", {"command": command}) - - # Route all commands before introspection gate - if command == "stats": - show_stats() - return True - if command == "session": - show_session() - return True - if command == "track": - track_usage(args) - return True - if command == "caller-usage": - show_caller_usage(args) - return True - if command == "cleanup": - cleanup_data(args) - return True - - # NO-ARGS GATE (seedgo standard) — only for unrecognized subcommands - if not args: - print_introspection() - return True - - return True - except Exception as e: - logger.error(f"Error in usage_tracker.handle_command: {e}") - raise - - -def track_usage(args: List[str]): - """Orchestrate usage tracking workflow""" - header("Track API Usage") - console.print() - - if not args: - error("Generation ID required", suggestion="drone @api track [caller]") - return - - generation_id = args[0] - caller = args[1] if len(args) > 1 else "manual" - - console.print(f"[dim]Tracking generation {generation_id}...[/dim]") - - result = tracking.track_usage(generation_id, caller) - - if result.get("success"): - metrics = result.get("metrics", {}) - success(f"Tracked: {metrics.get('tokens_prompt', 0)} prompt + {metrics.get('tokens_completion', 0)} completion tokens, ${metrics.get('total_cost', 0):.6f}") - else: - error(f"Tracking failed: {result.get('error', 'unknown')}") - - -def show_stats(): - """Orchestrate overall statistics display workflow (aggregate across all callers)""" - header("Usage Statistics") - console.print() - - stats = aggregation.get_overall_stats() - - if stats: - console.print(f" Total Requests: {stats.get('total_requests', 0)}") - console.print(f" Total Cost: ${stats.get('total_cost', 0.0):.6f}") - console.print(f" Total Tokens: {stats.get('total_tokens', 0)}") - console.print(f" Callers: {stats.get('callers', 0)}") - models = stats.get('models_used', []) - if models: - console.print(f" Models Used: {', '.join(models)}") - else: - warning("No usage data available") - - -def show_session(): - """Orchestrate session summary workflow""" - header("Session Summary") - console.print() - - # Call handler for session data - summary = aggregation.get_session_summary() - - if summary: - console.print(f" Session Requests: {summary.get('total_requests', 0)}") - console.print(f" Session Cost: ${summary.get('total_cost', 0.0):.6f}") - console.print(f" Session Tokens: {summary.get('total_tokens', 0)}") - else: - warning("No session data available") - - -def show_caller_usage(args: List[str]): - """Orchestrate caller usage display workflow""" - if not args: - error("Caller name required") - return - - caller = args[0] - - header(f"Usage for Caller: {caller}") - console.print() - - # Call handler for caller stats - usage = aggregation.get_caller_usage(caller) - - if usage: - console.print(f" Requests: {usage.get('requests', 0)}") - console.print(f" Total Cost: ${usage.get('total_cost', 0.0):.6f}") - console.print(f" Total Tokens: {usage.get('total_tokens', 0)}") - else: - warning(f"No usage data found for caller: {caller}") - - -def cleanup_data(args: List[str]): - """Orchestrate cleanup workflow""" - days = int(args[0]) if args else DEFAULT_RETENTION_DAYS - - header(f"Cleanup Old Data (retain {days} days)") - console.print() - - # Call handler for cleanup - # Navigate: usage_tracker.py -> modules/ -> apps/ -> api/ - API_JSON_DIR = Path(__file__).resolve().parent.parent.parent / "api_json" - data_path = API_JSON_DIR / "usage_tracker_data.json" - - if not data_path.exists(): - warning("No usage data file found — nothing to clean") - return - - removed = cleanup.cleanup_old_data(data_path, days) - - if removed > 0: - success(f"Cleaned up {removed} entries older than {days} days") - - # Fire trigger event - try: - from aipass.trigger.apps.modules.core import trigger - trigger.fire('usage_data_cleaned', days=days, data_path=str(data_path)) - except ImportError: - logger.warning("Trigger module not available — skipping event fire") - else: - success(f"Nothing to clean — no entries older than {days} days") - - -if __name__ == "__main__": - """Standalone execution mode""" - args = sys.argv[1:] - - # Show introspection when run without arguments - if len(args) == 0: - print_introspection() - sys.exit(0) - - # Show help for explicit help flags - if args[0] in ['--help', '-h', 'help']: - print_help() - sys.exit(0) - - # Execute command - command = args[0] - remaining_args = args[1:] if len(args) > 1 else [] - - if handle_command(command, remaining_args): - sys.exit(0) - else: - console.print() - console.print(f"[red]Unknown command: {command}[/red]") - console.print() - console.print("Run [dim]drone @api --help[/dim] for available commands") - console.print() - sys.exit(1) diff --git a/src/aipass/api/apps/plugins/README.md b/src/aipass/api/apps/plugins/README.md deleted file mode 100644 index a1cbf5d4..00000000 --- a/src/aipass/api/apps/plugins/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Plugins - -Scheduled tasks and extensions for `API`. - -Plugins are standalone units of work that can be scheduled via the daemon. Each plugin handles one specific recurring task. diff --git a/src/aipass/api/apps/plugins/__init__.py b/src/aipass/api/apps/plugins/__init__.py deleted file mode 100644 index 69b056dd..00000000 --- a/src/aipass/api/apps/plugins/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Plugins package - Pluggable components for branch capabilities diff --git a/src/aipass/api/docs/README.md b/src/aipass/api/docs/README.md deleted file mode 100644 index 579fe5fd..00000000 --- a/src/aipass/api/docs/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Docs - -Documentation, design plans, and reference material for the API branch. diff --git a/src/aipass/api/pytest.ini b/src/aipass/api/pytest.ini deleted file mode 100644 index 6e0f5894..00000000 --- a/src/aipass/api/pytest.ini +++ /dev/null @@ -1,18 +0,0 @@ -[pytest] -# Test discovery paths -testpaths = tests - -# Test file patterns -python_files = test_*.py -python_functions = test_* -python_classes = Test* - -# Command-line options (always applied) -# Verbose, short tracebacks, strict markers, show all outcomes -addopts = -v --tb=short --strict-markers -ra - -# Test markers (for categorizing tests) -markers = - unit: Unit tests - integration: Integration tests - slow: Tests that take significant time diff --git a/src/aipass/api/templates/README.md b/src/aipass/api/templates/README.md deleted file mode 100644 index bd5ca9f6..00000000 --- a/src/aipass/api/templates/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Templates - -Branch-specific templates for `API`. - -Any templates this branch provides to the system or uses internally. Examples: plan templates (flow), trinity templates (memory), test templates (seedgo). diff --git a/src/aipass/api/tests/README.md b/src/aipass/api/tests/README.md deleted file mode 100644 index 1f7e37e7..00000000 --- a/src/aipass/api/tests/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# Tests - -Pytest unit tests for `API`. - -- `conftest.py` — Shared fixtures (temp dirs, mocks, sample data). -- `test_*.py` — Test files. Standard tests cover JSON handler, CLI routing, and error resilience. Custom tests cover branch-specific domain logic. diff --git a/src/aipass/api/tests/__init__.py b/src/aipass/api/tests/__init__.py deleted file mode 100644 index 711dba10..00000000 --- a/src/aipass/api/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Tests package for api diff --git a/src/aipass/api/tests/conftest.py b/src/aipass/api/tests/conftest.py deleted file mode 100644 index 1668730f..00000000 --- a/src/aipass/api/tests/conftest.py +++ /dev/null @@ -1,157 +0,0 @@ - -# ===================AIPASS==================== -# META DATA HEADER -# Name: tests/conftest.py -# Date: 2025-11-08 -# Version: 2.0.0 -# Category: api/tests -# -# CHANGELOG (Max 5 entries): -# - v2.0.0 (2026-03-27): Added mock_infrastructure, mock_logger, -# mock_json_handler fixtures for test quality compliance -# - v1.0.0 (2025-11-08): Initial implementation - Shared pytest fixtures -# -# CODE STANDARDS: -# - Error handling: Use error handler system (apps/handlers/error/) -# ============================================= - -"""Shared pytest fixtures for api tests""" -import os -import tempfile - -# Redirect prax logs to temp directory during tests -# Must be set before any prax imports to catch logger initialization -if "AIPASS_TEST_LOG_DIR" not in os.environ: - os.environ["AIPASS_TEST_LOG_DIR"] = tempfile.mkdtemp(prefix="aipass_test_logs_") - -import importlib -import logging -import sys -import types -from pathlib import Path -from typing import Generator -from unittest.mock import MagicMock - -import pytest - - -# ============ BRANCH CONFIG ============ -BRANCH_MODULE = "api" -# ======================================= - -# --------------------------------------------------------------------------- -# Dynamic import for json_handler isolation -# --------------------------------------------------------------------------- - -_handler_pkg = f"aipass.{BRANCH_MODULE}.apps.handlers" -_json_mod_path = f"aipass.{BRANCH_MODULE}.apps.handlers.json.json_handler" - -if _handler_pkg not in sys.modules: - _stub = types.ModuleType(_handler_pkg) - _handlers_dir = ( - Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers" - ) - _stub.__path__ = [str(_handlers_dir)] - sys.modules[_handler_pkg] = _stub - -_json_mod = importlib.import_module(_json_mod_path) - - -# --------------------------------------------------------------------------- -# JSON_DIR variable discovery -# --------------------------------------------------------------------------- - -_JSON_DIR_ATTR: str | None = None -_JSON_DIR_CANDIDATES = [ - f"{BRANCH_MODULE.upper()}_JSON_DIR", - "JSON_DIR", - "BRANCH_JSON_DIR", - "_JSON_DIR", -] - -for _candidate in _JSON_DIR_CANDIDATES: - if hasattr(_json_mod, _candidate): - _JSON_DIR_ATTR = _candidate - break - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - -@pytest.fixture() -def temp_test_dir(tmp_path: Path) -> Generator[Path, None, None]: - """Creates temporary directory for testing, cleans up after""" - test_dir = tmp_path / "test_workspace" - test_dir.mkdir(parents=True, exist_ok=True) - yield test_dir - for child in test_dir.iterdir(): - if child.is_file(): - child.unlink() - - -@pytest.fixture() -def sample_test_data() -> dict: - """Provides sample test data""" - return { - "created": "2026-01-01", - "last_updated": "2026-01-15", - "entries": [ - {"id": 1, "name": "alpha", "status": "active"}, - {"id": 2, "name": "beta", "status": "pending"}, - ], - "metadata": { - "source": "test_fixture", - "version": "1.0.0", - }, - } - - -@pytest.fixture(autouse=True) -def mock_infrastructure( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Autouse fixture that isolates JSON operations and silences logging. - - This fixture: - 1. Redirects the branch's JSON_DIR to tmp_path (test isolation) - 2. Patches the branch logger to a NullHandler (no console noise) - """ - if _JSON_DIR_ATTR is not None: - monkeypatch.setattr(_json_mod, _JSON_DIR_ATTR, tmp_path) - - logger_names = [ - f"aipass.{BRANCH_MODULE}", - BRANCH_MODULE, - f"{BRANCH_MODULE}.apps.handlers.json.json_handler", - ] - for logger_name in logger_names: - log = logging.getLogger(logger_name) - monkeypatch.setattr(log, "handlers", [logging.NullHandler()]) - - -@pytest.fixture() -def mock_logger() -> MagicMock: - """Standalone mock logger for tests that need to verify logging calls.""" - mock = MagicMock(spec=logging.Logger) - mock.debug = MagicMock() - mock.info = MagicMock() - mock.warning = MagicMock() - mock.error = MagicMock() - mock.critical = MagicMock() - return mock - - -@pytest.fixture() -def mock_json_handler() -> MagicMock: - """Standalone mock json_handler for isolating from real file I/O.""" - handler = MagicMock() - handler.load_json = MagicMock(return_value={}) - handler.save_json = MagicMock(return_value=True) - handler.ensure_json_exists = MagicMock(return_value=True) - handler.ensure_module_jsons = MagicMock(return_value=True) - handler.get_json_path = MagicMock(return_value=Path("/tmp/mock.json")) - handler.validate_json_structure = MagicMock(return_value=True) - handler.log_operation = MagicMock(return_value=True) - return handler diff --git a/src/aipass/api/tests/test_api_key.py b/src/aipass/api/tests/test_api_key.py deleted file mode 100644 index ebd70b26..00000000 --- a/src/aipass/api/tests/test_api_key.py +++ /dev/null @@ -1,696 +0,0 @@ -# =================== AIPass ==================== -# Name: test_api_key.py -# Description: Tests for API Key Management Module -# Version: 1.0.0 -# Created: 2026-03-24 -# Modified: 2026-03-24 -# ============================================= - -""" -Tests for api_key.py — API key management module. - -Tests: -- handle_command routing for all known commands -- handle_command returns False for unknown commands -- Help gate triggers print_help -- Introspection gate triggers print_introspection for no-args -- get_key success/failure paths -- validate_key valid/invalid/no-key paths -- init_env existing/create paths -- list_providers workflow -- json_handler.log_operation called on valid commands -""" - -from unittest.mock import patch, MagicMock, call -from pathlib import Path - -import pytest - -from aipass.api.apps.modules import api_key - - -# ============================================= -# Shared patch decorator — suppresses all CLI output -# ============================================= - -PATCH_CONSOLE = "aipass.api.apps.modules.api_key.console" -PATCH_HEADER = "aipass.api.apps.modules.api_key.header" -PATCH_SUCCESS = "aipass.api.apps.modules.api_key.success" -PATCH_ERROR = "aipass.api.apps.modules.api_key.error" -PATCH_WARNING = "aipass.api.apps.modules.api_key.warning" -PATCH_JSON_HANDLER = "aipass.api.apps.modules.api_key.json_handler" -PATCH_KEYS = "aipass.api.apps.modules.api_key.keys" -PATCH_ENV = "aipass.api.apps.modules.api_key.env" -PATCH_PROVIDER = "aipass.api.apps.modules.api_key.provider" - - -# ============================================= -# handle_command — routing tests -# ============================================= - - -@patch(PATCH_CONSOLE) -@patch(PATCH_HEADER) -@patch(PATCH_JSON_HANDLER) -def test_handle_command_returns_false_for_unknown_command(mock_jh, mock_header, mock_console): - """Unknown command should return False without logging.""" - result = api_key.handle_command("unknown", []) - - assert result is False - mock_jh.log_operation.assert_not_called() - - -@patch(PATCH_CONSOLE) -@patch(PATCH_HEADER) -@patch(PATCH_SUCCESS) -@patch(PATCH_ERROR) -@patch(PATCH_JSON_HANDLER) -@patch(PATCH_KEYS) -def test_handle_command_routes_get_key(mock_keys, mock_jh, mock_error, mock_success, mock_header, mock_console): - """get-key command should route to get_key with args.""" - mock_keys.get_api_key.return_value = "sk-test1234567890abcdef" - - result = api_key.handle_command("get-key", ["openrouter"]) - - assert result is True - mock_keys.get_api_key.assert_called_once_with("openrouter") - - -@patch(PATCH_CONSOLE) -@patch(PATCH_HEADER) -@patch(PATCH_SUCCESS) -@patch(PATCH_ERROR) -@patch(PATCH_JSON_HANDLER) -@patch(PATCH_KEYS) -def test_handle_command_routes_validate(mock_keys, mock_jh, mock_error, mock_success, mock_header, mock_console): - """validate command should route to validate_key with args.""" - mock_keys.get_api_key.return_value = "sk-test1234567890abcdef" - mock_keys.validate_key.return_value = True - - result = api_key.handle_command("validate", ["openrouter"]) - - assert result is True - mock_keys.get_api_key.assert_called_once_with("openrouter") - mock_keys.validate_key.assert_called_once_with("sk-test1234567890abcdef", "openrouter") - - -@patch(PATCH_CONSOLE) -@patch(PATCH_HEADER) -@patch(PATCH_JSON_HANDLER) -def test_handle_command_routes_list_providers(mock_jh, mock_header, mock_console): - """list-providers command should route to list_providers.""" - result = api_key.handle_command("list-providers", []) - - assert result is True - mock_header.assert_called_once_with("Available Providers") - - -@patch(PATCH_CONSOLE) -@patch(PATCH_HEADER) -@patch(PATCH_SUCCESS) -@patch(PATCH_ERROR) -@patch(PATCH_JSON_HANDLER) -@patch(PATCH_ENV) -def test_handle_command_routes_init(mock_env, mock_jh, mock_error, mock_success, mock_header, mock_console): - """init command should route to init_env.""" - with patch("aipass.api.apps.modules.api_key.Path") as mock_path_cls: - mock_env_path = MagicMock() - mock_env_path.exists.return_value = True - mock_path_cls.home.return_value.__truediv__ = MagicMock(return_value=mock_env_path) - # Simpler: just mock the whole Path.home() chain - mock_path_cls.home.return_value = MagicMock() - mock_path_cls.home.return_value.__truediv__ = MagicMock() - mock_home = MagicMock() - mock_secrets = MagicMock() - mock_aipass = MagicMock() - mock_env_path = MagicMock() - mock_env_path.exists.return_value = True - mock_home.__truediv__ = MagicMock(return_value=mock_secrets) - mock_secrets.__truediv__ = MagicMock(return_value=mock_aipass) - mock_aipass.__truediv__ = MagicMock(return_value=mock_env_path) - mock_path_cls.home.return_value = mock_home - - result = api_key.handle_command("init", []) - - assert result is True - - -# ============================================= -# handle_command — gate tests -# ============================================= - - -@patch(PATCH_CONSOLE) -@patch(PATCH_HEADER) -@patch(PATCH_JSON_HANDLER) -def test_handle_command_help_gate(mock_jh, mock_header, mock_console): - """--help arg should trigger print_help and return True without logging.""" - result = api_key.handle_command("get-key", ["--help"]) - - assert result is True - # Help gate fires before log_operation - mock_jh.log_operation.assert_not_called() - - -@patch(PATCH_CONSOLE) -@patch(PATCH_HEADER) -@patch(PATCH_JSON_HANDLER) -def test_handle_command_help_gate_short_flag(mock_jh, mock_header, mock_console): - """Short -h flag should also trigger help gate.""" - result = api_key.handle_command("validate", ["-h"]) - - assert result is True - mock_jh.log_operation.assert_not_called() - - -@patch(PATCH_CONSOLE) -@patch(PATCH_HEADER) -@patch(PATCH_SUCCESS) -@patch(PATCH_ERROR) -@patch(PATCH_JSON_HANDLER) -@patch(PATCH_KEYS) -def test_handle_command_get_key_no_args_defaults_to_openrouter( - mock_keys, mock_jh, mock_error, mock_success, mock_header, mock_console -): - """get-key with no args should execute with default provider 'openrouter'.""" - mock_keys.get_api_key.return_value = "sk-or-test-key-123" - - result = api_key.handle_command("get-key", []) - - assert result is True - mock_keys.get_api_key.assert_called_once_with("openrouter") - mock_header.assert_called_with("Get API Key - openrouter") - - -@patch(PATCH_CONSOLE) -@patch(PATCH_HEADER) -@patch(PATCH_SUCCESS) -@patch(PATCH_ERROR) -@patch(PATCH_JSON_HANDLER) -@patch(PATCH_KEYS) -def test_handle_command_validate_no_args_defaults_to_openrouter( - mock_keys, mock_jh, mock_error, mock_success, mock_header, mock_console -): - """validate with no args should execute with default provider 'openrouter'.""" - mock_keys.get_api_key.return_value = "sk-or-test-key-123" - mock_keys.validate_key.return_value = True - - result = api_key.handle_command("validate", []) - - assert result is True - mock_keys.get_api_key.assert_called_once_with("openrouter") - mock_header.assert_called_with("Validate API Key - openrouter") - - -@patch(PATCH_CONSOLE) -@patch(PATCH_HEADER) -@patch(PATCH_JSON_HANDLER) -def test_handle_command_list_providers_standalone(mock_jh, mock_header, mock_console): - """list-providers should work without args and not hit introspection gate.""" - result = api_key.handle_command("list-providers", []) - - assert result is True - # Should call "Available Providers" header, not introspection - mock_header.assert_called_with("Available Providers") - - -# ============================================= -# get_key tests -# ============================================= - - -@patch(PATCH_CONSOLE) -@patch(PATCH_HEADER) -@patch(PATCH_SUCCESS) -@patch(PATCH_ERROR) -@patch(PATCH_KEYS) -def test_get_key_success(mock_keys, mock_error, mock_success, mock_header, mock_console): - """Successful key retrieval should call success().""" - mock_keys.get_api_key.return_value = "sk-test1234567890abcdef" - - api_key.get_key(["openrouter"]) - - mock_keys.get_api_key.assert_called_once_with("openrouter") - mock_success.assert_called_once_with("API key retrieved for openrouter") - mock_error.assert_not_called() - - -@patch(PATCH_CONSOLE) -@patch(PATCH_HEADER) -@patch(PATCH_SUCCESS) -@patch(PATCH_ERROR) -@patch(PATCH_KEYS) -def test_get_key_failure(mock_keys, mock_error, mock_success, mock_header, mock_console): - """Failed key retrieval (None returned) should call error().""" - mock_keys.get_api_key.return_value = None - - api_key.get_key(["openrouter"]) - - mock_keys.get_api_key.assert_called_once_with("openrouter") - mock_error.assert_called_once_with("Failed to retrieve API key for openrouter") - mock_success.assert_not_called() - - -@patch(PATCH_CONSOLE) -@patch(PATCH_HEADER) -@patch(PATCH_SUCCESS) -@patch(PATCH_ERROR) -@patch(PATCH_KEYS) -def test_get_key_default_provider(mock_keys, mock_error, mock_success, mock_header, mock_console): - """Empty args list should default to 'openrouter' provider.""" - mock_keys.get_api_key.return_value = "sk-test1234567890abcdef" - - api_key.get_key([]) - - mock_keys.get_api_key.assert_called_once_with("openrouter") - - -# ============================================= -# validate_key tests -# ============================================= - - -@patch(PATCH_CONSOLE) -@patch(PATCH_HEADER) -@patch(PATCH_SUCCESS) -@patch(PATCH_ERROR) -@patch(PATCH_KEYS) -def test_validate_key_valid(mock_keys, mock_error, mock_success, mock_header, mock_console): - """Valid key should call success().""" - mock_keys.get_api_key.return_value = "sk-test1234567890abcdef" - mock_keys.validate_key.return_value = True - - api_key.validate_key(["openrouter"]) - - mock_keys.get_api_key.assert_called_once_with("openrouter") - mock_keys.validate_key.assert_called_once_with("sk-test1234567890abcdef", "openrouter") - mock_success.assert_called_once_with("API key for openrouter is valid") - mock_error.assert_not_called() - - -@patch(PATCH_CONSOLE) -@patch(PATCH_HEADER) -@patch(PATCH_SUCCESS) -@patch(PATCH_ERROR) -@patch(PATCH_KEYS) -def test_validate_key_no_key(mock_keys, mock_error, mock_success, mock_header, mock_console): - """No key found should call error() and skip validation.""" - mock_keys.get_api_key.return_value = None - - api_key.validate_key(["openrouter"]) - - mock_keys.get_api_key.assert_called_once_with("openrouter") - mock_keys.validate_key.assert_not_called() - mock_error.assert_called_once_with("No API key found for openrouter") - mock_success.assert_not_called() - - -@patch(PATCH_CONSOLE) -@patch(PATCH_HEADER) -@patch(PATCH_SUCCESS) -@patch(PATCH_ERROR) -@patch(PATCH_KEYS) -def test_validate_key_invalid(mock_keys, mock_error, mock_success, mock_header, mock_console): - """Invalid key should call error().""" - mock_keys.get_api_key.return_value = "sk-test1234567890abcdef" - mock_keys.validate_key.return_value = False - - api_key.validate_key(["openrouter"]) - - mock_keys.get_api_key.assert_called_once_with("openrouter") - mock_keys.validate_key.assert_called_once_with("sk-test1234567890abcdef", "openrouter") - mock_error.assert_called_once_with("API key for openrouter is invalid") - mock_success.assert_not_called() - - -# ============================================= -# init_env tests -# ============================================= - - -@patch(PATCH_CONSOLE) -@patch(PATCH_HEADER) -@patch(PATCH_SUCCESS) -@patch(PATCH_ERROR) -@patch(PATCH_ENV) -def test_init_env_already_exists(mock_env, mock_error, mock_success, mock_header, mock_console): - """Existing env file should call success() and skip creation.""" - with patch("aipass.api.apps.modules.api_key.Path") as mock_path_cls: - mock_env_path = MagicMock() - mock_env_path.exists.return_value = True - - mock_home = MagicMock() - mock_secrets = MagicMock() - mock_aipass_dir = MagicMock() - mock_home.__truediv__ = MagicMock(return_value=mock_secrets) - mock_secrets.__truediv__ = MagicMock(return_value=mock_aipass_dir) - mock_aipass_dir.__truediv__ = MagicMock(return_value=mock_env_path) - mock_path_cls.home.return_value = mock_home - - api_key.init_env() - - mock_success.assert_called_once() - assert "already exists" in mock_success.call_args[0][0] - mock_env.create_env_template.assert_not_called() - mock_error.assert_not_called() - - -@patch(PATCH_CONSOLE) -@patch(PATCH_HEADER) -@patch(PATCH_SUCCESS) -@patch(PATCH_ERROR) -@patch(PATCH_ENV) -def test_init_env_creates_template(mock_env, mock_error, mock_success, mock_header, mock_console): - """Missing env file should call create_env_template and success() on True.""" - mock_env.create_env_template.return_value = True - - with patch("aipass.api.apps.modules.api_key.Path") as mock_path_cls: - mock_env_path = MagicMock() - mock_env_path.exists.return_value = False - - mock_home = MagicMock() - mock_secrets = MagicMock() - mock_aipass_dir = MagicMock() - mock_home.__truediv__ = MagicMock(return_value=mock_secrets) - mock_secrets.__truediv__ = MagicMock(return_value=mock_aipass_dir) - mock_aipass_dir.__truediv__ = MagicMock(return_value=mock_env_path) - mock_path_cls.home.return_value = mock_home - - api_key.init_env() - - mock_env.create_env_template.assert_called_once() - mock_success.assert_called_once() - assert "template created" in mock_success.call_args[0][0] - mock_error.assert_not_called() - - -@patch(PATCH_CONSOLE) -@patch(PATCH_HEADER) -@patch(PATCH_SUCCESS) -@patch(PATCH_ERROR) -@patch(PATCH_ENV) -def test_init_env_create_failure(mock_env, mock_error, mock_success, mock_header, mock_console): - """Failed template creation should call error().""" - mock_env.create_env_template.return_value = False - - with patch("aipass.api.apps.modules.api_key.Path") as mock_path_cls: - mock_env_path = MagicMock() - mock_env_path.exists.return_value = False - - mock_home = MagicMock() - mock_secrets = MagicMock() - mock_aipass_dir = MagicMock() - mock_home.__truediv__ = MagicMock(return_value=mock_secrets) - mock_secrets.__truediv__ = MagicMock(return_value=mock_aipass_dir) - mock_aipass_dir.__truediv__ = MagicMock(return_value=mock_env_path) - mock_path_cls.home.return_value = mock_home - - api_key.init_env() - - mock_env.create_env_template.assert_called_once() - mock_error.assert_called_once_with("Failed to create environment template") - mock_success.assert_not_called() - - -# ============================================= -# list_providers tests -# ============================================= - - -@patch(PATCH_CONSOLE) -@patch(PATCH_HEADER) -def test_list_providers_prints_header(mock_header, mock_console): - """list_providers should print 'Available Providers' header.""" - api_key.list_providers() - - mock_header.assert_called_once_with("Available Providers") - - -@patch(PATCH_CONSOLE) -@patch(PATCH_HEADER) -def test_list_providers_prints_openrouter(mock_header, mock_console): - """list_providers should print openrouter as an available provider.""" - api_key.list_providers() - - # Check that console.print was called with the openrouter provider line - print_calls = [str(c) for c in mock_console.print.call_args_list] - found = any("openrouter" in c for c in print_calls) - assert found, f"Expected 'openrouter' in console output, got: {print_calls}" - - -# ============================================= -# log_operation tests -# ============================================= - - -@patch(PATCH_CONSOLE) -@patch(PATCH_HEADER) -@patch(PATCH_JSON_HANDLER) -def test_handle_command_logs_operation(mock_jh, mock_header, mock_console): - """Valid command should call json_handler.log_operation with command context.""" - api_key.handle_command("list-providers", []) - - mock_jh.log_operation.assert_called_once_with( - "api_key_list-providers", - {"command": "list-providers"}, - ) - - -@patch(PATCH_CONSOLE) -@patch(PATCH_HEADER) -@patch(PATCH_SUCCESS) -@patch(PATCH_ERROR) -@patch(PATCH_JSON_HANDLER) -@patch(PATCH_KEYS) -def test_handle_command_logs_operation_for_get_key(mock_keys, mock_jh, mock_error, mock_success, mock_header, mock_console): - """get-key command should log api_key_get-key operation.""" - mock_keys.get_api_key.return_value = "sk-test1234567890abcdef" - - api_key.handle_command("get-key", ["openrouter"]) - - mock_jh.log_operation.assert_called_once_with( - "api_key_get-key", - {"command": "get-key"}, - ) - - -# ============================================= -# print_introspection tests -# ============================================= - - -@patch(PATCH_CONSOLE) -@patch(PATCH_HEADER) -def test_print_introspection_shows_header(mock_header, mock_console): - """print_introspection should display the module header.""" - api_key.print_introspection() - - mock_header.assert_called_once_with("API Key Module Introspection") - - -@patch(PATCH_CONSOLE) -@patch(PATCH_HEADER) -def test_print_introspection_shows_handlers(mock_header, mock_console): - """print_introspection should list connected handlers.""" - api_key.print_introspection() - - print_calls = [str(c) for c in mock_console.print.call_args_list] - found_keys = any("auth.keys" in c for c in print_calls) - found_env = any("auth.env" in c for c in print_calls) - found_provider = any("config.provider" in c for c in print_calls) - assert found_keys, "Expected auth.keys handler listed" - assert found_env, "Expected auth.env handler listed" - assert found_provider, "Expected config.provider handler listed" - - -# ============================================= -# handle_command — exception propagation -# ============================================= - - -@patch(PATCH_KEYS) -@patch(PATCH_JSON_HANDLER) -@patch(PATCH_CONSOLE) -@patch(PATCH_HEADER) -def test_handle_command_propagates_exception(mock_header, mock_console, mock_jh, mock_keys): - """handle_command re-raises exceptions from downstream handlers.""" - mock_keys.get_api_key.side_effect = RuntimeError("handler failed") - - with pytest.raises(RuntimeError, match="handler failed"): - api_key.handle_command("get-key", ["openrouter"]) - - -# ============================================= -# get_key_from_config tests (auth.keys handler) -# ============================================= - -from aipass.api.apps.handlers.auth import keys as auth_keys - - -class TestGetKeyFromConfig: - """Tests for auth.keys.get_key_from_config().""" - - def test_returns_key_from_valid_config(self, tmp_path, monkeypatch): - """Valid config JSON should return the API key string.""" - import json - - config_dir = tmp_path / "api_json" - config_dir.mkdir() - config_file = config_dir / "api_connect_config.json" - config_file.write_text(json.dumps({ - "config": { - "providers": { - "openrouter": {"api_key": "sk-or-test-key-abc123"} - } - } - }), encoding="utf-8") - - monkeypatch.setattr(auth_keys, "API_JSON_DIR", config_dir) - - result = auth_keys.get_key_from_config("openrouter") - assert result == "sk-or-test-key-abc123" - - def test_returns_none_when_config_file_missing(self, tmp_path, monkeypatch): - """Missing config file should return None.""" - config_dir = tmp_path / "api_json" - config_dir.mkdir() - monkeypatch.setattr(auth_keys, "API_JSON_DIR", config_dir) - - result = auth_keys.get_key_from_config("openrouter") - assert result is None - - def test_returns_none_when_provider_not_in_config(self, tmp_path, monkeypatch): - """Config exists but provider not listed should return None.""" - import json - - config_dir = tmp_path / "api_json" - config_dir.mkdir() - config_file = config_dir / "api_connect_config.json" - config_file.write_text(json.dumps({ - "config": { - "providers": { - "openai": {"api_key": "sk-openai-key-123"} - } - } - }), encoding="utf-8") - - monkeypatch.setattr(auth_keys, "API_JSON_DIR", config_dir) - - result = auth_keys.get_key_from_config("openrouter") - assert result is None - - def test_returns_none_when_api_key_empty(self, tmp_path, monkeypatch): - """Provider present but api_key is empty string should return None.""" - import json - - config_dir = tmp_path / "api_json" - config_dir.mkdir() - config_file = config_dir / "api_connect_config.json" - config_file.write_text(json.dumps({ - "config": { - "providers": { - "openrouter": {"api_key": ""} - } - } - }), encoding="utf-8") - - monkeypatch.setattr(auth_keys, "API_JSON_DIR", config_dir) - - result = auth_keys.get_key_from_config("openrouter") - assert result is None - - def test_returns_none_when_config_missing_config_key(self, tmp_path, monkeypatch): - """JSON file without 'config' top-level key should return None.""" - import json - - config_dir = tmp_path / "api_json" - config_dir.mkdir() - config_file = config_dir / "api_connect_config.json" - config_file.write_text(json.dumps({"other": "data"}), encoding="utf-8") - - monkeypatch.setattr(auth_keys, "API_JSON_DIR", config_dir) - - result = auth_keys.get_key_from_config("openrouter") - assert result is None - - @patch("aipass.api.apps.handlers.auth.keys.logger") - def test_returns_none_on_invalid_json(self, mock_logger, tmp_path, monkeypatch): - """Malformed JSON should return None and log error.""" - config_dir = tmp_path / "api_json" - config_dir.mkdir() - config_file = config_dir / "api_connect_config.json" - config_file.write_text("not valid json {{{", encoding="utf-8") - - monkeypatch.setattr(auth_keys, "API_JSON_DIR", config_dir) - - result = auth_keys.get_key_from_config("openrouter") - assert result is None - mock_logger.error.assert_called_once() - - def test_reads_different_providers(self, tmp_path, monkeypatch): - """Should retrieve keys for different provider names.""" - import json - - config_dir = tmp_path / "api_json" - config_dir.mkdir() - config_file = config_dir / "api_connect_config.json" - config_file.write_text(json.dumps({ - "config": { - "providers": { - "openrouter": {"api_key": "sk-or-key"}, - "openai": {"api_key": "sk-openai-key"}, - "anthropic": {"api_key": "sk-ant-key"}, - } - } - }), encoding="utf-8") - - monkeypatch.setattr(auth_keys, "API_JSON_DIR", config_dir) - - assert auth_keys.get_key_from_config("openrouter") == "sk-or-key" - assert auth_keys.get_key_from_config("openai") == "sk-openai-key" - assert auth_keys.get_key_from_config("anthropic") == "sk-ant-key" - - -# ============================================= -# get_validation_rules tests (auth.keys handler) -# ============================================= - - -class TestGetValidationRulesAuthKeys: - """Tests for auth.keys.get_validation_rules().""" - - def test_openrouter_rules(self): - """openrouter should have prefix 'sk-or-v1-' and min_length 40.""" - rules = auth_keys.get_validation_rules("openrouter") - assert rules["prefix"] == "sk-or-v1-" - assert rules["min_length"] == 40 - - def test_openai_rules(self): - """openai should have prefix 'sk-' and min_length 40.""" - rules = auth_keys.get_validation_rules("openai") - assert rules["prefix"] == "sk-" - assert rules["min_length"] == 40 - - def test_anthropic_rules(self): - """anthropic should have prefix 'sk-ant-' and min_length 40.""" - rules = auth_keys.get_validation_rules("anthropic") - assert rules["prefix"] == "sk-ant-" - assert rules["min_length"] == 40 - - def test_unknown_provider_falls_back_to_generic(self): - """Unknown provider should fall back to generic rules.""" - rules = auth_keys.get_validation_rules("unknown_provider") - assert rules["min_length"] == 10 - assert "prefix" not in rules - - def test_generic_rules_directly(self): - """Requesting 'generic' should return generic rules.""" - rules = auth_keys.get_validation_rules("generic") - assert rules["min_length"] == 10 - assert "prefix" not in rules - - def test_return_type_is_dict(self): - """All providers should return a dict.""" - for provider in ["openrouter", "openai", "anthropic", "generic", "nonexistent"]: - rules = auth_keys.get_validation_rules(provider) - assert isinstance(rules, dict) diff --git a/src/aipass/api/tests/test_caller.py b/src/aipass/api/tests/test_caller.py deleted file mode 100644 index 18aa577e..00000000 --- a/src/aipass/api/tests/test_caller.py +++ /dev/null @@ -1,91 +0,0 @@ -# =================== AIPass ==================== -# Name: test_caller.py -# Description: Tests for OpenRouter caller detection handler -# Version: 1.0.0 -# Created: 2026-04-03 -# Modified: 2026-04-03 -# ============================================= - -""" -Tests for openrouter.caller — caller detection handler. - -Tests: -- detect_caller_category for flow paths -- detect_caller_category for prax paths -- detect_caller_category for skills paths -- detect_caller_category for unknown paths -- detect_caller_category with 'skills' as substring in a part -""" - -from unittest.mock import patch, MagicMock -from pathlib import Path - -import pytest - -from aipass.api.apps.handlers.openrouter.caller import detect_caller_category - - -# ============================================= -# detect_caller_category tests -# ============================================= - - -class TestDetectCallerCategory: - """Tests for caller.detect_caller_category().""" - - def test_flow_path_returns_flow(self): - """Path containing 'flow' part should return 'flow'.""" - path = Path("/home/user/projects/aipass/src/aipass/flow/engine.py") - assert detect_caller_category(path) == "flow" - - def test_prax_path_returns_prax(self): - """Path containing 'prax' part should return 'prax'.""" - path = Path("/home/user/projects/aipass/src/aipass/prax/monitor.py") - assert detect_caller_category(path) == "prax" - - def test_skills_exact_part_returns_skills(self): - """Path with exact 'skills' directory should return 'skills'.""" - path = Path("/home/user/projects/aipass/src/aipass/skills/skills_api/tool.py") - assert detect_caller_category(path) == "skills" - - def test_skills_substring_returns_skills(self): - """Path with 'skills' as substring in a part (e.g., 'skills_api') should return 'skills'.""" - path = Path("/home/user/projects/aipass/src/aipass/modules/skills_custom/handler.py") - assert detect_caller_category(path) == "skills" - - def test_unknown_path_returns_unknown(self): - """Path without flow, prax, or skills should return 'unknown'.""" - path = Path("/home/user/projects/aipass/src/aipass/api/apps/handler.py") - assert detect_caller_category(path) == "unknown" - - def test_flow_takes_priority_over_later_prax(self): - """If 'flow' appears before 'prax' in path, should return 'flow'.""" - path = Path("/home/user/flow/prax/script.py") - assert detect_caller_category(path) == "flow" - - def test_prax_takes_priority_over_skills(self): - """If 'prax' appears before a skills part, should return 'prax'.""" - path = Path("/home/user/prax/skills_module/script.py") - assert detect_caller_category(path) == "prax" - - def test_root_path_returns_unknown(self): - """Root-level path should return 'unknown'.""" - path = Path("/somefile.py") - assert detect_caller_category(path) == "unknown" - - def test_deeply_nested_flow_path(self): - """Deeply nested path with 'flow' should still return 'flow'.""" - path = Path("/a/b/c/d/flow/e/f/g/handler.py") - assert detect_caller_category(path) == "flow" - - @patch("aipass.api.apps.handlers.openrouter.caller.logger") - def test_exception_returns_unknown(self, mock_logger): - """If an exception occurs, should return 'unknown' and log error.""" - bad_path = MagicMock(spec=Path) - bad_path.parts = property(lambda self: (_ for _ in ()).throw(RuntimeError("boom"))) - type(bad_path).parts = property(lambda self: (_ for _ in ()).throw(RuntimeError("boom"))) - - result = detect_caller_category(bad_path) - - assert result == "unknown" - mock_logger.error.assert_called_once() diff --git a/src/aipass/api/tests/test_cli_routing.py b/src/aipass/api/tests/test_cli_routing.py deleted file mode 100644 index df07f3ef..00000000 --- a/src/aipass/api/tests/test_cli_routing.py +++ /dev/null @@ -1,120 +0,0 @@ -# =================== AIPass ==================== -# Name: test_cli_routing.py -# Description: CLI Routing Tests (adapted for API module structure) -# Version: 1.0.0 -# Created: 2026-03-27 -# Modified: 2026-03-27 -# ============================================= - -""" -CLI Routing Tests for API branch. - -API has handle_command in module files (api_key.py, openrouter_client.py, etc.) -rather than a standalone cli_handler. Tests adapted accordingly. - -Covers 9 items: - - help_flag, short_help, help_word, no_args, unknown_command, - return_bool, print_help, print_introspection, output_capture -""" - -import importlib -import sys -import types -from pathlib import Path -from unittest.mock import patch, MagicMock - -import pytest - -from aipass.api.apps.modules import api_key - - -# --------------------------------------------------------------------------- -# handle_command routing tests -# --------------------------------------------------------------------------- - -@patch("aipass.api.apps.modules.api_key.console") -@patch("aipass.api.apps.modules.api_key.header") -@patch("aipass.api.apps.modules.api_key.json_handler") -def test_handle_command_help_flag(mock_jh, mock_header, mock_console): - """handle_command with --help flag returns True.""" - result = api_key.handle_command("get-key", ["--help"]) - assert result is True - - -@patch("aipass.api.apps.modules.api_key.console") -@patch("aipass.api.apps.modules.api_key.header") -@patch("aipass.api.apps.modules.api_key.json_handler") -def test_handle_command_short_help(mock_jh, mock_header, mock_console): - """handle_command with -h flag returns True.""" - result = api_key.handle_command("validate", ["-h"]) - assert result is True - - -@patch("aipass.api.apps.modules.api_key.console") -@patch("aipass.api.apps.modules.api_key.header") -@patch("aipass.api.apps.modules.api_key.json_handler") -def test_handle_command_help_word(mock_jh, mock_header, mock_console): - """handle_command with 'help' as arg returns True.""" - result = api_key.handle_command("get-key", ["help"]) - assert result is True - - -@patch("aipass.api.apps.modules.api_key.console") -@patch("aipass.api.apps.modules.api_key.header") -@patch("aipass.api.apps.modules.api_key.json_handler") -def test_handle_command_no_args(mock_jh, mock_header, mock_console): - """handle_command with no args triggers introspection, returns True.""" - result = api_key.handle_command("get-key", []) - assert result is True - - -@patch("aipass.api.apps.modules.api_key.console") -@patch("aipass.api.apps.modules.api_key.header") -@patch("aipass.api.apps.modules.api_key.json_handler") -def test_handle_command_unknown(mock_jh, mock_header, mock_console): - """handle_command with unknown command returns False.""" - result = api_key.handle_command("bogus_unknown", []) - assert result is False - - -@patch("aipass.api.apps.modules.api_key.console") -@patch("aipass.api.apps.modules.api_key.header") -@patch("aipass.api.apps.modules.api_key.json_handler") -def test_handle_command_return_bool(mock_jh, mock_header, mock_console): - """handle_command always returns a bool (True or False).""" - result_true = api_key.handle_command("get-key", ["--help"]) - result_false = api_key.handle_command("bogus_xyz", []) - assert isinstance(result_true, bool) - assert isinstance(result_false, bool) - assert result_true is True - assert result_false is False - - -# --------------------------------------------------------------------------- -# Output capture tests -# --------------------------------------------------------------------------- - -def test_output_capture_help(capsys): - """--help flag triggers console output (output capture with capsys).""" - # capsys captures stdout/stderr — satisfies output_capture pattern - api_key.print_help() - captured = capsys.readouterr() - assert len(captured.out) > 0 or len(captured.err) > 0 - - -@patch("aipass.api.apps.modules.api_key.console") -@patch("aipass.api.apps.modules.api_key.header") -@patch("aipass.api.apps.modules.api_key.json_handler") -def test_print_help_produces_output(mock_jh, mock_header, mock_console): - """print_help runs without error.""" - api_key.print_help() - assert mock_console.print.called or mock_header.called - - -@patch("aipass.api.apps.modules.api_key.console") -@patch("aipass.api.apps.modules.api_key.header") -@patch("aipass.api.apps.modules.api_key.json_handler") -def test_print_introspection_produces_output(mock_jh, mock_header, mock_console): - """print_introspection runs without error.""" - api_key.print_introspection() - assert mock_console.print.called or mock_header.called diff --git a/src/aipass/api/tests/test_config_provider.py b/src/aipass/api/tests/test_config_provider.py deleted file mode 100644 index 139d3b3b..00000000 --- a/src/aipass/api/tests/test_config_provider.py +++ /dev/null @@ -1,199 +0,0 @@ -# =================== AIPass ==================== -# Name: test_config_provider.py -# Description: Tests for provider configuration handler -# Version: 1.0.0 -# Created: 2026-04-03 -# Modified: 2026-04-03 -# ============================================= - -""" -Tests for config.provider — provider configuration handler. - -Tests: -- merge_configs deep merge behavior -- merge_configs in-place mutation and return value -- merge_configs nested dict recursion -- merge_configs non-dict overwrite -- get_validation_rules known providers -- get_validation_rules unknown provider returns None -""" - -from unittest.mock import patch, MagicMock - -import pytest - -from aipass.api.apps.handlers.config import provider as config_provider - - -# ============================================= -# merge_configs tests -# ============================================= - - -class TestMergeConfigs: - """Tests for config.provider.merge_configs().""" - - @patch("aipass.api.apps.handlers.config.provider.json_handler") - def test_simple_merge_adds_new_key(self, mock_jh): - """New key in updates should appear in base.""" - base = {"a": 1} - updates = {"b": 2} - - result = config_provider.merge_configs(base, updates) - - assert result["a"] == 1 - assert result["b"] == 2 - - @patch("aipass.api.apps.handlers.config.provider.json_handler") - def test_simple_merge_overwrites_existing_key(self, mock_jh): - """Existing key should be overwritten by updates.""" - base = {"a": 1} - updates = {"a": 99} - - result = config_provider.merge_configs(base, updates) - - assert result["a"] == 99 - - @patch("aipass.api.apps.handlers.config.provider.json_handler") - def test_nested_dict_merges_recursively(self, mock_jh): - """Nested dicts should merge recursively, preserving untouched keys.""" - base = {"a": 1, "b": {"c": 2, "d": 3}} - updates = {"b": {"c": 99}, "e": 4} - - result = config_provider.merge_configs(base, updates) - - assert result["a"] == 1 - assert result["b"]["c"] == 99 - assert result["b"]["d"] == 3 - assert result["e"] == 4 - - @patch("aipass.api.apps.handlers.config.provider.json_handler") - def test_modifies_base_in_place(self, mock_jh): - """merge_configs should modify base dict in-place.""" - base = {"a": 1} - updates = {"b": 2} - - result = config_provider.merge_configs(base, updates) - - assert result is base - assert base["b"] == 2 - - @patch("aipass.api.apps.handlers.config.provider.json_handler") - def test_returns_same_object_as_base(self, mock_jh): - """Return value should be the same object as the input base.""" - base = {"x": "original"} - updates = {"y": "added"} - - result = config_provider.merge_configs(base, updates) - - assert result is base - - @patch("aipass.api.apps.handlers.config.provider.json_handler") - def test_non_dict_value_overwrites_dict(self, mock_jh): - """Non-dict update value should overwrite existing dict value.""" - base = {"a": {"nested": True}} - updates = {"a": "flat_string"} - - result = config_provider.merge_configs(base, updates) - - assert result["a"] == "flat_string" - - @patch("aipass.api.apps.handlers.config.provider.json_handler") - def test_dict_value_overwrites_non_dict(self, mock_jh): - """Dict update value should overwrite existing non-dict value.""" - base = {"a": "flat_string"} - updates = {"a": {"nested": True}} - - result = config_provider.merge_configs(base, updates) - - assert result["a"] == {"nested": True} - - @patch("aipass.api.apps.handlers.config.provider.json_handler") - def test_empty_updates_leaves_base_unchanged(self, mock_jh): - """Empty updates dict should not change base.""" - base = {"a": 1, "b": 2} - original = base.copy() - updates = {} - - config_provider.merge_configs(base, updates) - - assert base == original - - @patch("aipass.api.apps.handlers.config.provider.json_handler") - def test_deeply_nested_merge(self, mock_jh): - """Three levels of nesting should all merge correctly.""" - base = {"level1": {"level2": {"level3": "old", "keep": "yes"}}} - updates = {"level1": {"level2": {"level3": "new"}}} - - result = config_provider.merge_configs(base, updates) - - assert result["level1"]["level2"]["level3"] == "new" - assert result["level1"]["level2"]["keep"] == "yes" - - @patch("aipass.api.apps.handlers.config.provider.json_handler") - def test_logs_operation_on_merge(self, mock_jh): - """merge_configs should call json_handler.log_operation.""" - base = {"a": 1} - updates = {"b": 2, "c": 3} - - config_provider.merge_configs(base, updates) - - mock_jh.log_operation.assert_called_once_with( - "config_merged", {"keys_updated": 2} - ) - - -# ============================================= -# get_validation_rules tests (config.provider) -# ============================================= - - -class TestGetValidationRulesConfigProvider: - """Tests for config.provider.get_validation_rules().""" - - @patch("aipass.api.apps.handlers.config.provider.logger") - def test_openrouter_rules(self, mock_logger): - """openrouter should have prefix 'sk-or-v1-' and min_length 40.""" - rules = config_provider.get_validation_rules("openrouter") - - assert rules is not None - assert rules["prefix"] == "sk-or-v1-" - assert rules["min_length"] == 40 - - @patch("aipass.api.apps.handlers.config.provider.logger") - def test_openai_rules(self, mock_logger): - """openai should have prefix 'sk-' and min_length 40.""" - rules = config_provider.get_validation_rules("openai") - - assert rules is not None - assert rules["prefix"] == "sk-" - assert rules["min_length"] == 40 - - @patch("aipass.api.apps.handlers.config.provider.logger") - def test_unknown_provider_returns_none(self, mock_logger): - """Unknown provider should return None (no generic fallback).""" - rules = config_provider.get_validation_rules("unknown_provider") - - assert rules is None - - @patch("aipass.api.apps.handlers.config.provider.logger") - def test_unknown_provider_logs_info(self, mock_logger): - """Unknown provider should log an info message.""" - config_provider.get_validation_rules("nonexistent") - - mock_logger.info.assert_called_once() - assert "nonexistent" in mock_logger.info.call_args[0][0] - - @patch("aipass.api.apps.handlers.config.provider.logger") - def test_known_provider_does_not_log(self, mock_logger): - """Known provider should not trigger the info log.""" - config_provider.get_validation_rules("openrouter") - - mock_logger.info.assert_not_called() - - @patch("aipass.api.apps.handlers.config.provider.logger") - def test_return_type_is_dict_for_known(self, mock_logger): - """Known providers should return a dict.""" - for name in ["openrouter", "openai"]: - rules = config_provider.get_validation_rules(name) - assert isinstance(rules, dict), f"Expected dict for {name}" diff --git a/src/aipass/api/tests/test_contracts.py b/src/aipass/api/tests/test_contracts.py deleted file mode 100644 index 01f6f4e6..00000000 --- a/src/aipass/api/tests/test_contracts.py +++ /dev/null @@ -1,200 +0,0 @@ -# =================== AIPass ==================== -# Name: test_contracts.py -# Description: Contract Tests (return types, exceptions, data structures) -# Version: 1.0.0 -# Created: 2026-03-27 -# Modified: 2026-03-27 -# ============================================= - -""" -Contract Tests for API branch. - -Covers 3 groups: - - Return type contracts (4): command_returns_bool, paths_return_path, - ensure_returns_bool, load_correct_type - - Exception contracts (3): create_default_raises, save_invalid_raises, - invalid_mode_raises - - Data structure contracts (3): config_keys, data_keys, log_entry_field -""" - -import importlib -import json -import sys -import types -from pathlib import Path -from typing import Any - -import pytest - - -BRANCH_MODULE = "api" - -_handler_pkg = f"aipass.{BRANCH_MODULE}.apps.handlers" -_json_mod_path = f"aipass.{BRANCH_MODULE}.apps.handlers.json.json_handler" - -if _handler_pkg not in sys.modules: - _stub = types.ModuleType(_handler_pkg) - _handlers_dir = ( - Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers" - ) - _stub.__path__ = [str(_handlers_dir)] - sys.modules[_handler_pkg] = _stub - -_mod = importlib.import_module(_json_mod_path) -json_handler = _mod - - -_JSON_DIR_ATTR: str | None = None -_JSON_DIR_CANDIDATES = [ - f"{BRANCH_MODULE.upper()}_JSON_DIR", - "JSON_DIR", - "BRANCH_JSON_DIR", - "_JSON_DIR", -] - -for _candidate in _JSON_DIR_CANDIDATES: - if hasattr(_mod, _candidate): - _JSON_DIR_ATTR = _candidate - break - -if _JSON_DIR_ATTR is None: - pytest.skip( - f"Cannot find JSON_DIR attribute on {BRANCH_MODULE}.json_handler", - allow_module_level=True, - ) - - -@pytest.fixture(autouse=True) -def isolate_json_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - """Redirect JSON operations to tmp_path for test isolation.""" - assert _JSON_DIR_ATTR is not None - monkeypatch.setattr(_mod, _JSON_DIR_ATTR, tmp_path) - return tmp_path - - -# --------------------------------------------------------------------------- -# Default factory helpers -# --------------------------------------------------------------------------- - -def _get_default_for_type(json_type: str, module_name: str = "test_mod") -> Any: - for fn_name in ("_create_default", "_get_default_template", "_get_default"): - fn = getattr(_mod, fn_name, None) - if fn is not None: - return fn(json_type, module_name) - return None - - -def _default_factory_raises_on_unknown() -> bool: - for fn_name in ("_create_default", "_get_default_template", "_get_default"): - fn = getattr(_mod, fn_name, None) - if fn is not None: - try: - fn("__nonexistent_type__", "test_mod") - except ValueError: - return True - except Exception: - return False - return False - return False - - -# ============================================================================ -# Group 1 — Return type contracts -# ============================================================================ - -def test_handle_command_returns_bool() -> None: - """handle_command must return a bool.""" - from aipass.api.apps.modules import api_key - result = api_key.handle_command("get-key", ["--help"]) - assert isinstance(result, bool) - - -def test_get_json_path_returns_path() -> None: - """get_json_path must return a Path (paths_return_path contract).""" - result = json_handler.get_json_path("contract_mod", "config") - assert isinstance(result, Path) - - -def test_ensure_json_exists_returns_bool(tmp_path: Path) -> None: - """ensure_json_exists must return a bool.""" - result = json_handler.ensure_json_exists("contract_mod", "data") - assert isinstance(result, bool) - assert result is True - - -def test_load_json_returns_dict_for_config(tmp_path: Path) -> None: - """load_json for config type must return a dict.""" - result = json_handler.load_json("contract_mod", "config") - assert isinstance(result, dict) - - -# ============================================================================ -# Group 2 — Exception contracts -# ============================================================================ - -def test_create_default_unknown_raises_value_error() -> None: - """_create_default must raise ValueError for unknown type.""" - if not _default_factory_raises_on_unknown(): - pytest.skip("Branch default factory does not raise ValueError") - with pytest.raises(ValueError, match="[Uu]nknown"): - _get_default_for_type("__nonexistent__", "test_mod") - - -def test_save_json_invalid_structure_rejects(tmp_path: Path) -> None: - """save_json must reject invalid structure (returns False).""" - result = json_handler.save_json("bad", "config", {"missing": "keys"}) - assert result is False - - -def test_validate_rejects_invalid_mode() -> None: - """validate_json_structure must return False for unknown json_type.""" - try: - result = json_handler.validate_json_structure({}, "invalid_mode_xyz") - except ValueError: - return - assert result is False - - -# ============================================================================ -# Group 3 — Data structure contracts -# ============================================================================ - -def test_config_has_required_keys(tmp_path: Path) -> None: - """Config must contain module_name and version.""" - json_handler.ensure_json_exists("struct_mod", "config") - result = json_handler.load_json("struct_mod", "config") - assert isinstance(result, dict) - assert "module_name" in result - assert "version" in result - - -def test_data_has_date_keys(tmp_path: Path) -> None: - """Data structure must contain created and last_updated.""" - json_handler.ensure_json_exists("struct_mod", "data") - result = json_handler.load_json("struct_mod", "data") - assert isinstance(result, dict) - assert "created" in result - assert "last_updated" in result - - -def test_reimport_after_mock(tmp_path: Path) -> None: - """Module can be reloaded after mocking (reimport_after_mock contract).""" - import importlib - # Reload the json_handler module to verify it survives reimport - reloaded = importlib.reload(_mod) - assert hasattr(reloaded, "load_json") - assert hasattr(reloaded, "save_json") - - -def test_log_entry_has_operation(tmp_path: Path) -> None: - """Log entries must contain an 'operation' field.""" - json_handler.log_operation("contract_test", module_name="struct_mod") - - assert _JSON_DIR_ATTR is not None - val = getattr(_mod, _JSON_DIR_ATTR) - json_dir = Path(val) if isinstance(val, str) else val - - log = json.loads((json_dir / "struct_mod_log.json").read_text(encoding="utf-8")) - assert len(log) >= 1 - assert "operation" in log[-1] - assert log[-1]["operation"] == "contract_test" diff --git a/src/aipass/api/tests/test_critical_paths.py b/src/aipass/api/tests/test_critical_paths.py deleted file mode 100644 index 23f54fa9..00000000 --- a/src/aipass/api/tests/test_critical_paths.py +++ /dev/null @@ -1,343 +0,0 @@ -# =================== AIPass ==================== -# Name: test_critical_paths.py -# Description: Critical path tests for API branch core functions -# Version: 1.0.0 -# Created: 2026-03-31 -# Modified: 2026-03-31 -# ============================================= - -""" -Critical path tests for the API branch. - -Covers the 4 core functions that form the API request pipeline: - -1. get_api_key() - Key retrieval from config JSON and secrets file -2. validate_key() - Key format validation per provider rules -3. get_response() - Main API call orchestrator -4. extract_response() - Response content extraction - -All external dependencies are mocked. File-based tests use tmp_path. -""" - -import json -from pathlib import Path -from unittest.mock import patch, MagicMock - -import pytest - - -# ============================================= -# 1. get_api_key() tests -# ============================================= - - -class TestGetApiKey: - """Tests for get_api_key() — key retrieval from config and secrets sources.""" - - @patch("aipass.api.apps.handlers.auth.keys.json_handler") - @patch("aipass.api.apps.handlers.auth.keys.API_JSON_DIR") - def test_key_from_config_json(self, mock_api_json_dir, mock_jh, tmp_path): - """Key found in api_connect_config.json is returned after validation.""" - from aipass.api.apps.handlers.auth.keys import get_api_key - - config_path = tmp_path / "api_connect_config.json" - config_data = { - "config": { - "providers": { - "openrouter": { - "api_key": "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" - } - } - } - } - config_path.write_text(json.dumps(config_data), encoding="utf-8") - - mock_api_json_dir.__truediv__ = lambda self, name: tmp_path / name - - result = get_api_key("openrouter") - - assert result == "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" - mock_jh.log_operation.assert_called_once() - - @patch("aipass.api.apps.handlers.auth.keys.json_handler") - @patch("aipass.api.apps.handlers.auth.keys.API_JSON_DIR") - def test_key_from_secrets_file(self, mock_api_json_dir, mock_jh, tmp_path): - """Key found in ~/.secrets/aipass/.env when config has no key.""" - from aipass.api.apps.handlers.auth.keys import get_api_key - - # Config file exists but has no key for openrouter - config_path = tmp_path / "api_connect_config.json" - config_data = {"config": {"providers": {}}} - config_path.write_text(json.dumps(config_data), encoding="utf-8") - mock_api_json_dir.__truediv__ = lambda self, name: tmp_path / name - - # Create secrets file - secrets_dir = tmp_path / ".secrets" / "aipass" - secrets_dir.mkdir(parents=True) - env_file = secrets_dir / ".env" - env_file.write_text( - "OPENROUTER_API_KEY=sk-or-v1-a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n", - encoding="utf-8", - ) - - with patch("aipass.api.apps.handlers.auth.keys.Path") as mock_path_cls: - # Path.home() should return tmp_path so secrets resolve there - mock_path_cls.home.return_value = tmp_path - result = get_api_key("openrouter") - - assert result == "sk-or-v1-a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" - - @patch("aipass.api.apps.handlers.auth.keys.json_handler") - @patch("aipass.api.apps.handlers.auth.keys.API_JSON_DIR") - def test_no_key_found_returns_none(self, mock_api_json_dir, mock_jh, tmp_path): - """Returns None when no key exists in any source.""" - from aipass.api.apps.handlers.auth.keys import get_api_key - - # Config file with no providers - config_path = tmp_path / "api_connect_config.json" - config_data = {"config": {"providers": {}}} - config_path.write_text(json.dumps(config_data), encoding="utf-8") - mock_api_json_dir.__truediv__ = lambda self, name: tmp_path / name - - # No secrets file exists - with patch("aipass.api.apps.handlers.auth.keys.Path") as mock_path_cls: - mock_home = tmp_path / "nonexistent_home" - mock_path_cls.home.return_value = mock_home - result = get_api_key("openrouter") - - assert result is None - - @patch("aipass.api.apps.handlers.auth.keys.json_handler") - @patch("aipass.api.apps.handlers.auth.keys.API_JSON_DIR") - def test_invalid_key_format_returns_none(self, mock_api_json_dir, mock_jh, tmp_path): - """Key exists in config but fails validation (wrong prefix) returns None.""" - from aipass.api.apps.handlers.auth.keys import get_api_key - - config_path = tmp_path / "api_connect_config.json" - config_data = { - "config": { - "providers": { - "openrouter": { - "api_key": "INVALID-PREFIX-key-that-is-long-enough" - } - } - } - } - config_path.write_text(json.dumps(config_data), encoding="utf-8") - mock_api_json_dir.__truediv__ = lambda self, name: tmp_path / name - - # No secrets file fallback - with patch("aipass.api.apps.handlers.auth.keys.Path") as mock_path_cls: - mock_path_cls.home.return_value = tmp_path / "no_home" - result = get_api_key("openrouter") - - assert result is None - - -# ============================================= -# 2. validate_key() tests -# ============================================= - - -class TestValidateKey: - """Tests for validate_key() — key format validation per provider rules.""" - - def test_valid_openrouter_key(self): - """Valid openrouter key with correct prefix and length passes.""" - from aipass.api.apps.handlers.auth.keys import validate_key - - key = "sk-or-v1-abcdefghijklmnopqrstuvwxyz0123456" - assert validate_key(key, "openrouter") is True - - def test_key_too_short(self): - """Key shorter than min_length fails validation.""" - from aipass.api.apps.handlers.auth.keys import validate_key - - key = "sk-or-v1-short" - assert len(key) < 20 - assert validate_key(key, "openrouter") is False - - def test_wrong_prefix(self): - """Key with wrong prefix for provider fails validation.""" - from aipass.api.apps.handlers.auth.keys import validate_key - - key = "sk-wrong-prefix-but-long-enough-to-pass-length" - assert validate_key(key, "openrouter") is False - - def test_empty_key(self): - """Empty string key fails validation.""" - from aipass.api.apps.handlers.auth.keys import validate_key - - assert validate_key("", "openrouter") is False - - def test_none_key(self): - """None key fails validation.""" - from aipass.api.apps.handlers.auth.keys import validate_key - - assert validate_key(None, "openrouter") is False # type: ignore[arg-type] - - def test_generic_provider_no_prefix_required(self): - """Generic provider only checks min_length, no prefix required.""" - from aipass.api.apps.handlers.auth.keys import validate_key - - key = "any-key-that-is-long-enough" - assert validate_key(key, "unknown_provider") is True - - def test_generic_provider_too_short(self): - """Generic provider rejects keys shorter than 10 chars.""" - from aipass.api.apps.handlers.auth.keys import validate_key - - assert validate_key("short", "unknown_provider") is False - - -# ============================================= -# 3. get_response() tests -# ============================================= - - -MODULE = "aipass.api.apps.handlers.openrouter.client" - - -class TestGetResponse: - """Tests for get_response() — main API call orchestrator.""" - - @patch(f"{MODULE}.track_usage") - @patch(f"{MODULE}.extract_response") - @patch(f"{MODULE}.make_api_request") - @patch(f"{MODULE}.get_cached_client") - @patch(f"{MODULE}.get_api_key") - @patch(f"{MODULE}.ensure_caller_config") - @patch(f"{MODULE}.get_caller_info") - def test_successful_call( - self, - mock_caller_info, - mock_ensure, - mock_get_key, - mock_get_client, - mock_make_req, - mock_extract, - mock_track, - ): - """Full successful pipeline: detect caller, get key, make request, extract, track.""" - from aipass.api.apps.handlers.openrouter.client import get_response - - mock_caller_info.return_value = {"caller_name": "test-branch"} - mock_get_key.return_value = "sk-or-v1-test-key-long-enough" - mock_get_client.return_value = MagicMock() - mock_make_req.return_value = MagicMock() - mock_extract.return_value = { - "content": "Hello, world!", - "id": "gen-abc123", - "model": "anthropic/claude-3.5-sonnet", - } - - result = get_response( - "What is Python?", model="anthropic/claude-3.5-sonnet" - ) - - assert result is not None - assert result["content"] == "Hello, world!" - assert result["id"] == "gen-abc123" - mock_track.assert_called_once() - - @patch(f"{MODULE}.get_api_key") - @patch(f"{MODULE}.ensure_caller_config") - @patch(f"{MODULE}.get_caller_info") - def test_no_model_returns_none( - self, mock_caller_info, mock_ensure, mock_get_key - ): - """Missing model parameter returns None without making API call.""" - from aipass.api.apps.handlers.openrouter.client import get_response - - mock_caller_info.return_value = {"caller_name": "test"} - - result = get_response("What is Python?", model=None) - - assert result is None - mock_get_key.assert_not_called() - - @patch(f"{MODULE}.get_api_key") - @patch(f"{MODULE}.ensure_caller_config") - @patch(f"{MODULE}.get_caller_info") - def test_no_api_key_returns_none( - self, mock_caller_info, mock_ensure, mock_get_key - ): - """No API key available returns None.""" - from aipass.api.apps.handlers.openrouter.client import get_response - - mock_caller_info.return_value = {"caller_name": "test"} - mock_get_key.return_value = None - - result = get_response( - "What is Python?", model="anthropic/claude-3.5-sonnet" - ) - - assert result is None - - -# ============================================= -# 4. extract_response() tests -# ============================================= - - -class TestExtractResponse: - """Tests for extract_response() — response content extraction from OpenAI objects.""" - - def test_valid_response(self): - """Extracts content, id, and model from a well-formed response.""" - from aipass.api.apps.handlers.openrouter.client import extract_response - - choice = MagicMock() - choice.message.content = "The answer is 42." - choice.finish_reason = "stop" - - response = MagicMock() - response.choices = [choice] - response.id = "gen-xyz789" - response.model = "anthropic/claude-3.5-sonnet" - - result = extract_response(response) - - assert result is not None - assert result["content"] == "The answer is 42." - assert result["id"] == "gen-xyz789" - assert result["model"] == "anthropic/claude-3.5-sonnet" - - def test_none_response(self): - """None response returns None.""" - from aipass.api.apps.handlers.openrouter.client import extract_response - - assert extract_response(None) is None - - def test_response_no_choices(self): - """Response with empty choices list returns None.""" - from aipass.api.apps.handlers.openrouter.client import extract_response - - response = MagicMock() - response.choices = [] - - assert extract_response(response) is None - - def test_response_no_content(self): - """Response with choice but no content returns None.""" - from aipass.api.apps.handlers.openrouter.client import extract_response - - choice = MagicMock() - choice.message.content = None - - response = MagicMock() - response.choices = [choice] - response.id = "gen-empty" - response.model = "test/model" - - assert extract_response(response) is None - - def test_response_missing_message(self): - """Response choice without message attribute returns None.""" - from aipass.api.apps.handlers.openrouter.client import extract_response - - choice = MagicMock(spec=[]) # No attributes at all - response = MagicMock() - response.choices = [choice] - - assert extract_response(response) is None diff --git a/src/aipass/api/tests/test_error_resilience.py b/src/aipass/api/tests/test_error_resilience.py deleted file mode 100644 index 87356fed..00000000 --- a/src/aipass/api/tests/test_error_resilience.py +++ /dev/null @@ -1,140 +0,0 @@ -# =================== AIPass ==================== -# Name: test_error_resilience.py -# Description: Error Resilience Tests (from seedgo template) -# Version: 1.0.0 -# Created: 2026-03-27 -# Modified: 2026-03-27 -# ============================================= - -""" -Error Resilience Tests for API branch. - -Covers 4 tests: - - missing_file, corrupt_json, empty_file, nonexistent_dir -""" - -import importlib -import json -import sys -import types -from pathlib import Path - -import pytest - - -BRANCH_MODULE = "api" - -_handler_pkg = f"aipass.{BRANCH_MODULE}.apps.handlers" -_json_mod_path = f"aipass.{BRANCH_MODULE}.apps.handlers.json.json_handler" - -if _handler_pkg not in sys.modules: - _stub = types.ModuleType(_handler_pkg) - _handlers_dir = ( - Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers" - ) - _stub.__path__ = [str(_handlers_dir)] - sys.modules[_handler_pkg] = _stub - -_mod = importlib.import_module(_json_mod_path) -json_handler = _mod - - -_JSON_DIR_ATTR: str | None = None -_JSON_DIR_CANDIDATES = [ - f"{BRANCH_MODULE.upper()}_JSON_DIR", - "JSON_DIR", - "BRANCH_JSON_DIR", - "_JSON_DIR", -] - -for _candidate in _JSON_DIR_CANDIDATES: - if hasattr(_mod, _candidate): - _JSON_DIR_ATTR = _candidate - break - -if _JSON_DIR_ATTR is None: - pytest.skip( - f"Cannot find JSON_DIR attribute on {BRANCH_MODULE}.json_handler", - allow_module_level=True, - ) - - -@pytest.fixture(autouse=True) -def isolate_json_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - """Redirect JSON operations to tmp_path for test isolation.""" - assert _JSON_DIR_ATTR is not None - monkeypatch.setattr(_mod, _JSON_DIR_ATTR, tmp_path) - return tmp_path - - -def _json_dir_as_path(tmp_path: Path) -> Path: - assert _JSON_DIR_ATTR is not None - val = getattr(_mod, _JSON_DIR_ATTR) - return Path(val) if isinstance(val, str) else val - - -# ============================================================================ -# Error Resilience Tests -# ============================================================================ - -def test_missing_file(tmp_path: Path) -> None: - """Loading a non-existent file returns a graceful default, not a crash.""" - json_dir = _json_dir_as_path(tmp_path) - target = json_dir / "ghost_config.json" - assert not target.exists() - - try: - result = json_handler.load_json("ghost", "config") - except FileNotFoundError: - return - - assert result is not None - assert isinstance(result, dict) - - -def test_corrupt_json(tmp_path: Path) -> None: - """Corrupt JSON on disk is handled gracefully — file is regenerated.""" - json_dir = _json_dir_as_path(tmp_path) - json_dir.mkdir(parents=True, exist_ok=True) - target = json_dir / "corrupt_data.json" - target.write_bytes(b"\x00\x01NOT-JSON{{{broken") - - result = json_handler.ensure_json_exists("corrupt", "data") - assert result is True - - raw = target.read_text(encoding="utf-8") - data = json.loads(raw) - assert isinstance(data, dict) - assert "created" in data - assert "last_updated" in data - - -def test_empty_file(tmp_path: Path) -> None: - """An empty file (0 bytes) is handled gracefully.""" - json_dir = _json_dir_as_path(tmp_path) - json_dir.mkdir(parents=True, exist_ok=True) - target = json_dir / "empty_log.json" - target.write_text("", encoding="utf-8") - - result = json_handler.ensure_json_exists("empty", "log") - assert result is True - - raw = target.read_text(encoding="utf-8") - data = json.loads(raw) - assert isinstance(data, list) - - -def test_nonexistent_dir(tmp_path: Path) -> None: - """Missing parent directory is handled gracefully.""" - json_dir = tmp_path / "does_not_exist" / "nested" - assert not json_dir.exists() - - assert _JSON_DIR_ATTR is not None - setattr(_mod, _JSON_DIR_ATTR, json_dir) - - try: - result = json_handler.ensure_json_exists("nodir", "config") - assert json_dir.exists() - assert result is True - except (FileNotFoundError, OSError): - pass diff --git a/src/aipass/api/tests/test_google_client.py b/src/aipass/api/tests/test_google_client.py deleted file mode 100644 index d3fda8b8..00000000 --- a/src/aipass/api/tests/test_google_client.py +++ /dev/null @@ -1,1077 +0,0 @@ -# =================== AIPass ==================== -# Name: test_google_client.py -# Description: Tests for Google API client module -# Version: 1.0.0 -# Created: 2026-03-24 -# Modified: 2026-03-24 -# ============================================= - -""" -Tests for google_client.py — Google API Client Module. - -Tests: -- handle_command routing: help, introspection, validate, reauth, pass-through -- get_drive_service() delegates to get_google_service() -- get_google_service() standard and thread-safe paths -- get_google_service() error paths: libs missing, auth failure -- validate_google() success and failure -- authenticate_google() success and failure -- reauth_google() success and failure -- api_call_with_retry() delegation -- is_ssl_error() delegation -""" - -from unittest.mock import patch, MagicMock, call - -import pytest - -from aipass.api.apps.modules.google_client import handle_command as _hc # noqa: F401 — seedgo test_coverage detection - - -# Base patch path for all mocks in this module -_MOD = "aipass.api.apps.modules.google_client" - - -# ============================================= -# handle_command tests -# ============================================= - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_handle_command_returns_false_no_args( - _warn, _err, _succ, _hdr, _json, _retry, _factory, _auth, _console -): - """handle_command returns False when args=[] and command != 'google'.""" - from aipass.api.apps.modules import google_client - - result = google_client.handle_command("validate", []) - assert result is False - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_handle_command_returns_false_non_google_provider( - _warn, _err, _succ, _hdr, _json, _retry, _factory, _auth, _console -): - """handle_command returns False when provider is not 'google'.""" - from aipass.api.apps.modules import google_client - - result = google_client.handle_command("validate", ["openrouter"]) - assert result is False - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_handle_command_routes_validate_google( - _warn, _err, _succ, _hdr, mock_json, _retry, _factory, mock_auth, _console -): - """handle_command routes 'validate' with ['google'] to _cmd_validate.""" - from aipass.api.apps.modules import google_client - - mock_auth.is_available.return_value = True - mock_auth.CLIENT_SECRET_PATH.exists.return_value = True - mock_auth.validate_credentials.return_value = True - - result = google_client.handle_command("validate", ["google"]) - - assert result is True - mock_auth.validate_credentials.assert_called_once() - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_handle_command_routes_reauth_google( - mock_warn, _err, _succ, _hdr, mock_json, _retry, _factory, mock_auth, _console -): - """handle_command routes 'reauth' with ['google'] to _cmd_reauth.""" - from aipass.api.apps.modules import google_client - - mock_auth.is_available.return_value = True - mock_auth.CLIENT_SECRET_PATH.exists.return_value = True - mock_auth.reauth.return_value = MagicMock() - - result = google_client.handle_command("reauth", ["google"]) - - assert result is True - mock_auth.reauth.assert_called_once() - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_handle_command_help_gate( - _warn, _err, _succ, _hdr, _json, _retry, _factory, _auth, mock_console -): - """handle_command prints help when args=['google', '--help'] and returns True.""" - from aipass.api.apps.modules import google_client - - result = google_client.handle_command("validate", ["google", "--help"]) - - assert result is True - # print_help calls console.print with the argparse output - mock_console.print.assert_called() - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_handle_command_google_introspection( - _warn, _err, _succ, mock_hdr, _json, _retry, _factory, mock_auth, mock_console -): - """handle_command prints introspection when command='google' and args=[].""" - from aipass.api.apps.modules import google_client - - mock_auth.is_available.return_value = True - mock_auth.CREDS_PATH.exists.return_value = True - mock_auth.CLIENT_SECRET_PATH.exists.return_value = True - - result = google_client.handle_command("google", []) - - assert result is True - mock_hdr.assert_called_once_with("Google Client Module Introspection") - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_handle_command_unknown_command_returns_false( - _warn, _err, _succ, _hdr, _json, _retry, _factory, _auth, _console -): - """handle_command returns False for unknown commands with 'google' arg.""" - from aipass.api.apps.modules import google_client - - result = google_client.handle_command("deploy", ["google"]) - assert result is False - - -# ============================================= -# get_drive_service tests -# ============================================= - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_get_drive_service_delegates( - _warn, _err, _succ, _hdr, _json, _retry, mock_factory, mock_auth, _console -): - """get_drive_service() delegates to get_google_service('drive', 'v3').""" - from aipass.api.apps.modules import google_client - - mock_auth.is_available.return_value = True - mock_service = MagicMock() - mock_factory.build_service.return_value = mock_service - - result = google_client.get_drive_service() - - mock_factory.build_service.assert_called_once_with("drive", "v3", None) - assert result is mock_service - - -# ============================================= -# get_google_service tests -# ============================================= - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_get_google_service_standard( - _warn, _err, _succ, _hdr, _json, _retry, mock_factory, mock_auth, _console -): - """get_google_service() returns service via build_service when is_available=True.""" - from aipass.api.apps.modules import google_client - - mock_auth.is_available.return_value = True - mock_service = MagicMock() - mock_factory.build_service.return_value = mock_service - - result = google_client.get_google_service("calendar", "v3") - - mock_factory.build_service.assert_called_once_with("calendar", "v3", None) - assert result is mock_service - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_get_google_service_thread_safe( - _warn, _err, _succ, _hdr, _json, _retry, mock_factory, mock_auth, _console -): - """get_google_service(thread_safe=True) calls build_thread_safe_service.""" - from aipass.api.apps.modules import google_client - - mock_auth.is_available.return_value = True - mock_service = MagicMock() - mock_factory.build_thread_safe_service.return_value = mock_service - - result = google_client.get_google_service("drive", "v3", thread_safe=True) - - mock_factory.build_thread_safe_service.assert_called_once_with("drive", "v3", None) - assert result is mock_service - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_get_google_service_libs_not_available( - _warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console -): - """get_google_service() raises RuntimeError when libraries are not installed.""" - from aipass.api.apps.modules import google_client - - mock_auth.is_available.return_value = False - - with pytest.raises(RuntimeError, match="not installed"): - google_client.get_google_service() - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_get_google_service_auth_failure( - _warn, _err, _succ, _hdr, _json, _retry, mock_factory, mock_auth, _console -): - """get_google_service() raises RuntimeError when build_service returns None.""" - from aipass.api.apps.modules import google_client - - mock_auth.is_available.return_value = True - mock_factory.build_service.return_value = None - - with pytest.raises(RuntimeError, match="Failed to authenticate"): - google_client.get_google_service("drive", "v3") - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_get_google_service_with_custom_scopes( - _warn, _err, _succ, _hdr, _json, _retry, mock_factory, mock_auth, _console -): - """get_google_service() passes custom scopes through to build_service.""" - from aipass.api.apps.modules import google_client - - mock_auth.is_available.return_value = True - mock_service = MagicMock() - mock_factory.build_service.return_value = mock_service - scopes = ["https://www.googleapis.com/auth/drive.readonly"] - - result = google_client.get_google_service("drive", "v3", scopes=scopes) - - mock_factory.build_service.assert_called_once_with("drive", "v3", scopes) - assert result is mock_service - - -# ============================================= -# validate_google tests -# ============================================= - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_validate_google_true( - _warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console -): - """validate_google() returns True when credentials are valid.""" - from aipass.api.apps.modules import google_client - - mock_auth.validate_credentials.return_value = True - - result = google_client.validate_google() - - assert result is True - mock_auth.validate_credentials.assert_called_once_with(scopes=None) - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_validate_google_false( - _warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console -): - """validate_google() returns False when no valid credentials exist.""" - from aipass.api.apps.modules import google_client - - mock_auth.validate_credentials.return_value = False - - result = google_client.validate_google() - - assert result is False - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_validate_google_with_scopes( - _warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console -): - """validate_google() passes scopes to validate_credentials.""" - from aipass.api.apps.modules import google_client - - mock_auth.validate_credentials.return_value = True - scopes = ["https://www.googleapis.com/auth/calendar"] - - result = google_client.validate_google(scopes=scopes) - - assert result is True - mock_auth.validate_credentials.assert_called_once_with(scopes=scopes) - - -# ============================================= -# authenticate_google tests -# ============================================= - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_authenticate_google_success( - _warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console -): - """authenticate_google() returns True when authenticate returns credentials.""" - from aipass.api.apps.modules import google_client - - mock_auth.authenticate.return_value = MagicMock() - - result = google_client.authenticate_google() - - assert result is True - mock_auth.authenticate.assert_called_once_with(scopes=None) - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_authenticate_google_failure( - _warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console -): - """authenticate_google() returns False when authenticate returns None.""" - from aipass.api.apps.modules import google_client - - mock_auth.authenticate.return_value = None - - result = google_client.authenticate_google() - - assert result is False - - -# ============================================= -# reauth_google tests -# ============================================= - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_reauth_google_success( - _warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console -): - """reauth_google() returns True when reauth returns credentials.""" - from aipass.api.apps.modules import google_client - - mock_auth.reauth.return_value = MagicMock() - - result = google_client.reauth_google() - - assert result is True - mock_auth.reauth.assert_called_once_with(scopes=None) - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_reauth_google_failure( - _warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console -): - """reauth_google() returns False when reauth returns None.""" - from aipass.api.apps.modules import google_client - - mock_auth.reauth.return_value = None - - result = google_client.reauth_google() - - assert result is False - - -# ============================================= -# api_call_with_retry tests -# ============================================= - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_api_call_with_retry_delegates( - _warn, _err, _succ, _hdr, _json, mock_retry, _factory, _auth, _console -): - """api_call_with_retry() delegates to google_retry.api_call_with_retry.""" - from aipass.api.apps.modules import google_client - - mock_callable = MagicMock() - mock_retry.api_call_with_retry.return_value = "result" - - result = google_client.api_call_with_retry(mock_callable, retries=3) - - mock_retry.api_call_with_retry.assert_called_once_with(mock_callable, retries=3) - assert result == "result" - - -# ============================================= -# is_ssl_error tests -# ============================================= - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_is_ssl_error_delegates( - _warn, _err, _succ, _hdr, _json, mock_retry, _factory, _auth, _console -): - """is_ssl_error() delegates to google_retry.is_ssl_error.""" - from aipass.api.apps.modules import google_client - - test_error = Exception("SSL handshake failed") - mock_retry.is_ssl_error.return_value = True - - result = google_client.is_ssl_error(test_error) - - mock_retry.is_ssl_error.assert_called_once_with(test_error) - assert result is True - - -# ============================================= -# _cmd_validate CLI integration tests -# ============================================= - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_cmd_validate_libs_not_available( - _warn, mock_err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console -): - """_cmd_validate shows error when Google libs are not installed.""" - from aipass.api.apps.modules import google_client - - mock_auth.is_available.return_value = False - - google_client._cmd_validate() - - mock_err.assert_called_once() - assert "not installed" in mock_err.call_args[0][0] - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_cmd_validate_no_client_secret( - _warn, mock_err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console -): - """_cmd_validate shows error when client secret file is missing.""" - from aipass.api.apps.modules import google_client - - mock_auth.is_available.return_value = True - mock_auth.CLIENT_SECRET_PATH.exists.return_value = False - - google_client._cmd_validate() - - mock_err.assert_called_once() - assert "Client secret not found" in mock_err.call_args[0][0] - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_cmd_validate_valid_creds( - _warn, _err, mock_succ, _hdr, mock_json, _retry, _factory, mock_auth, _console -): - """_cmd_validate shows success when credentials are valid.""" - from aipass.api.apps.modules import google_client - - mock_auth.is_available.return_value = True - mock_auth.CLIENT_SECRET_PATH.exists.return_value = True - mock_auth.validate_credentials.return_value = True - - google_client._cmd_validate() - - mock_succ.assert_called_once_with("Google credentials are valid") - mock_json.log_operation.assert_called_once_with( - "google_validate", {"status": "valid"} - ) - _err.assert_not_called() - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_cmd_validate_invalid_creds( - mock_warn, _err, _succ, _hdr, mock_json, _retry, _factory, mock_auth, mock_console -): - """_cmd_validate shows warning when credentials are invalid.""" - from aipass.api.apps.modules import google_client - - mock_auth.is_available.return_value = True - mock_auth.CLIENT_SECRET_PATH.exists.return_value = True - mock_auth.validate_credentials.return_value = False - - google_client._cmd_validate() - - mock_warn.assert_called_once_with("No valid Google credentials found") - mock_json.log_operation.assert_called_once_with( - "google_validate", {"status": "invalid"} - ) - _succ.assert_not_called() - - -# ============================================= -# _cmd_reauth CLI integration tests -# ============================================= - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_cmd_reauth_libs_not_available( - _warn, mock_err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console -): - """_cmd_reauth shows error when Google libs are not installed.""" - from aipass.api.apps.modules import google_client - - mock_auth.is_available.return_value = False - - google_client._cmd_reauth() - - mock_err.assert_called_once() - assert "not installed" in mock_err.call_args[0][0] - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_cmd_reauth_success( - _warn, _err, mock_succ, _hdr, mock_json, _retry, _factory, mock_auth, _console -): - """_cmd_reauth shows success when reauth returns credentials.""" - from aipass.api.apps.modules import google_client - - mock_auth.is_available.return_value = True - mock_auth.CLIENT_SECRET_PATH.exists.return_value = True - mock_auth.reauth.return_value = MagicMock() - - google_client._cmd_reauth() - - mock_succ.assert_called_once_with("Google re-authentication successful") - mock_json.log_operation.assert_called_once_with( - "google_reauth", {"status": "success"} - ) - _err.assert_not_called() - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_cmd_reauth_failure( - _warn, mock_err, _succ, _hdr, mock_json, _retry, _factory, mock_auth, _console -): - """_cmd_reauth shows error when reauth returns None.""" - from aipass.api.apps.modules import google_client - - mock_auth.is_available.return_value = True - mock_auth.CLIENT_SECRET_PATH.exists.return_value = True - mock_auth.reauth.return_value = None - - google_client._cmd_reauth() - - mock_err.assert_called_once_with("Google re-authentication failed") - mock_json.log_operation.assert_called_once_with( - "google_reauth", {"status": "failed"} - ) - _succ.assert_not_called() - - -# ============================================= -# handle_command — exception propagation -# ============================================= - - -@patch(f"{_MOD}.console") -@patch(f"{_MOD}.google_auth") -@patch(f"{_MOD}.google_factory") -@patch(f"{_MOD}.google_retry") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.warning") -def test_handle_command_propagates_exception( - _warn, _err, _succ, _hdr, _json, _retry, _factory, mock_auth, _console -): - """handle_command re-raises exceptions from downstream handlers.""" - from aipass.api.apps.modules import google_client - - mock_auth.is_available.side_effect = RuntimeError("handler failed") - - with pytest.raises(RuntimeError, match="handler failed"): - google_client.handle_command("validate", ["google"]) - - -# ============================================= -# auth.py — load_credentials tests -# ============================================= - -# Base patch path for auth module -_AUTH = "aipass.api.apps.handlers.google.auth" - - -@patch(f"{_AUTH}.json_handler") -@patch(f"{_AUTH}.Credentials") -@patch(f"{_AUTH}.CREDS_PATH") -@patch(f"{_AUTH}.GOOGLE_AUTH_AVAILABLE", True) -def test_load_credentials_success(mock_creds_path, mock_creds_cls, mock_json): - """load_credentials() returns Credentials when file exists and loads ok.""" - from aipass.api.apps.handlers.google.auth import load_credentials - - mock_creds_path.exists.return_value = True - mock_creds_obj = MagicMock() - mock_creds_cls.from_authorized_user_file.return_value = mock_creds_obj - - result = load_credentials() - - assert result is mock_creds_obj - mock_creds_cls.from_authorized_user_file.assert_called_once_with( - str(mock_creds_path), - ["https://www.googleapis.com/auth/drive.file"], - ) - mock_json.log_operation.assert_called_once_with( - "credentials_loaded", {"source": str(mock_creds_path)} - ) - - -@patch(f"{_AUTH}.json_handler") -@patch(f"{_AUTH}.Credentials") -@patch(f"{_AUTH}.CREDS_PATH") -@patch(f"{_AUTH}.GOOGLE_AUTH_AVAILABLE", True) -def test_load_credentials_custom_scopes(mock_creds_path, mock_creds_cls, mock_json): - """load_credentials() uses custom scopes when provided.""" - from aipass.api.apps.handlers.google.auth import load_credentials - - mock_creds_path.exists.return_value = True - custom_scopes = ["https://www.googleapis.com/auth/calendar.readonly"] - mock_creds_cls.from_authorized_user_file.return_value = MagicMock() - - load_credentials(scopes=custom_scopes) - - mock_creds_cls.from_authorized_user_file.assert_called_once_with( - str(mock_creds_path), - custom_scopes, - ) - - -@patch(f"{_AUTH}.GOOGLE_AUTH_AVAILABLE", False) -def test_load_credentials_libs_unavailable(): - """load_credentials() returns None when Google libs are not installed.""" - from aipass.api.apps.handlers.google.auth import load_credentials - - result = load_credentials() - - assert result is None - - -@patch(f"{_AUTH}.CREDS_PATH") -@patch(f"{_AUTH}.GOOGLE_AUTH_AVAILABLE", True) -def test_load_credentials_no_file(mock_creds_path): - """load_credentials() returns None when creds file does not exist.""" - from aipass.api.apps.handlers.google.auth import load_credentials - - mock_creds_path.exists.return_value = False - - result = load_credentials() - - assert result is None - - -@patch(f"{_AUTH}.logger") -@patch(f"{_AUTH}.json_handler") -@patch(f"{_AUTH}.Credentials") -@patch(f"{_AUTH}.CREDS_PATH") -@patch(f"{_AUTH}.GOOGLE_AUTH_AVAILABLE", True) -def test_load_credentials_exception(mock_creds_path, mock_creds_cls, mock_json, mock_logger): - """load_credentials() returns None and logs error on exception.""" - from aipass.api.apps.handlers.google.auth import load_credentials - - mock_creds_path.exists.return_value = True - mock_creds_cls.from_authorized_user_file.side_effect = ValueError("corrupt file") - - result = load_credentials() - - assert result is None - mock_logger.error.assert_called_once() - mock_json.log_operation.assert_not_called() - - -# ============================================= -# auth.py — refresh_credentials tests -# ============================================= - - -@patch(f"{_AUTH}._save_credentials") -@patch(f"{_AUTH}.Request") -@patch(f"{_AUTH}.GOOGLE_AUTH_AVAILABLE", True) -def test_refresh_credentials_success(mock_request_cls, mock_save): - """refresh_credentials() returns True and saves when refresh succeeds.""" - from aipass.api.apps.handlers.google.auth import refresh_credentials - - mock_creds = MagicMock() - mock_creds.expired = True - mock_creds.refresh_token = "tok_refresh" - - result = refresh_credentials(mock_creds) - - assert result is True - mock_creds.refresh.assert_called_once_with(mock_request_cls()) - mock_save.assert_called_once_with(mock_creds) - - -@patch(f"{_AUTH}.GOOGLE_AUTH_AVAILABLE", False) -def test_refresh_credentials_libs_unavailable(): - """refresh_credentials() returns False when Google libs are not installed.""" - from aipass.api.apps.handlers.google.auth import refresh_credentials - - result = refresh_credentials(MagicMock()) - - assert result is False - - -@patch(f"{_AUTH}.GOOGLE_AUTH_AVAILABLE", True) -def test_refresh_credentials_none_creds(): - """refresh_credentials() returns False when creds is None.""" - from aipass.api.apps.handlers.google.auth import refresh_credentials - - result = refresh_credentials(None) - - assert result is False - - -@patch(f"{_AUTH}.GOOGLE_AUTH_AVAILABLE", True) -def test_refresh_credentials_not_expired(): - """refresh_credentials() returns False when creds are not expired.""" - from aipass.api.apps.handlers.google.auth import refresh_credentials - - mock_creds = MagicMock() - mock_creds.expired = False - mock_creds.refresh_token = "tok_refresh" - - result = refresh_credentials(mock_creds) - - assert result is False - - -@patch(f"{_AUTH}.GOOGLE_AUTH_AVAILABLE", True) -def test_refresh_credentials_no_refresh_token(): - """refresh_credentials() returns False when no refresh token exists.""" - from aipass.api.apps.handlers.google.auth import refresh_credentials - - mock_creds = MagicMock() - mock_creds.expired = True - mock_creds.refresh_token = None - - result = refresh_credentials(mock_creds) - - assert result is False - - -@patch(f"{_AUTH}.logger") -@patch(f"{_AUTH}._save_credentials") -@patch(f"{_AUTH}.Request") -@patch(f"{_AUTH}.GOOGLE_AUTH_AVAILABLE", True) -def test_refresh_credentials_exception(mock_request_cls, mock_save, mock_logger): - """refresh_credentials() returns False on refresh exception.""" - from aipass.api.apps.handlers.google.auth import refresh_credentials - - mock_creds = MagicMock() - mock_creds.expired = True - mock_creds.refresh_token = "tok_refresh" - mock_creds.refresh.side_effect = RuntimeError("network error") - - result = refresh_credentials(mock_creds) - - assert result is False - mock_save.assert_not_called() - mock_logger.error.assert_called_once() - - -# ============================================= -# auth.py — run_oauth_flow tests -# ============================================= - - -@patch(f"{_AUTH}._save_credentials") -@patch(f"{_AUTH}.InstalledAppFlow") -@patch(f"{_AUTH}.CLIENT_SECRET_PATH") -@patch(f"{_AUTH}.GOOGLE_AUTH_AVAILABLE", True) -def test_run_oauth_flow_success(mock_secret_path, mock_flow_cls, mock_save): - """run_oauth_flow() returns credentials after successful OAuth flow.""" - from aipass.api.apps.handlers.google.auth import run_oauth_flow, DEFAULT_SCOPES - - mock_secret_path.exists.return_value = True - mock_flow = MagicMock() - mock_creds = MagicMock() - mock_flow_cls.from_client_secrets_file.return_value = mock_flow - mock_flow.run_local_server.return_value = mock_creds - - result = run_oauth_flow() - - assert result is mock_creds - mock_flow_cls.from_client_secrets_file.assert_called_once_with( - str(mock_secret_path), - DEFAULT_SCOPES["drive"], - ) - mock_flow.run_local_server.assert_called_once_with(port=0, open_browser=True) - mock_save.assert_called_once_with(mock_creds) - - -@patch(f"{_AUTH}._save_credentials") -@patch(f"{_AUTH}.InstalledAppFlow") -@patch(f"{_AUTH}.CLIENT_SECRET_PATH") -@patch(f"{_AUTH}.GOOGLE_AUTH_AVAILABLE", True) -def test_run_oauth_flow_custom_params(mock_secret_path, mock_flow_cls, mock_save): - """run_oauth_flow() passes custom scopes, port, and open_browser.""" - from aipass.api.apps.handlers.google.auth import run_oauth_flow - - mock_secret_path.exists.return_value = True - mock_flow = MagicMock() - mock_creds = MagicMock() - mock_flow_cls.from_client_secrets_file.return_value = mock_flow - mock_flow.run_local_server.return_value = mock_creds - custom_scopes = ["https://www.googleapis.com/auth/calendar"] - - result = run_oauth_flow(scopes=custom_scopes, port=8085, open_browser=False) - - assert result is mock_creds - mock_flow_cls.from_client_secrets_file.assert_called_once_with( - str(mock_secret_path), - custom_scopes, - ) - mock_flow.run_local_server.assert_called_once_with(port=8085, open_browser=False) - - -@patch(f"{_AUTH}.GOOGLE_AUTH_AVAILABLE", False) -def test_run_oauth_flow_libs_unavailable(): - """run_oauth_flow() returns None when Google libs are not installed.""" - from aipass.api.apps.handlers.google.auth import run_oauth_flow - - result = run_oauth_flow() - - assert result is None - - -@patch(f"{_AUTH}.CLIENT_SECRET_PATH") -@patch(f"{_AUTH}.GOOGLE_AUTH_AVAILABLE", True) -def test_run_oauth_flow_no_client_secret(mock_secret_path): - """run_oauth_flow() returns None when client secret file is missing.""" - from aipass.api.apps.handlers.google.auth import run_oauth_flow - - mock_secret_path.exists.return_value = False - - result = run_oauth_flow() - - assert result is None - - -@patch(f"{_AUTH}.logger") -@patch(f"{_AUTH}._save_credentials") -@patch(f"{_AUTH}.InstalledAppFlow") -@patch(f"{_AUTH}.CLIENT_SECRET_PATH") -@patch(f"{_AUTH}.GOOGLE_AUTH_AVAILABLE", True) -def test_run_oauth_flow_exception(mock_secret_path, mock_flow_cls, mock_save, mock_logger): - """run_oauth_flow() returns None and logs error on exception.""" - from aipass.api.apps.handlers.google.auth import run_oauth_flow - - mock_secret_path.exists.return_value = True - mock_flow_cls.from_client_secrets_file.side_effect = OSError("bad secret file") - - result = run_oauth_flow() - - assert result is None - mock_save.assert_not_called() - mock_logger.error.assert_called_once() diff --git a/src/aipass/api/tests/test_init_provisioning.py b/src/aipass/api/tests/test_init_provisioning.py deleted file mode 100644 index 331a082d..00000000 --- a/src/aipass/api/tests/test_init_provisioning.py +++ /dev/null @@ -1,147 +0,0 @@ -# =================== AIPass ==================== -# Name: test_init_provisioning.py -# Description: Init/Provisioning Tests (from seedgo template) -# Version: 1.0.0 -# Created: 2026-03-27 -# Modified: 2026-03-27 -# ============================================= - -""" -Init/Provisioning Tests for API branch. - -Covers 4 tests: - - creates_files, auto_creates_dir, no_overwrite, returns_dict -""" - -import importlib -import json -import sys -import types -from pathlib import Path - -import pytest - - -BRANCH_MODULE = "api" - -_handler_pkg = f"aipass.{BRANCH_MODULE}.apps.handlers" -_json_mod_path = f"aipass.{BRANCH_MODULE}.apps.handlers.json.json_handler" - -if _handler_pkg not in sys.modules: - _stub = types.ModuleType(_handler_pkg) - _handlers_dir = ( - Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers" - ) - _stub.__path__ = [str(_handlers_dir)] - sys.modules[_handler_pkg] = _stub - -_mod = importlib.import_module(_json_mod_path) -json_handler = _mod - - -_JSON_DIR_ATTR: str | None = None -_JSON_DIR_CANDIDATES = [ - f"{BRANCH_MODULE.upper()}_JSON_DIR", - "JSON_DIR", - "BRANCH_JSON_DIR", - "_JSON_DIR", -] - -for _candidate in _JSON_DIR_CANDIDATES: - if hasattr(_mod, _candidate): - _JSON_DIR_ATTR = _candidate - break - -if _JSON_DIR_ATTR is None: - pytest.skip( - f"Cannot find JSON_DIR attribute on {BRANCH_MODULE}.json_handler", - allow_module_level=True, - ) - - -@pytest.fixture(autouse=True) -def isolate_json_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - """Redirect JSON operations to tmp_path for test isolation.""" - assert _JSON_DIR_ATTR is not None - monkeypatch.setattr(_mod, _JSON_DIR_ATTR, tmp_path) - return tmp_path - - -def _json_dir_as_path(tmp_path: Path) -> Path: - assert _JSON_DIR_ATTR is not None - val = getattr(_mod, _JSON_DIR_ATTR) - return Path(val) if isinstance(val, str) else val - - -# ============================================================================ -# Init/Provisioning Tests -# ============================================================================ - -def test_creates_expected_files(tmp_path: Path) -> None: - """ensure_json_exists creates expected files on disk.""" - json_dir = _json_dir_as_path(tmp_path) - - for json_type in ("config", "data", "log"): - result = json_handler.ensure_json_exists("prov_mod", json_type) - assert result is True - - expected = json_dir / f"prov_mod_{json_type}.json" - assert expected.exists() - - raw = expected.read_text(encoding="utf-8") - parsed = json.loads(raw) - assert parsed is not None - - -def test_auto_creates_directory(tmp_path: Path) -> None: - """ensure_json_exists auto-creates parent directory when missing.""" - nested_dir = tmp_path / "auto_created" / "subdir" - assert not nested_dir.exists() - - assert _JSON_DIR_ATTR is not None - setattr(_mod, _JSON_DIR_ATTR, nested_dir) - - try: - result = json_handler.ensure_json_exists("autodir", "config") - assert nested_dir.exists() - assert result is True - assert (nested_dir / "autodir_config.json").exists() - except (FileNotFoundError, OSError): - pytest.skip("Branch does not auto-create missing directories") - - -def test_no_overwrite_on_second_call(tmp_path: Path) -> None: - """Second call must not overwrite existing data (idempotency).""" - json_dir = _json_dir_as_path(tmp_path) - json_dir.mkdir(parents=True, exist_ok=True) - - json_handler.ensure_json_exists("idem_mod", "data") - - target = json_dir / "idem_mod_data.json" - original = json.loads(target.read_text(encoding="utf-8")) - original["custom_field"] = "do_not_overwrite" - target.write_text(json.dumps(original, indent=2), encoding="utf-8") - - json_handler.ensure_json_exists("idem_mod", "data") - - after = json.loads(target.read_text(encoding="utf-8")) - assert after.get("custom_field") == "do_not_overwrite" - - -def test_returns_dict_with_expected_keys(tmp_path: Path) -> None: - """Provisioned files contain the correct structure keys.""" - json_handler.ensure_json_exists("key_mod", "config") - config = json_handler.load_json("key_mod", "config") - assert isinstance(config, dict) - assert "module_name" in config - assert "version" in config - - json_handler.ensure_json_exists("key_mod", "data") - data = json_handler.load_json("key_mod", "data") - assert isinstance(data, dict) - assert "created" in data - assert "last_updated" in data - - json_handler.ensure_json_exists("key_mod", "log") - log = json_handler.load_json("key_mod", "log") - assert isinstance(log, list) diff --git a/src/aipass/api/tests/test_json_handler.py b/src/aipass/api/tests/test_json_handler.py deleted file mode 100644 index dec65971..00000000 --- a/src/aipass/api/tests/test_json_handler.py +++ /dev/null @@ -1,471 +0,0 @@ -# =================== AIPass ==================== -# Name: test_json_handler.py -# Description: JSON Handler Tests (from seedgo template) -# Version: 1.0.0 -# Created: 2026-03-27 -# Modified: 2026-03-27 -# ============================================= - -""" -JSON Handler Tests for API branch. - -Adapted from seedgo universal template (DPLAN-0059). -Covers 8 test quality categories for json_handler: - - default_factory, validate, get_path, ensure_exists, - load, save, log_operation, ensure_module -""" - -import importlib -import json -import sys -import types -from datetime import datetime -from pathlib import Path -from typing import Any - -import pytest - - -# ============ BRANCH CONFIG ============ -BRANCH_MODULE = "api" -# ======================================= - -# --------------------------------------------------------------------------- -# Dynamic import with cross-branch guard bypass -# --------------------------------------------------------------------------- - -_handler_pkg = f"aipass.{BRANCH_MODULE}.apps.handlers" -_json_pkg = f"aipass.{BRANCH_MODULE}.apps.handlers.json" -_json_mod_path = f"aipass.{BRANCH_MODULE}.apps.handlers.json.json_handler" - -if _handler_pkg not in sys.modules: - _stub = types.ModuleType(_handler_pkg) - _handlers_dir = ( - Path(__file__).resolve().parents[3] / "aipass" / BRANCH_MODULE / "apps" / "handlers" - ) - _stub.__path__ = [str(_handlers_dir)] - sys.modules[_handler_pkg] = _stub - -_mod = importlib.import_module(_json_mod_path) -json_handler = _mod - - -# --------------------------------------------------------------------------- -# JSON_DIR variable discovery -# --------------------------------------------------------------------------- - -_JSON_DIR_ATTR: str | None = None -_JSON_DIR_CANDIDATES = [ - f"{BRANCH_MODULE.upper()}_JSON_DIR", - "JSON_DIR", - "BRANCH_JSON_DIR", - "_JSON_DIR", -] - -for _candidate in _JSON_DIR_CANDIDATES: - if hasattr(_mod, _candidate): - _JSON_DIR_ATTR = _candidate - break - -if _JSON_DIR_ATTR is None: - pytest.skip( - f"Cannot find JSON_DIR attribute on {BRANCH_MODULE}.json_handler — " - f"tried: {_JSON_DIR_CANDIDATES}", - allow_module_level=True, - ) - - -# --------------------------------------------------------------------------- -# Default factory discovery -# --------------------------------------------------------------------------- - -def _get_default_for_type(json_type: str, module_name: str = "test_mod") -> Any: - """Call whichever default factory the branch exposes.""" - for fn_name in ("_create_default", "_get_default_template", "_get_default"): - fn = getattr(_mod, fn_name, None) - if fn is not None: - return fn(json_type, module_name) - return None - - -def _has_default_factory() -> bool: - for fn_name in ("_create_default", "_get_default_template", "_get_default"): - if hasattr(_mod, fn_name): - return True - return False - - -def _default_factory_raises_on_unknown() -> bool: - for fn_name in ("_create_default", "_get_default_template", "_get_default"): - fn = getattr(_mod, fn_name, None) - if fn is not None: - try: - fn("__nonexistent_type__", "test_mod") - except ValueError: - return True - except Exception: - return False - return False - return False - - -# --------------------------------------------------------------------------- -# Isolation fixture -# --------------------------------------------------------------------------- - -@pytest.fixture(autouse=True) -def isolate_json_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - """Redirect JSON operations to tmp_path for test isolation.""" - assert _JSON_DIR_ATTR is not None - monkeypatch.setattr(_mod, _JSON_DIR_ATTR, tmp_path) - return tmp_path - - -def _json_dir_as_path(tmp_path: Path) -> Path: - assert _JSON_DIR_ATTR is not None - val = getattr(_mod, _JSON_DIR_ATTR) - return Path(val) if isinstance(val, str) else val - - -# ============================================================================ -# Group 1 — _create_default / default templates -# ============================================================================ - -def test_default_config_returns_dict_with_required_keys() -> None: - if not _has_default_factory(): - pytest.skip("Branch has no default factory function") - result = _get_default_for_type("config", "test_mod") - assert isinstance(result, dict) - assert "module_name" in result - assert "version" in result - assert "config" in result - - -def test_default_data_returns_dict_with_date_keys() -> None: - if not _has_default_factory(): - pytest.skip("Branch has no default factory function") - result = _get_default_for_type("data", "test_mod") - assert isinstance(result, dict) - assert "created" in result - assert "last_updated" in result - - -def test_default_log_returns_empty_list() -> None: - if not _has_default_factory(): - pytest.skip("Branch has no default factory function") - result = _get_default_for_type("log", "test_mod") - assert isinstance(result, list) - assert len(result) == 0 - - -def test_default_unknown_type_raises_value_error() -> None: - if not _default_factory_raises_on_unknown(): - pytest.skip("Branch default factory does not raise ValueError") - with pytest.raises(ValueError, match="[Uu]nknown"): - _get_default_for_type("__nonexistent__", "test_mod") - - -# ============================================================================ -# Group 2 — validate_json_structure -# ============================================================================ - -def test_validate_valid_config() -> None: - data = {"module_name": "x", "version": "1.0.0", "config": {}} - assert json_handler.validate_json_structure(data, "config") is True - - -def test_validate_config_missing_key() -> None: - data = {"module_name": "x", "version": "1.0.0"} - assert json_handler.validate_json_structure(data, "config") is False - - -def test_validate_config_not_dict() -> None: - assert json_handler.validate_json_structure([1, 2, 3], "config") is False - - -def test_validate_valid_data() -> None: - data = {"created": "2026-01-01", "last_updated": "2026-01-01"} - assert json_handler.validate_json_structure(data, "data") is True - - -def test_validate_data_missing_key() -> None: - data = {"created": "2026-01-01"} - assert json_handler.validate_json_structure(data, "data") is False - - -def test_validate_data_not_dict() -> None: - assert json_handler.validate_json_structure("not a dict", "data") is False - - -def test_validate_valid_log() -> None: - assert json_handler.validate_json_structure([], "log") is True - assert json_handler.validate_json_structure([{"entry": 1}], "log") is True - - -def test_validate_log_not_list() -> None: - assert json_handler.validate_json_structure({"not": "a list"}, "log") is False - - -def test_validate_unknown_type_returns_false() -> None: - assert json_handler.validate_json_structure({}, "nonexistent_type") is False - - -def test_validate_none_input_returns_false() -> None: - assert json_handler.validate_json_structure(None, "config") is False - assert json_handler.validate_json_structure(None, "data") is False - assert json_handler.validate_json_structure(None, "log") is False - - -# ============================================================================ -# Group 3 — get_json_path -# ============================================================================ - -def test_get_json_path_returns_path_type(tmp_path: Path) -> None: - result = json_handler.get_json_path("mymod", "config") - assert isinstance(result, (Path, str)) - - -def test_get_json_path_filename_pattern(tmp_path: Path) -> None: - result = json_handler.get_json_path("mymod", "config") - name = Path(result).name if isinstance(result, str) else result.name - assert name == "mymod_config.json" - - -def test_get_json_path_different_combos_differ(tmp_path: Path) -> None: - path_a = str(json_handler.get_json_path("alpha", "log")) - path_b = str(json_handler.get_json_path("beta", "data")) - assert path_a != path_b - - -# ============================================================================ -# Group 4 — ensure_json_exists -# ============================================================================ - -def test_ensure_creates_file_when_missing(tmp_path: Path) -> None: - result = json_handler.ensure_json_exists("ens_mod", "config") - assert result is True - json_dir = _json_dir_as_path(tmp_path) - created = json_dir / "ens_mod_config.json" - assert created.exists() - - -def test_ensure_preserves_valid_existing_file(tmp_path: Path) -> None: - json_dir = _json_dir_as_path(tmp_path) - json_dir.mkdir(parents=True, exist_ok=True) - target = json_dir / "keep_data.json" - original = {"created": "2025-01-01", "last_updated": "2025-06-01", "custom_key": "preserve_me"} - target.write_text(json.dumps(original), encoding="utf-8") - - json_handler.ensure_json_exists("keep", "data") - - data = json.loads(target.read_text(encoding="utf-8")) - assert data["custom_key"] == "preserve_me" - - -def test_ensure_regenerates_corrupt_json(tmp_path: Path) -> None: - json_dir = _json_dir_as_path(tmp_path) - json_dir.mkdir(parents=True, exist_ok=True) - target = json_dir / "bad_log.json" - target.write_bytes(b"\x00\x01NOT VALID JSON{{{") - - json_handler.ensure_json_exists("bad", "log") - - data = json.loads(target.read_text(encoding="utf-8")) - assert isinstance(data, list) - - -def test_ensure_regenerates_invalid_structure(tmp_path: Path) -> None: - json_dir = _json_dir_as_path(tmp_path) - json_dir.mkdir(parents=True, exist_ok=True) - target = json_dir / "wrong_config.json" - target.write_text(json.dumps({"wrong": "structure"}), encoding="utf-8") - - json_handler.ensure_json_exists("wrong", "config") - - data = json.loads(target.read_text(encoding="utf-8")) - assert "module_name" in data - assert "version" in data - assert "config" in data - - -def test_ensure_returns_bool(tmp_path: Path) -> None: - result = json_handler.ensure_json_exists("bool_mod", "data") - assert isinstance(result, bool) - assert result is True - - -# ============================================================================ -# Group 5 — load_json -# ============================================================================ - -def test_load_creates_default_when_missing(tmp_path: Path) -> None: - result = json_handler.load_json("fresh_mod", "log") - assert result is not None - assert isinstance(result, list) - - -def test_load_returns_existing_content(tmp_path: Path) -> None: - json_dir = _json_dir_as_path(tmp_path) - json_dir.mkdir(parents=True, exist_ok=True) - payload = {"created": "2025-01-01", "last_updated": "2025-06-15", "x": 42} - target = json_dir / "exist_data.json" - target.write_text(json.dumps(payload), encoding="utf-8") - - result = json_handler.load_json("exist", "data") - assert isinstance(result, dict) - assert result["x"] == 42 - - -def test_load_returns_dict_for_config(tmp_path: Path) -> None: - result = json_handler.load_json("cfg_mod", "config") - assert isinstance(result, dict) - - -def test_load_returns_list_for_log(tmp_path: Path) -> None: - result = json_handler.load_json("log_mod", "log") - assert isinstance(result, list) - - -# ============================================================================ -# Group 6 — save_json -# ============================================================================ - -def test_save_roundtrip(tmp_path: Path) -> None: - json_dir = _json_dir_as_path(tmp_path) - json_dir.mkdir(parents=True, exist_ok=True) - data = {"module_name": "rt", "version": "1.0.0", "config": {"key": "val"}} - json_handler.save_json("rt", "config", data) - - loaded = json_handler.load_json("rt", "config") - assert loaded is not None - assert loaded["config"]["key"] == "val" - - -def test_save_returns_true(tmp_path: Path) -> None: - json_dir = _json_dir_as_path(tmp_path) - json_dir.mkdir(parents=True, exist_ok=True) - data = {"module_name": "sv", "version": "1.0.0", "config": {}} - result = json_handler.save_json("sv", "config", data) - assert result is True - - -def test_save_rejects_invalid_structure(tmp_path: Path) -> None: - """save_json returns False for invalid structure.""" - json_dir = _json_dir_as_path(tmp_path) - json_dir.mkdir(parents=True, exist_ok=True) - result = json_handler.save_json("bad", "config", {"missing": "keys"}) - assert result is False - - -def test_save_data_updates_last_updated(tmp_path: Path) -> None: - json_dir = _json_dir_as_path(tmp_path) - json_dir.mkdir(parents=True, exist_ok=True) - today = datetime.now().date().isoformat() - data = {"created": "2025-01-01", "last_updated": "2025-01-01"} - json_handler.save_json("ts", "data", data) - - on_disk = json.loads((json_dir / "ts_data.json").read_text(encoding="utf-8")) - assert on_disk["last_updated"] == today - - -def test_save_writes_valid_json_to_disk(tmp_path: Path) -> None: - json_dir = _json_dir_as_path(tmp_path) - json_dir.mkdir(parents=True, exist_ok=True) - entries = [{"timestamp": "t1", "operation": "test"}] - json_handler.save_json("disk", "log", entries) - - raw = (json_dir / "disk_log.json").read_text(encoding="utf-8") - parsed = json.loads(raw) - assert isinstance(parsed, list) - assert len(parsed) == 1 - - -# ============================================================================ -# Group 7 — log_operation -# ============================================================================ - -def test_log_operation_appends_entry(tmp_path: Path) -> None: - json_handler.log_operation("deploy", module_name="logmod") - json_dir = _json_dir_as_path(tmp_path) - log = json.loads((json_dir / "logmod_log.json").read_text(encoding="utf-8")) - assert len(log) >= 1 - assert log[-1]["operation"] == "deploy" - - -def test_log_operation_returns_bool(tmp_path: Path) -> None: - result = json_handler.log_operation("test_op", module_name="boolmod") - assert isinstance(result, bool) - assert result is True - - -def test_log_operation_entry_has_timestamp(tmp_path: Path) -> None: - json_handler.log_operation("check_ts", module_name="tsmod") - json_dir = _json_dir_as_path(tmp_path) - log = json.loads((json_dir / "tsmod_log.json").read_text(encoding="utf-8")) - assert "timestamp" in log[-1] - - -def test_log_operation_includes_data_when_provided(tmp_path: Path) -> None: - json_handler.log_operation("with_data", data={"count": 5}, module_name="datamod") - json_dir = _json_dir_as_path(tmp_path) - log = json.loads((json_dir / "datamod_log.json").read_text(encoding="utf-8")) - assert "data" in log[-1] - assert log[-1]["data"]["count"] == 5 - - -def test_log_operation_multiple_calls_accumulate(tmp_path: Path) -> None: - json_handler.log_operation("first", module_name="accmod") - json_handler.log_operation("second", module_name="accmod") - json_handler.log_operation("third", module_name="accmod") - json_dir = _json_dir_as_path(tmp_path) - log = json.loads((json_dir / "accmod_log.json").read_text(encoding="utf-8")) - assert len(log) >= 3 - ops = [e["operation"] for e in log[-3:]] - assert ops == ["first", "second", "third"] - - -# ============================================================================ -# Group 8 — ensure_module_jsons -# ============================================================================ - -def test_ensure_module_jsons_creates_all_three(tmp_path: Path) -> None: - json_handler.ensure_module_jsons("triple") - json_dir = _json_dir_as_path(tmp_path) - assert (json_dir / "triple_config.json").exists() - assert (json_dir / "triple_data.json").exists() - assert (json_dir / "triple_log.json").exists() - - -def test_ensure_module_jsons_returns_true(tmp_path: Path) -> None: - result = json_handler.ensure_module_jsons("retmod") - assert result is True - - -def test_ensure_module_jsons_files_pass_validation(tmp_path: Path) -> None: - json_handler.ensure_module_jsons("valid_mod") - json_dir = _json_dir_as_path(tmp_path) - - config = json.loads((json_dir / "valid_mod_config.json").read_text(encoding="utf-8")) - assert json_handler.validate_json_structure(config, "config") is True - - data = json.loads((json_dir / "valid_mod_data.json").read_text(encoding="utf-8")) - assert json_handler.validate_json_structure(data, "data") is True - - log = json.loads((json_dir / "valid_mod_log.json").read_text(encoding="utf-8")) - assert json_handler.validate_json_structure(log, "log") is True - - -def test_ensure_module_jsons_data_has_correct_keys(tmp_path: Path) -> None: - json_handler.ensure_module_jsons("keymod") - json_dir = _json_dir_as_path(tmp_path) - data = json.loads((json_dir / "keymod_data.json").read_text(encoding="utf-8")) - assert "created" in data - assert "last_updated" in data - - -def test_ensure_module_jsons_log_is_empty_list(tmp_path: Path) -> None: - json_handler.ensure_module_jsons("listmod") - json_dir = _json_dir_as_path(tmp_path) - log = json.loads((json_dir / "listmod_log.json").read_text(encoding="utf-8")) - assert isinstance(log, list) - assert len(log) == 0 diff --git a/src/aipass/api/tests/test_openrouter_client.py b/src/aipass/api/tests/test_openrouter_client.py deleted file mode 100644 index c5d9b887..00000000 --- a/src/aipass/api/tests/test_openrouter_client.py +++ /dev/null @@ -1,748 +0,0 @@ -# =================== AIPass ==================== -# Name: test_openrouter_client.py -# Description: Tests for OpenRouter client module -# Version: 1.0.0 -# Created: 2026-03-24 -# Modified: 2026-03-24 -# ============================================= - -""" -Tests for openrouter_client.py — OpenRouter client module orchestration. - -Tests: -- handle_command routing for test, call, models, status, unknown -- Help gate (--help) and introspection gate (no-args on "call") -- log_operation called on every valid command -- test_connection success / no-key / API-failure paths -- list_models success / no-key / --all limiter -- check_status with key / without key -- get_response delegation to client handler -""" - -from unittest.mock import patch, MagicMock, call - -import pytest - -from aipass.api.apps.modules.openrouter_client import handle_command as _hc # noqa: F401 — seedgo test_coverage detection - - -# Base set of patches applied to every test via the module-level prefix -_MOD = "aipass.api.apps.modules.openrouter_client" - - -# ============================================= -# handle_command — routing -# ============================================= - - -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_handle_command_returns_false_for_unknown(mock_console, mock_header, mock_jh): - """handle_command returns False when the command is not recognised.""" - from aipass.api.apps.modules import openrouter_client - - result = openrouter_client.handle_command("unknown", []) - - assert result is False - mock_jh.log_operation.assert_not_called() - - -@patch(f"{_MOD}.test_connection") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_handle_command_routes_test(mock_console, mock_header, mock_jh, mock_test): - """handle_command('test', []) delegates to test_connection().""" - from aipass.api.apps.modules import openrouter_client - - result = openrouter_client.handle_command("test", []) - - assert result is True - mock_test.assert_called_once() - - -@patch(f"{_MOD}.list_models") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_handle_command_routes_models(mock_console, mock_header, mock_jh, mock_list): - """handle_command('models', []) delegates to list_models().""" - from aipass.api.apps.modules import openrouter_client - - result = openrouter_client.handle_command("models", []) - - assert result is True - mock_list.assert_called_once_with([]) - - -@patch(f"{_MOD}.check_status") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_handle_command_routes_status(mock_console, mock_header, mock_jh, mock_status): - """handle_command('status', []) delegates to check_status().""" - from aipass.api.apps.modules import openrouter_client - - result = openrouter_client.handle_command("status", []) - - assert result is True - mock_status.assert_called_once() - - -@patch(f"{_MOD}.make_call") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_handle_command_routes_call(mock_console, mock_header, mock_jh, mock_call): - """handle_command('call', ['hello']) delegates to make_call with args.""" - from aipass.api.apps.modules import openrouter_client - - result = openrouter_client.handle_command("call", ["hello"]) - - assert result is True - mock_call.assert_called_once_with(["hello"]) - - -# ============================================= -# handle_command — gates -# ============================================= - - -@patch(f"{_MOD}.print_help") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_handle_command_help_gate(mock_console, mock_header, mock_jh, mock_help): - """--help flag triggers print_help and returns True without logging.""" - from aipass.api.apps.modules import openrouter_client - - result = openrouter_client.handle_command("test", ["--help"]) - - assert result is True - mock_help.assert_called_once() - mock_jh.log_operation.assert_not_called() - - -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_handle_command_call_no_args_executes(mock_console, mock_header, mock_jh, mock_error): - """'call' with no args should execute (show error), not show introspection.""" - from aipass.api.apps.modules import openrouter_client - - result = openrouter_client.handle_command("call", []) - - assert result is True - mock_error.assert_called() - assert "Prompt required" in mock_error.call_args[0][0] - - -# ============================================= -# handle_command — logging -# ============================================= - - -@patch(f"{_MOD}.test_connection") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_handle_command_logs_operation(mock_console, mock_header, mock_jh, mock_test): - """Valid commands log their operation via json_handler.log_operation.""" - from aipass.api.apps.modules import openrouter_client - - openrouter_client.handle_command("test", []) - - mock_jh.log_operation.assert_called_once_with( - "openrouter_test", {"command": "test"} - ) - - -# ============================================= -# test_connection -# ============================================= - - -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.models") -@patch(f"{_MOD}.keys") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_test_connection_success(mock_console, mock_header, mock_keys, mock_models, mock_success): - """Successful connection prints success with model count.""" - from aipass.api.apps.modules import openrouter_client - - mock_keys.get_api_key.return_value = "sk-or-test-key" - mock_models.fetch_models_from_api.return_value = [{"id": "m1"}, {"id": "m2"}, {"id": "m3"}] - - openrouter_client.test_connection() - - mock_models.fetch_models_from_api.assert_called_once_with("sk-or-test-key") - mock_success.assert_called_once() - assert "3 models" in mock_success.call_args[0][0] - - -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.models") -@patch(f"{_MOD}.keys") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_test_connection_success_no_error(mock_console, mock_header, mock_keys, mock_models, mock_success, mock_error): - """Successful connection must not call error().""" - from aipass.api.apps.modules import openrouter_client - - mock_keys.get_api_key.return_value = "sk-or-test-key" - mock_models.fetch_models_from_api.return_value = [{"id": "m1"}] - - openrouter_client.test_connection() - - mock_error.assert_not_called() - - -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.keys") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_test_connection_no_key(mock_console, mock_header, mock_keys, mock_error): - """Missing API key triggers error with diagnosis.""" - from aipass.api.apps.modules import openrouter_client - - mock_keys.get_api_key.return_value = None - mock_keys.diagnose_key.return_value = "No key found in env" - - openrouter_client.test_connection() - - mock_keys.diagnose_key.assert_called_once_with("openrouter") - mock_error.assert_called_once_with("No key found in env") - - -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.keys") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_test_connection_no_key_no_success(mock_console, mock_header, mock_keys, mock_error, mock_success): - """Missing API key path must not call success().""" - from aipass.api.apps.modules import openrouter_client - - mock_keys.get_api_key.return_value = None - mock_keys.diagnose_key.return_value = "No key found in env" - - openrouter_client.test_connection() - - mock_success.assert_not_called() - - -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.models") -@patch(f"{_MOD}.keys") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_test_connection_api_failure_no_success(mock_console, mock_header, mock_keys, mock_models, mock_error, mock_success): - """API failure path must not call success().""" - from aipass.api.apps.modules import openrouter_client - - mock_keys.get_api_key.return_value = "sk-or-test-key" - mock_models.fetch_models_from_api.return_value = None - - openrouter_client.test_connection() - - mock_success.assert_not_called() - - -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.models") -@patch(f"{_MOD}.keys") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_test_connection_api_failure(mock_console, mock_header, mock_keys, mock_models, mock_error): - """API returning None triggers connection-failed error.""" - from aipass.api.apps.modules import openrouter_client - - mock_keys.get_api_key.return_value = "sk-or-test-key" - mock_models.fetch_models_from_api.return_value = None - - openrouter_client.test_connection() - - mock_error.assert_called_once() - assert "failed" in mock_error.call_args[0][0].lower() - - -# ============================================= -# list_models -# ============================================= - - -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.models") -@patch(f"{_MOD}.keys") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_list_models_success(mock_console, mock_header, mock_keys, mock_models, mock_success): - """Successful model listing prints success and table rows.""" - from aipass.api.apps.modules import openrouter_client - - mock_keys.get_api_key.return_value = "sk-or-test-key" - fake_models = [ - { - "id": f"provider/model-{i}", - "context_length": 128000, - "pricing": {"prompt": "0.001", "completion": "0.002"}, - } - for i in range(3) - ] - mock_models.fetch_models_from_api.return_value = fake_models - - openrouter_client.list_models([]) - - mock_success.assert_called_once() - assert "3 models" in mock_success.call_args[0][0] - # Header row + separator + 3 data rows = at least 5 console.print calls after header - assert mock_console.print.call_count >= 5 - - -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.models") -@patch(f"{_MOD}.keys") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_list_models_success_no_error(mock_console, mock_header, mock_keys, mock_models, mock_success, mock_error): - """Successful model listing must not call error().""" - from aipass.api.apps.modules import openrouter_client - - mock_keys.get_api_key.return_value = "sk-or-test-key" - mock_models.fetch_models_from_api.return_value = [ - {"id": "p/m", "context_length": 4096, "pricing": {"prompt": "0", "completion": "0"}} - ] - - openrouter_client.list_models([]) - - mock_error.assert_not_called() - - -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.keys") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_list_models_no_key_no_success(mock_console, mock_header, mock_keys, mock_error, mock_success): - """Missing API key on list_models must not call success().""" - from aipass.api.apps.modules import openrouter_client - - mock_keys.get_api_key.return_value = None - mock_keys.diagnose_key.return_value = "Key not set" - - openrouter_client.list_models([]) - - mock_success.assert_not_called() - - -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.keys") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_list_models_no_key(mock_console, mock_header, mock_keys, mock_error): - """Missing API key triggers error with diagnosis.""" - from aipass.api.apps.modules import openrouter_client - - mock_keys.get_api_key.return_value = None - mock_keys.diagnose_key.return_value = "Key not set" - - openrouter_client.list_models([]) - - mock_error.assert_called_once_with("Key not set") - - -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.models") -@patch(f"{_MOD}.keys") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_list_models_limits_to_10(mock_console, mock_header, mock_keys, mock_models, mock_success): - """Without --all flag, only 10 models are displayed from a larger list.""" - from aipass.api.apps.modules import openrouter_client - - mock_keys.get_api_key.return_value = "sk-or-test-key" - fake_models = [ - { - "id": f"provider/model-{i}", - "context_length": 4096, - "pricing": {"prompt": "0", "completion": "0"}, - } - for i in range(25) - ] - mock_models.fetch_models_from_api.return_value = fake_models - - openrouter_client.list_models([]) - - # Count data rows: calls that contain a model ID pattern - data_row_calls = [ - c for c in mock_console.print.call_args_list - if c.args and isinstance(c.args[0], str) and "provider/model-" in c.args[0] - ] - assert len(data_row_calls) == 10 - - # Should show "Showing 10 of 25" truncation notice - all_output = " ".join( - str(c) for c in mock_console.print.call_args_list - ) - assert "10 of 25" in all_output - - -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.models") -@patch(f"{_MOD}.keys") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_list_models_all_flag_shows_everything(mock_console, mock_header, mock_keys, mock_models, mock_success): - """With --all flag, all models are displayed.""" - from aipass.api.apps.modules import openrouter_client - - mock_keys.get_api_key.return_value = "sk-or-test-key" - fake_models = [ - { - "id": f"provider/model-{i}", - "context_length": 4096, - "pricing": {"prompt": "0", "completion": "0"}, - } - for i in range(25) - ] - mock_models.fetch_models_from_api.return_value = fake_models - - openrouter_client.list_models(["--all"]) - - data_row_calls = [ - c for c in mock_console.print.call_args_list - if c.args and isinstance(c.args[0], str) and "provider/model-" in c.args[0] - ] - assert len(data_row_calls) == 25 - - -# ============================================= -# check_status -# ============================================= - - -@patch(f"{_MOD}.client") -@patch(f"{_MOD}.keys") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_check_status_with_key(mock_console, mock_header, mock_keys, mock_client): - """When API key exists, status shows masked key and cache stats.""" - from aipass.api.apps.modules import openrouter_client - - mock_keys.get_api_key.return_value = "sk-or-v1-abcdefgh12345678" - mock_client.get_cache_stats.return_value = {"cached_clients": 2, "max_cache_size": 5} - - openrouter_client.check_status() - - all_output = " ".join(str(c) for c in mock_console.print.call_args_list) - # Key should be shown as masked - assert "sk-or-v1" in all_output - assert "5678" in all_output - # "yes" for key configured - assert "yes" in all_output - # Cache stats shown - assert "2/5" in all_output - - -@patch(f"{_MOD}.client") -@patch(f"{_MOD}.keys") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_check_status_no_key(mock_console, mock_header, mock_keys, mock_client): - """When API key is missing, status shows 'no' and diagnosis.""" - from aipass.api.apps.modules import openrouter_client - - mock_keys.get_api_key.return_value = None - mock_keys.diagnose_key.return_value = "OPENROUTER_API_KEY not set" - mock_client.get_cache_stats.return_value = {"cached_clients": 0, "max_cache_size": 5} - - openrouter_client.check_status() - - all_output = " ".join(str(c) for c in mock_console.print.call_args_list) - assert "no" in all_output - assert "OPENROUTER_API_KEY not set" in all_output - - -# ============================================= -# get_response — delegation -# ============================================= - - -@patch(f"{_MOD}.client") -def test_get_response_delegates_to_handler(mock_client): - """get_response passes through to client.get_response and returns its result.""" - from aipass.api.apps.modules import openrouter_client - - mock_client.get_response.return_value = { - "content": "Hello!", - "id": "gen-123", - "model": "anthropic/claude-3.5-sonnet", - } - - result = openrouter_client.get_response( - "Hi there", - caller="flow", - model="anthropic/claude-3.5-sonnet", - temperature=0.5, - ) - - mock_client.get_response.assert_called_once_with( - "Hi there", - "flow", - "anthropic/claude-3.5-sonnet", - temperature=0.5, - ) - assert result is not None - assert result["content"] == "Hello!" - assert result["model"] == "anthropic/claude-3.5-sonnet" - - -@patch(f"{_MOD}.client") -def test_get_response_returns_none_on_failure(mock_client): - """get_response returns None when client handler returns None.""" - from aipass.api.apps.modules import openrouter_client - - mock_client.get_response.return_value = None - - result = openrouter_client.get_response("fail prompt", caller="test") - - assert result is None - - -# ============================================= -# list_models — context formatting -# ============================================= - - -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.models") -@patch(f"{_MOD}.keys") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_list_models_formats_million_context(mock_console, mock_header, mock_keys, mock_models, mock_success): - """Context length >= 1M formatted as 'XM'.""" - from aipass.api.apps.modules import openrouter_client - - mock_keys.get_api_key.return_value = "sk-or-test" - mock_models.fetch_models_from_api.return_value = [ - {"id": "big/model", "context_length": 2_000_000, "pricing": {"prompt": "0", "completion": "0"}} - ] - - openrouter_client.list_models([]) - - data_rows = [ - c for c in mock_console.print.call_args_list - if c.args and isinstance(c.args[0], str) and "big/model" in c.args[0] - ] - assert len(data_rows) == 1 - assert "2M" in data_rows[0].args[0] - - -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.models") -@patch(f"{_MOD}.keys") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_list_models_formats_thousand_context(mock_console, mock_header, mock_keys, mock_models, mock_success): - """Context length >= 1k formatted as 'Xk'.""" - from aipass.api.apps.modules import openrouter_client - - mock_keys.get_api_key.return_value = "sk-or-test" - mock_models.fetch_models_from_api.return_value = [ - {"id": "med/model", "context_length": 128_000, "pricing": {"prompt": "0.01", "completion": "0.02"}} - ] - - openrouter_client.list_models([]) - - data_rows = [ - c for c in mock_console.print.call_args_list - if c.args and isinstance(c.args[0], str) and "med/model" in c.args[0] - ] - assert len(data_rows) == 1 - assert "128k" in data_rows[0].args[0] - - -@patch(f"{_MOD}.success") -@patch(f"{_MOD}.models") -@patch(f"{_MOD}.keys") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_list_models_formats_free_pricing(mock_console, mock_header, mock_keys, mock_models, mock_success): - """Models with zero pricing show 'free'.""" - from aipass.api.apps.modules import openrouter_client - - mock_keys.get_api_key.return_value = "sk-or-test" - mock_models.fetch_models_from_api.return_value = [ - {"id": "free/model", "context_length": 4096, "pricing": {"prompt": "0", "completion": "0"}} - ] - - openrouter_client.list_models([]) - - data_rows = [ - c for c in mock_console.print.call_args_list - if c.args and isinstance(c.args[0], str) and "free/model" in c.args[0] - ] - assert len(data_rows) == 1 - assert "free" in data_rows[0].args[0] - - -# ============================================= -# make_call — stub behaviour -# ============================================= - - -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_make_call_no_model_shows_error(mock_console, mock_header, mock_error): - """make_call without --model shows error.""" - from aipass.api.apps.modules import openrouter_client - - openrouter_client.make_call(["What is AI?"]) - - mock_error.assert_called_once() - assert "Model required" in mock_error.call_args[0][0] - - -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_handle_command_call_no_args_shows_error(mock_console, mock_header, mock_jh, mock_error): - """call with no args should show error, not introspection.""" - from aipass.api.apps.modules import openrouter_client - - result = openrouter_client.handle_command("call", []) - - assert result is True - mock_error.assert_called_once() - assert "Prompt required" in mock_error.call_args[0][0] - - -# ============================================= -# list_models — error on fetch failure -# ============================================= - - -@patch(f"{_MOD}.error") -@patch(f"{_MOD}.models") -@patch(f"{_MOD}.keys") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_list_models_fetch_failure(mock_console, mock_header, mock_keys, mock_models, mock_error): - """When fetch_models_from_api returns None, error is shown.""" - from aipass.api.apps.modules import openrouter_client - - mock_keys.get_api_key.return_value = "sk-or-test" - mock_models.fetch_models_from_api.return_value = None - - openrouter_client.list_models([]) - - mock_error.assert_called_once() - assert "fetch" in mock_error.call_args[0][0].lower() - - -# ============================================= -# handle_command — exception propagation -# ============================================= - - -@patch(f"{_MOD}.keys") -@patch(f"{_MOD}.json_handler") -@patch(f"{_MOD}.header") -@patch(f"{_MOD}.console") -def test_handle_command_propagates_exception(mock_console, mock_header, mock_jh, mock_keys): - """handle_command re-raises exceptions from downstream handlers.""" - from aipass.api.apps.modules import openrouter_client - - mock_keys.get_api_key.side_effect = RuntimeError("handler failed") - - with pytest.raises(RuntimeError, match="handler failed"): - openrouter_client.handle_command("test", []) - - -# ============================================= -# create_client() — handler-level tests -# ============================================= - -_CLIENT_MOD = "aipass.api.apps.handlers.openrouter.client" - - -@patch(f"{_CLIENT_MOD}.OPENAI_AVAILABLE", False) -def test_create_client_returns_none_when_sdk_unavailable(): - """create_client returns None when OpenAI SDK is not installed.""" - from aipass.api.apps.handlers.openrouter.client import create_client - - result = create_client("sk-or-test-key") - - assert result is None - - -@patch(f"{_CLIENT_MOD}.OPENAI_AVAILABLE", True) -def test_create_client_returns_none_for_empty_key(): - """create_client returns None when api_key is empty string.""" - from aipass.api.apps.handlers.openrouter.client import create_client - - assert create_client("") is None - - -@patch(f"{_CLIENT_MOD}.OPENAI_AVAILABLE", True) -def test_create_client_returns_none_for_none_key(): - """create_client returns None when api_key is None.""" - from aipass.api.apps.handlers.openrouter.client import create_client - - assert create_client(None) is None # type: ignore[arg-type] - - -@patch(f"{_CLIENT_MOD}.json_handler") -@patch(f"{_CLIENT_MOD}.OpenAI") -@patch(f"{_CLIENT_MOD}.OPENAI_AVAILABLE", True) -def test_create_client_success(mock_openai_cls, mock_jh): - """create_client returns an OpenAI client instance on success.""" - from aipass.api.apps.handlers.openrouter.client import create_client, OPENROUTER_HEADERS - - mock_client = MagicMock() - mock_openai_cls.return_value = mock_client - - result = create_client("sk-or-valid-key", base_url="https://openrouter.ai/api/v1", timeout=30) - - assert result is mock_client - mock_openai_cls.assert_called_once_with( - base_url="https://openrouter.ai/api/v1", - api_key="sk-or-valid-key", - timeout=30, - default_headers=OPENROUTER_HEADERS, - ) - - -@patch(f"{_CLIENT_MOD}.json_handler") -@patch(f"{_CLIENT_MOD}.OpenAI") -@patch(f"{_CLIENT_MOD}.OPENAI_AVAILABLE", True) -def test_create_client_custom_timeout(mock_openai_cls, mock_jh): - """create_client passes custom timeout to OpenAI constructor.""" - from aipass.api.apps.handlers.openrouter.client import create_client - - mock_openai_cls.return_value = MagicMock() - - create_client("sk-or-key", timeout=60) - - assert mock_openai_cls.call_args[1]["timeout"] == 60 - - -@patch(f"{_CLIENT_MOD}.OpenAI") -@patch(f"{_CLIENT_MOD}.OPENAI_AVAILABLE", True) -def test_create_client_returns_none_on_exception(mock_openai_cls): - """create_client returns None when OpenAI constructor raises.""" - from aipass.api.apps.handlers.openrouter.client import create_client - - mock_openai_cls.side_effect = RuntimeError("connection refused") - - result = create_client("sk-or-key") - - assert result is None diff --git a/src/aipass/api/tests/test_provision.py b/src/aipass/api/tests/test_provision.py deleted file mode 100644 index b4cb7874..00000000 --- a/src/aipass/api/tests/test_provision.py +++ /dev/null @@ -1,321 +0,0 @@ -# =================== AIPass ==================== -# Name: test_provision.py -# Description: Tests for caller auto-provisioning handler -# Version: 1.0.0 -# Created: 2026-03-20 -# Modified: 2026-03-20 -# ============================================= - -""" -Tests for provision.py — caller auto-provisioning. - -Tests: -- create_caller_config() creates 3 JSON files with correct defaults -- ensure_caller_config() provisions on first call, returns existing on second -- Idempotency: second call doesn't overwrite existing config -- Config defaults match expected values -""" - -import json -from pathlib import Path -from unittest.mock import patch - -import pytest - -from aipass.api.apps.handlers.openrouter.provision import ( - create_caller_config, - ensure_caller_config, - get_default_caller_config, - get_default_caller_data, - get_default_caller_log, - provision_json_folder, - read_json, - write_json, -) - - -# ============================================= -# create_caller_config tests -# ============================================= - - -def test_create_caller_config_creates_three_files(tmp_path: Path): - """create_caller_config() should create config, data, and log JSON files.""" - json_folder = tmp_path / "test_json" - - result = create_caller_config("test_caller", json_folder) - - assert result != {} - assert (json_folder / "openrouter_skill_config.json").exists() - assert (json_folder / "openrouter_skill_data.json").exists() - assert (json_folder / "openrouter_skill_log.json").exists() - - -def test_create_caller_config_defaults(tmp_path: Path): - """Config defaults should match: ai_temperature=0.7, ai_max_tokens=4000, enabled=True.""" - json_folder = tmp_path / "test_json" - - create_caller_config("test_caller", json_folder) - - config = read_json(json_folder / "openrouter_skill_config.json") - assert config is not None - assert config["config"]["ai_temperature"] == 0.7 - assert config["config"]["ai_max_tokens"] == 4000 - assert config["config"]["enabled"] is True - assert config["config"]["ai_model"] == "" - assert config["module_name"] == "openrouter" - - -def test_create_caller_config_data_defaults(tmp_path: Path): - """Data file should have zeroed counters.""" - json_folder = tmp_path / "test_json" - - create_caller_config("test_caller", json_folder) - - data = read_json(json_folder / "openrouter_skill_data.json") - assert data is not None - assert data["data"]["total_requests"] == 0 - assert data["data"]["successful_requests"] == 0 - assert data["data"]["failed_requests"] == 0 - assert data["data"]["models_used"] == {} - assert data["data"]["last_request"] is None - - -def test_create_caller_config_log_defaults(tmp_path: Path): - """Log file should have empty logs list.""" - json_folder = tmp_path / "test_json" - - create_caller_config("test_caller", json_folder) - - log = read_json(json_folder / "openrouter_skill_log.json") - assert log is not None - assert log["logs"] == [] - assert log["module_name"] == "openrouter" - - -def test_create_caller_config_creates_folder(tmp_path: Path): - """Should create the json_folder if it doesn't exist.""" - json_folder = tmp_path / "nested" / "deep" / "test_json" - assert not json_folder.exists() - - create_caller_config("test_caller", json_folder) - - assert json_folder.exists() - - -# ============================================= -# provision_json_folder tests -# ============================================= - - -def test_provision_json_folder_creates(tmp_path: Path): - """Should create folder when it doesn't exist.""" - folder = tmp_path / "new_folder" - assert not folder.exists() - - result = provision_json_folder(folder) - - assert result is True - assert folder.exists() - - -def test_provision_json_folder_existing(tmp_path: Path): - """Should return True for already-existing folder.""" - folder = tmp_path / "existing" - folder.mkdir() - - result = provision_json_folder(folder) - - assert result is True - - -# ============================================= -# ensure_caller_config tests (mocked stack detection) -# ============================================= - - -@patch("aipass.api.apps.handlers.openrouter.provision.detect_caller_from_stack") -def test_ensure_caller_config_provisions_new(mock_detect, tmp_path: Path): - """ensure_caller_config() should create config when none exists.""" - json_folder = tmp_path / "caller_json" - mock_detect.return_value = ("test_caller", json_folder) - - result = ensure_caller_config("test_caller") - - assert result != {} - assert (json_folder / "openrouter_skill_config.json").exists() - assert (json_folder / "openrouter_skill_data.json").exists() - assert (json_folder / "openrouter_skill_log.json").exists() - - -@patch("aipass.api.apps.handlers.openrouter.provision.detect_caller_from_stack") -def test_ensure_caller_config_returns_existing(mock_detect, tmp_path: Path): - """Second call should return existing config without overwriting.""" - json_folder = tmp_path / "caller_json" - mock_detect.return_value = ("test_caller", json_folder) - - # First call — creates config - first_result = ensure_caller_config("test_caller") - assert first_result != {} - - # Read the created config and modify it to detect overwrites - config_path = json_folder / "openrouter_skill_config.json" - config = read_json(config_path) - assert config is not None - config["config"]["ai_model"] = "test/modified-model" - with open(config_path, "w", encoding="utf-8") as f: - json.dump(config, f, indent=2) - - # Second call — should return existing (modified) config, not overwrite - second_result = ensure_caller_config("test_caller") - assert second_result["config"]["ai_model"] == "test/modified-model" - - -@patch("aipass.api.apps.handlers.openrouter.provision.detect_caller_from_stack") -def test_ensure_caller_config_no_folder_detected(mock_detect): - """Should return empty dict when stack detection fails to find json_folder.""" - mock_detect.return_value = (None, None) - - result = ensure_caller_config() - - assert result == {} - - -# ============================================= -# get_default_caller_config tests -# ============================================= - - -def test_get_default_caller_config_structure(): - """Default config should have expected structure.""" - config = get_default_caller_config() - - assert "module_name" in config - assert "timestamp" in config - assert "config" in config - assert isinstance(config["config"], dict) - assert set(config["config"].keys()) == {"ai_model", "ai_temperature", "ai_max_tokens", "enabled"} - - -# ============================================= -# write_json tests -# ============================================= - - -def test_write_json_creates_file(tmp_path: Path): - """write_json writes valid JSON and returns True.""" - target = tmp_path / "output.json" - data = {"key": "value", "number": 42} - - result = write_json(target, data) - - assert result is True - assert target.exists() - with open(target, "r", encoding="utf-8") as f: - loaded = json.load(f) - assert loaded == data - - -def test_write_json_creates_parent_dirs(tmp_path: Path): - """write_json creates parent directories when they don't exist.""" - target = tmp_path / "nested" / "deep" / "data.json" - data = {"created": True} - - result = write_json(target, data) - - assert result is True - assert target.exists() - with open(target, "r", encoding="utf-8") as f: - loaded = json.load(f) - assert loaded == data - - -def test_write_json_uses_indent_and_ensure_ascii(tmp_path: Path): - """write_json formats with indent=2 and preserves unicode.""" - target = tmp_path / "unicode.json" - data = {"name": "caf\u00e9"} - - write_json(target, data) - - raw = target.read_text(encoding="utf-8") - # indent=2 means keys are indented - assert ' "name"' in raw - # ensure_ascii=False means unicode is preserved literally - assert "caf\u00e9" in raw - - -def test_write_json_overwrites_existing(tmp_path: Path): - """write_json overwrites an existing file.""" - target = tmp_path / "overwrite.json" - write_json(target, {"version": 1}) - write_json(target, {"version": 2}) - - with open(target, "r", encoding="utf-8") as f: - loaded = json.load(f) - assert loaded["version"] == 2 - - -def test_write_json_returns_false_on_error(tmp_path: Path): - """write_json returns False when writing fails.""" - # Use a path where the parent is a file, not a directory - blocker = tmp_path / "blocker" - blocker.write_text("not a dir", encoding="utf-8") - target = blocker / "sub" / "data.json" - - result = write_json(target, {"key": "value"}) - - assert result is False - - -# ============================================= -# get_default_caller_data tests -# ============================================= - - -def test_get_default_caller_data_structure(): - """get_default_caller_data returns dict with expected keys and zeroed counters.""" - data = get_default_caller_data() - - assert data["module_name"] == "openrouter" - assert "timestamp" in data - assert isinstance(data["data"], dict) - - counters = data["data"] - assert counters["total_requests"] == 0 - assert counters["successful_requests"] == 0 - assert counters["failed_requests"] == 0 - assert counters["models_used"] == {} - assert counters["last_request"] is None - - -def test_get_default_caller_data_timestamp_is_iso(): - """Timestamp should be a valid ISO-format string.""" - from datetime import datetime - - data = get_default_caller_data() - # Should not raise - datetime.fromisoformat(data["timestamp"]) - - -# ============================================= -# get_default_caller_log tests -# ============================================= - - -def test_get_default_caller_log_structure(): - """get_default_caller_log returns dict with module_name, timestamp, and empty logs.""" - log = get_default_caller_log() - - assert log["module_name"] == "openrouter" - assert "timestamp" in log - assert log["logs"] == [] - assert isinstance(log["logs"], list) - - -def test_get_default_caller_log_timestamp_is_iso(): - """Timestamp should be a valid ISO-format string.""" - from datetime import datetime - - log = get_default_caller_log() - # Should not raise - datetime.fromisoformat(log["timestamp"]) diff --git a/src/aipass/api/tests/test_tracking.py b/src/aipass/api/tests/test_tracking.py deleted file mode 100644 index ed23745c..00000000 --- a/src/aipass/api/tests/test_tracking.py +++ /dev/null @@ -1,354 +0,0 @@ -# =================== AIPass ==================== -# Name: test_tracking.py -# Description: Tests for usage tracking handler -# Version: 1.0.0 -# Created: 2026-04-03 -# Modified: 2026-04-03 -# ============================================= - -""" -Tests for tracking.py -- usage tracking handler. - -Tests: -- get_generation_metrics() HTTP success, non-200, invalid structure, exception -- store_usage_data() new file creation, existing file update, per-caller stats, - daily totals, newest-first ordering, exception handling -""" - -import json -from pathlib import Path -from unittest.mock import patch, MagicMock - -import pytest - -from aipass.api.apps.handlers.usage.tracking import ( - get_generation_metrics, - store_usage_data, -) - -_TRACKING_MOD = "aipass.api.apps.handlers.usage.tracking" - - -# ============================================= -# get_generation_metrics tests -# ============================================= - - -@patch(f"{_TRACKING_MOD}.requests") -def test_get_generation_metrics_success(mock_requests: MagicMock): - """Returns metrics dict when API returns 200 with valid structure.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "data": { - "total_cost": 0.0042, - "tokens_prompt": 150, - "tokens_completion": 80, - "generation_time": 1200, - "latency": 1500, - "provider_name": "anthropic", - } - } - mock_requests.get.return_value = mock_response - - result = get_generation_metrics("gen-abc-123", "sk-or-test-key") - - assert result is not None - assert result["total_cost"] == 0.0042 - assert result["tokens_prompt"] == 150 - assert result["tokens_completion"] == 80 - assert result["generation_time"] == 1200 - assert result["latency"] == 1500 - assert result["provider_name"] == "anthropic" - - # Verify request was made with correct params - mock_requests.get.assert_called_once() - call_kwargs = mock_requests.get.call_args - assert call_kwargs[1]["params"] == {"id": "gen-abc-123"} - assert "Bearer sk-or-test-key" in call_kwargs[1]["headers"]["Authorization"] - - -@patch(f"{_TRACKING_MOD}.requests") -def test_get_generation_metrics_non_200(mock_requests: MagicMock): - """Returns None when API returns non-200 status.""" - mock_response = MagicMock() - mock_response.status_code = 404 - mock_requests.get.return_value = mock_response - - result = get_generation_metrics("gen-missing", "sk-or-key") - - assert result is None - - -@patch(f"{_TRACKING_MOD}.requests") -def test_get_generation_metrics_invalid_structure_no_data_key(mock_requests: MagicMock): - """Returns None when response JSON lacks 'data' key.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"error": "not found"} - mock_requests.get.return_value = mock_response - - result = get_generation_metrics("gen-bad", "sk-or-key") - - assert result is None - - -@patch(f"{_TRACKING_MOD}.requests") -def test_get_generation_metrics_invalid_structure_empty_response(mock_requests: MagicMock): - """Returns None when response JSON is empty/None.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = None - mock_requests.get.return_value = mock_response - - result = get_generation_metrics("gen-empty", "sk-or-key") - - assert result is None - - -@patch(f"{_TRACKING_MOD}.requests") -def test_get_generation_metrics_request_exception(mock_requests: MagicMock): - """Returns None when requests raises an exception.""" - import requests as real_requests - - mock_requests.get.side_effect = real_requests.exceptions.ConnectionError("refused") - mock_requests.exceptions = real_requests.exceptions - - result = get_generation_metrics("gen-fail", "sk-or-key") - - assert result is None - - -@patch(f"{_TRACKING_MOD}.requests") -def test_get_generation_metrics_timeout(mock_requests: MagicMock): - """Returns None on request timeout.""" - import requests as real_requests - - mock_requests.get.side_effect = real_requests.exceptions.Timeout("timed out") - mock_requests.exceptions = real_requests.exceptions - - result = get_generation_metrics("gen-timeout", "sk-or-key") - - assert result is None - - -@patch(f"{_TRACKING_MOD}.requests") -def test_get_generation_metrics_defaults_missing_fields(mock_requests: MagicMock): - """Missing fields in metrics default to 0 / 'unknown'.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"data": {}} - mock_requests.get.return_value = mock_response - - result = get_generation_metrics("gen-sparse", "sk-or-key") - - assert result is not None - assert result["total_cost"] == 0.0 - assert result["tokens_prompt"] == 0 - assert result["tokens_completion"] == 0 - assert result["generation_time"] == 0 - assert result["latency"] == 0 - assert result["provider_name"] == "unknown" - - -# ============================================= -# store_usage_data tests -# ============================================= - - -@patch(f"{_TRACKING_MOD}.API_JSON_DIR") -def test_store_usage_data_creates_new_file(mock_dir: MagicMock, tmp_path: Path): - """store_usage_data creates initial structure when file doesn't exist.""" - mock_dir.__truediv__ = lambda self, other: tmp_path / other - mock_dir.mkdir = MagicMock() - - metrics = { - "total_cost": 0.005, - "tokens_prompt": 100, - "tokens_completion": 50, - "generation_time": 800, - "latency": 1000, - "provider_name": "anthropic", - } - - result = store_usage_data("test_caller", "anthropic/claude-3.5-sonnet", "gen-001", metrics) - - assert result is True - - data_path = tmp_path / "usage_tracker_data.json" - assert data_path.exists() - - with open(data_path, "r", encoding="utf-8") as f: - wrapper = json.load(f) - - assert wrapper["module_name"] == "api_usage" - data = wrapper["data"] - - # Session totals - assert data["current_session"]["total_requests"] == 1 - assert data["current_session"]["total_cost"] == 0.005 - assert data["current_session"]["total_tokens"] == 150 - - # Per-caller stats - assert "test_caller" in data["usage_by_caller"] - caller_data = data["usage_by_caller"]["test_caller"] - assert caller_data["requests"] == 1 - assert caller_data["total_cost"] == 0.005 - assert caller_data["total_tokens"] == 150 - assert caller_data["models_used"]["anthropic/claude-3.5-sonnet"] == 1 - - # Generation tracking - assert "gen-001" in data["generation_tracking"] - entry = data["generation_tracking"]["gen-001"] - assert entry["caller"] == "test_caller" - assert entry["model"] == "anthropic/claude-3.5-sonnet" - assert entry["usage_data"] == metrics - - -@patch(f"{_TRACKING_MOD}.API_JSON_DIR") -def test_store_usage_data_updates_existing(mock_dir: MagicMock, tmp_path: Path): - """store_usage_data increments counters in existing file.""" - mock_dir.__truediv__ = lambda self, other: tmp_path / other - mock_dir.mkdir = MagicMock() - - metrics = { - "total_cost": 0.01, - "tokens_prompt": 200, - "tokens_completion": 100, - "generation_time": 500, - "latency": 700, - "provider_name": "openai", - } - - # First call creates the file - store_usage_data("caller_a", "openai/gpt-4", "gen-100", metrics) - - # Second call updates - result = store_usage_data("caller_a", "openai/gpt-4", "gen-101", metrics) - - assert result is True - - data_path = tmp_path / "usage_tracker_data.json" - with open(data_path, "r", encoding="utf-8") as f: - wrapper = json.load(f) - - data = wrapper["data"] - assert data["current_session"]["total_requests"] == 2 - assert data["current_session"]["total_cost"] == pytest.approx(0.02) - assert data["current_session"]["total_tokens"] == 600 - - caller_data = data["usage_by_caller"]["caller_a"] - assert caller_data["requests"] == 2 - assert caller_data["models_used"]["openai/gpt-4"] == 2 - - -@patch(f"{_TRACKING_MOD}.API_JSON_DIR") -def test_store_usage_data_newest_first_ordering(mock_dir: MagicMock, tmp_path: Path): - """Generation tracking stores newest entry first.""" - mock_dir.__truediv__ = lambda self, other: tmp_path / other - mock_dir.mkdir = MagicMock() - - metrics = { - "total_cost": 0.001, - "tokens_prompt": 10, - "tokens_completion": 5, - "generation_time": 100, - "latency": 200, - "provider_name": "test", - } - - store_usage_data("caller", "model/a", "gen-first", metrics) - store_usage_data("caller", "model/a", "gen-second", metrics) - - data_path = tmp_path / "usage_tracker_data.json" - with open(data_path, "r", encoding="utf-8") as f: - wrapper = json.load(f) - - tracking_keys = list(wrapper["data"]["generation_tracking"].keys()) - assert tracking_keys[0] == "gen-second" - assert tracking_keys[1] == "gen-first" - - -@patch(f"{_TRACKING_MOD}.API_JSON_DIR") -def test_store_usage_data_multiple_callers(mock_dir: MagicMock, tmp_path: Path): - """store_usage_data tracks multiple callers independently.""" - mock_dir.__truediv__ = lambda self, other: tmp_path / other - mock_dir.mkdir = MagicMock() - - metrics = { - "total_cost": 0.003, - "tokens_prompt": 50, - "tokens_completion": 25, - "generation_time": 300, - "latency": 400, - "provider_name": "test", - } - - store_usage_data("caller_x", "model/x", "gen-x1", metrics) - store_usage_data("caller_y", "model/y", "gen-y1", metrics) - - data_path = tmp_path / "usage_tracker_data.json" - with open(data_path, "r", encoding="utf-8") as f: - wrapper = json.load(f) - - by_caller = wrapper["data"]["usage_by_caller"] - assert "caller_x" in by_caller - assert "caller_y" in by_caller - assert by_caller["caller_x"]["requests"] == 1 - assert by_caller["caller_y"]["requests"] == 1 - assert by_caller["caller_x"]["models_used"]["model/x"] == 1 - assert by_caller["caller_y"]["models_used"]["model/y"] == 1 - - -@patch(f"{_TRACKING_MOD}.API_JSON_DIR") -def test_store_usage_data_daily_totals(mock_dir: MagicMock, tmp_path: Path): - """store_usage_data updates daily totals for today's date.""" - from datetime import datetime - - mock_dir.__truediv__ = lambda self, other: tmp_path / other - mock_dir.mkdir = MagicMock() - - metrics = { - "total_cost": 0.002, - "tokens_prompt": 40, - "tokens_completion": 20, - "generation_time": 200, - "latency": 300, - "provider_name": "test", - } - - store_usage_data("caller", "model/a", "gen-daily", metrics) - - data_path = tmp_path / "usage_tracker_data.json" - with open(data_path, "r", encoding="utf-8") as f: - wrapper = json.load(f) - - today = datetime.now().date().isoformat() - daily = wrapper["data"]["daily_totals"] - assert today in daily - assert daily[today]["requests"] == 1 - assert daily[today]["cost"] == 0.002 - assert daily[today]["tokens"] == 60 - - -@patch(f"{_TRACKING_MOD}.API_JSON_DIR") -def test_store_usage_data_returns_false_on_exception(mock_dir: MagicMock, tmp_path: Path): - """store_usage_data returns False when an exception occurs.""" - # Point to a path that will fail (parent is a file, not a dir) - blocker = tmp_path / "blocker_file" - blocker.write_text("not a dir", encoding="utf-8") - mock_dir.__truediv__ = lambda self, other: blocker / other - mock_dir.mkdir = MagicMock(side_effect=OSError("cannot create")) - - metrics = { - "total_cost": 0.0, - "tokens_prompt": 0, - "tokens_completion": 0, - "generation_time": 0, - "latency": 0, - "provider_name": "test", - } - - result = store_usage_data("caller", "model", "gen-err", metrics) - - assert result is False diff --git a/src/aipass/api/tests/test_usage_tracker.py b/src/aipass/api/tests/test_usage_tracker.py deleted file mode 100644 index 05a317d1..00000000 --- a/src/aipass/api/tests/test_usage_tracker.py +++ /dev/null @@ -1,457 +0,0 @@ -# =================== AIPass ==================== -# Name: test_usage_tracker.py -# Description: Tests for usage tracker module -# Version: 1.0.0 -# Created: 2026-03-24 -# Modified: 2026-03-24 -# ============================================= - -""" -Tests for usage_tracker.py -- API usage monitoring orchestration. - -Tests: -- handle_command routing for all subcommands -- Help gate, introspection gate, unknown command -- show_stats / show_session with data and without -- show_caller_usage with data, no data, and missing args -- cleanup_data success/failure, default/custom days -""" - -from pathlib import Path -from unittest.mock import patch, MagicMock, call - -import pytest - -from aipass.api.apps.modules.usage_tracker import handle_command as _hc # noqa: F401 — seedgo test_coverage detection - -# All external dependencies are patched at the module level so no -# live I/O or network access is needed. - -PATCH_ROOT = "aipass.api.apps.modules.usage_tracker" - - -# ============================================= -# Helpers -# ============================================= - - -def _base_patches(): - """Return the list of patch targets shared by most tests.""" - return [ - f"{PATCH_ROOT}.console", - f"{PATCH_ROOT}.header", - f"{PATCH_ROOT}.success", - f"{PATCH_ROOT}.error", - f"{PATCH_ROOT}.warning", - f"{PATCH_ROOT}.json_handler", - f"{PATCH_ROOT}.tracking", - f"{PATCH_ROOT}.aggregation", - f"{PATCH_ROOT}.cleanup", - ] - - -# ============================================= -# handle_command -- routing & gates -# ============================================= - - -@patch(f"{PATCH_ROOT}.console") -@patch(f"{PATCH_ROOT}.header") -@patch(f"{PATCH_ROOT}.json_handler") -def test_handle_command_returns_false_for_unknown(mock_jh, mock_header, mock_console): - """Unknown commands must return False without logging.""" - from aipass.api.apps.modules import usage_tracker - - result = usage_tracker.handle_command("unknown", []) - - assert result is False - mock_jh.log_operation.assert_not_called() - - -@patch(f"{PATCH_ROOT}.track_usage") -@patch(f"{PATCH_ROOT}.console") -@patch(f"{PATCH_ROOT}.header") -@patch(f"{PATCH_ROOT}.json_handler") -def test_handle_command_routes_track(mock_jh, mock_header, mock_console, mock_track): - """'track' with args should call track_usage(args).""" - from aipass.api.apps.modules import usage_tracker - - result = usage_tracker.handle_command("track", ["my_app"]) - - assert result is True - mock_track.assert_called_once_with(["my_app"]) - - -@patch(f"{PATCH_ROOT}.show_stats") -@patch(f"{PATCH_ROOT}.console") -@patch(f"{PATCH_ROOT}.header") -@patch(f"{PATCH_ROOT}.json_handler") -def test_handle_command_routes_stats_standalone(mock_jh, mock_header, mock_console, mock_show): - """'stats' is standalone -- routes before introspection gate, no args needed.""" - from aipass.api.apps.modules import usage_tracker - - result = usage_tracker.handle_command("stats", []) - - assert result is True - mock_show.assert_called_once() - - -@patch(f"{PATCH_ROOT}.show_session") -@patch(f"{PATCH_ROOT}.console") -@patch(f"{PATCH_ROOT}.header") -@patch(f"{PATCH_ROOT}.json_handler") -def test_handle_command_routes_session_standalone(mock_jh, mock_header, mock_console, mock_show): - """'session' is standalone -- routes before introspection gate, no args needed.""" - from aipass.api.apps.modules import usage_tracker - - result = usage_tracker.handle_command("session", []) - - assert result is True - mock_show.assert_called_once() - - -@patch(f"{PATCH_ROOT}.show_caller_usage") -@patch(f"{PATCH_ROOT}.console") -@patch(f"{PATCH_ROOT}.header") -@patch(f"{PATCH_ROOT}.json_handler") -def test_handle_command_routes_caller_usage(mock_jh, mock_header, mock_console, mock_show): - """'caller-usage' with args routes to show_caller_usage.""" - from aipass.api.apps.modules import usage_tracker - - result = usage_tracker.handle_command("caller-usage", ["my_app"]) - - assert result is True - mock_show.assert_called_once_with(["my_app"]) - - -@patch(f"{PATCH_ROOT}.cleanup_data") -@patch(f"{PATCH_ROOT}.console") -@patch(f"{PATCH_ROOT}.header") -@patch(f"{PATCH_ROOT}.json_handler") -def test_handle_command_routes_cleanup(mock_jh, mock_header, mock_console, mock_cleanup): - """'cleanup' with args routes to cleanup_data.""" - from aipass.api.apps.modules import usage_tracker - - result = usage_tracker.handle_command("cleanup", ["60"]) - - assert result is True - mock_cleanup.assert_called_once_with(["60"]) - - -@patch(f"{PATCH_ROOT}.print_help") -@patch(f"{PATCH_ROOT}.console") -@patch(f"{PATCH_ROOT}.header") -@patch(f"{PATCH_ROOT}.json_handler") -def test_handle_command_help_gate(mock_jh, mock_header, mock_console, mock_help): - """--help flag triggers print_help and returns True.""" - from aipass.api.apps.modules import usage_tracker - - result = usage_tracker.handle_command("track", ["--help"]) - - assert result is True - mock_help.assert_called_once() - # log_operation should NOT be called when help is shown - mock_jh.log_operation.assert_not_called() - - -@patch(f"{PATCH_ROOT}.error") -@patch(f"{PATCH_ROOT}.console") -@patch(f"{PATCH_ROOT}.header") -@patch(f"{PATCH_ROOT}.json_handler") -def test_handle_command_track_no_args_executes(mock_jh, mock_header, mock_console, mock_error): - """'track' with empty args should execute (show error), not show introspection.""" - from aipass.api.apps.modules import usage_tracker - - result = usage_tracker.handle_command("track", []) - - assert result is True - mock_error.assert_called() - assert "Generation ID required" in mock_error.call_args[0][0] - - -@patch(f"{PATCH_ROOT}.console") -@patch(f"{PATCH_ROOT}.header") -@patch(f"{PATCH_ROOT}.json_handler") -def test_handle_command_logs_operation(mock_jh, mock_header, mock_console): - """handle_command should call json_handler.log_operation for valid commands.""" - from aipass.api.apps.modules import usage_tracker - - usage_tracker.handle_command("stats", []) - - mock_jh.log_operation.assert_called_once_with( - "usage_stats", {"command": "stats"} - ) - - -# ============================================= -# show_stats -# ============================================= - - -@patch(f"{PATCH_ROOT}.warning") -@patch(f"{PATCH_ROOT}.console") -@patch(f"{PATCH_ROOT}.header") -@patch(f"{PATCH_ROOT}.aggregation") -def test_show_stats_with_data(mock_agg, mock_header, mock_console, mock_warning): - """show_stats prints stats when aggregation returns data.""" - from aipass.api.apps.modules import usage_tracker - - mock_agg.get_overall_stats.return_value = { - "total_requests": 42, - "total_cost": 0.123456, - "total_tokens": 9001, - "callers": 3, - "models_used": ["anthropic/claude-3.5-sonnet"], - } - - usage_tracker.show_stats() - - mock_header.assert_called_once_with("Usage Statistics") - # Verify the data values appear in console output - calls = [str(c) for c in mock_console.print.call_args_list] - output = " ".join(calls) - assert "42" in output - assert "9001" in output - mock_warning.assert_not_called() - - -@patch(f"{PATCH_ROOT}.warning") -@patch(f"{PATCH_ROOT}.console") -@patch(f"{PATCH_ROOT}.header") -@patch(f"{PATCH_ROOT}.aggregation") -def test_show_stats_no_data(mock_agg, mock_header, mock_console, mock_warning): - """show_stats shows warning when no data available.""" - from aipass.api.apps.modules import usage_tracker - - mock_agg.get_overall_stats.return_value = {} - - usage_tracker.show_stats() - - mock_warning.assert_called_once_with("No usage data available") - # Verify no stat data was printed to console - stat_calls = [ - c for c in mock_console.print.call_args_list - if c.args and isinstance(c.args[0], str) and "Total Requests" in c.args[0] - ] - assert len(stat_calls) == 0, "No stat rows should be printed when data is empty" - - -# ============================================= -# show_session -# ============================================= - - -@patch(f"{PATCH_ROOT}.warning") -@patch(f"{PATCH_ROOT}.console") -@patch(f"{PATCH_ROOT}.header") -@patch(f"{PATCH_ROOT}.aggregation") -def test_show_session_with_data(mock_agg, mock_header, mock_console, mock_warning): - """show_session prints session data when available.""" - from aipass.api.apps.modules import usage_tracker - - mock_agg.get_session_summary.return_value = { - "total_requests": 10, - "total_cost": 0.05, - "total_tokens": 3000, - } - - usage_tracker.show_session() - - mock_header.assert_called_once_with("Session Summary") - calls = [str(c) for c in mock_console.print.call_args_list] - output = " ".join(calls) - assert "10" in output - assert "3000" in output - mock_warning.assert_not_called() - - -@patch(f"{PATCH_ROOT}.warning") -@patch(f"{PATCH_ROOT}.console") -@patch(f"{PATCH_ROOT}.header") -@patch(f"{PATCH_ROOT}.aggregation") -def test_show_session_no_data(mock_agg, mock_header, mock_console, mock_warning): - """show_session shows warning when no session data.""" - from aipass.api.apps.modules import usage_tracker - - mock_agg.get_session_summary.return_value = {} - - usage_tracker.show_session() - - mock_warning.assert_called_once_with("No session data available") - # Verify no session stat data was printed to console - stat_calls = [ - c for c in mock_console.print.call_args_list - if c.args and isinstance(c.args[0], str) and "Session Requests" in c.args[0] - ] - assert len(stat_calls) == 0, "No session rows should be printed when data is empty" - - -# ============================================= -# show_caller_usage -# ============================================= - - -@patch(f"{PATCH_ROOT}.warning") -@patch(f"{PATCH_ROOT}.console") -@patch(f"{PATCH_ROOT}.header") -@patch(f"{PATCH_ROOT}.aggregation") -def test_show_caller_usage_with_data(mock_agg, mock_header, mock_console, mock_warning): - """show_caller_usage prints caller data when available.""" - from aipass.api.apps.modules import usage_tracker - - mock_agg.get_caller_usage.return_value = { - "requests": 5, - "total_cost": 0.01, - "total_tokens": 1500, - } - - usage_tracker.show_caller_usage(["my_app"]) - - mock_agg.get_caller_usage.assert_called_once_with("my_app") - mock_header.assert_called_once_with("Usage for Caller: my_app") - calls = [str(c) for c in mock_console.print.call_args_list] - output = " ".join(calls) - assert "5" in output - assert "1500" in output - mock_warning.assert_not_called() - - -@patch(f"{PATCH_ROOT}.warning") -@patch(f"{PATCH_ROOT}.console") -@patch(f"{PATCH_ROOT}.header") -@patch(f"{PATCH_ROOT}.aggregation") -def test_show_caller_usage_no_data(mock_agg, mock_header, mock_console, mock_warning): - """show_caller_usage shows warning when no data found.""" - from aipass.api.apps.modules import usage_tracker - - mock_agg.get_caller_usage.return_value = {} - - usage_tracker.show_caller_usage(["ghost_caller"]) - - mock_warning.assert_called_once_with("No usage data found for caller: ghost_caller") - # Verify no usage data rows were printed to console - usage_calls = [ - c for c in mock_console.print.call_args_list - if c.args and isinstance(c.args[0], str) and "Requests" in c.args[0] - ] - assert len(usage_calls) == 0, "No usage rows should be printed when data is empty" - - -@patch(f"{PATCH_ROOT}.error") -@patch(f"{PATCH_ROOT}.console") -@patch(f"{PATCH_ROOT}.header") -@patch(f"{PATCH_ROOT}.aggregation") -def test_show_caller_usage_no_args(mock_agg, mock_header, mock_console, mock_error): - """show_caller_usage calls error() when called with empty args.""" - from aipass.api.apps.modules import usage_tracker - - usage_tracker.show_caller_usage([]) - - mock_error.assert_called_once_with("Caller name required") - mock_agg.get_caller_usage.assert_not_called() - - -# ============================================= -# cleanup_data -# ============================================= - - -@patch(f"{PATCH_ROOT}.error") -@patch(f"{PATCH_ROOT}.success") -@patch(f"{PATCH_ROOT}.console") -@patch(f"{PATCH_ROOT}.header") -@patch(f"{PATCH_ROOT}.cleanup") -def test_cleanup_success(mock_cleanup_handler, mock_header, mock_console, mock_success, mock_error): - """cleanup_data calls success() with count when handler returns > 0.""" - from aipass.api.apps.modules import usage_tracker - - mock_cleanup_handler.cleanup_old_data.return_value = 5 - - # The data_path.exists() check needs to pass - with patch(f"{PATCH_ROOT}.Path") as mock_path_cls: - mock_data_path = MagicMock() - mock_data_path.exists.return_value = True - mock_path_cls.return_value.resolve.return_value.parent.parent.parent.__truediv__.return_value.__truediv__.return_value = mock_data_path - - # Call directly -- cleanup handler is mocked, use real Path for API_JSON_DIR resolution - usage_tracker.cleanup_data(["45"]) - - mock_cleanup_handler.cleanup_old_data.assert_called_once() - mock_success.assert_called_once_with("Cleaned up 5 entries older than 45 days") - mock_error.assert_not_called() - - -@patch(f"{PATCH_ROOT}.warning") -@patch(f"{PATCH_ROOT}.success") -@patch(f"{PATCH_ROOT}.console") -@patch(f"{PATCH_ROOT}.header") -@patch(f"{PATCH_ROOT}.cleanup") -def test_cleanup_nothing_to_clean(mock_cleanup_handler, mock_header, mock_console, mock_success, mock_warning): - """cleanup_data calls success() with 'nothing to clean' when handler returns 0.""" - from aipass.api.apps.modules import usage_tracker - - mock_cleanup_handler.cleanup_old_data.return_value = 0 - - usage_tracker.cleanup_data(["30"]) - - mock_cleanup_handler.cleanup_old_data.assert_called_once() - mock_success.assert_called_once_with("Nothing to clean — no entries older than 30 days") - - -@patch(f"{PATCH_ROOT}.error") -@patch(f"{PATCH_ROOT}.success") -@patch(f"{PATCH_ROOT}.console") -@patch(f"{PATCH_ROOT}.header") -@patch(f"{PATCH_ROOT}.cleanup") -def test_cleanup_default_30_days(mock_cleanup_handler, mock_header, mock_console, mock_success, mock_error): - """cleanup_data defaults to 30 days when no args provided.""" - from aipass.api.apps.modules import usage_tracker - - mock_cleanup_handler.cleanup_old_data.return_value = 3 - - usage_tracker.cleanup_data([]) - - # Verify the header shows 30 days - mock_header.assert_called_once_with("Cleanup Old Data (retain 30 days)") - # Verify cleanup_old_data was called with days=30 - args, kwargs = mock_cleanup_handler.cleanup_old_data.call_args - assert args[1] == 30 - mock_success.assert_called_once_with("Cleaned up 3 entries older than 30 days") - - -@patch(f"{PATCH_ROOT}.error") -@patch(f"{PATCH_ROOT}.success") -@patch(f"{PATCH_ROOT}.console") -@patch(f"{PATCH_ROOT}.header") -@patch(f"{PATCH_ROOT}.cleanup") -def test_cleanup_custom_days(mock_cleanup_handler, mock_header, mock_console, mock_success, mock_error): - """cleanup_data parses custom days from args.""" - from aipass.api.apps.modules import usage_tracker - - mock_cleanup_handler.cleanup_old_data.return_value = 7 - - usage_tracker.cleanup_data(["90"]) - - mock_header.assert_called_once_with("Cleanup Old Data (retain 90 days)") - args, kwargs = mock_cleanup_handler.cleanup_old_data.call_args - assert args[1] == 90 - mock_success.assert_called_once_with("Cleaned up 7 entries older than 90 days") - - -# ============================================= -# handle_command — exception propagation -# ============================================= - - -@patch(f"{PATCH_ROOT}.aggregation") -@patch(f"{PATCH_ROOT}.json_handler") -@patch(f"{PATCH_ROOT}.header") -@patch(f"{PATCH_ROOT}.console") -def test_handle_command_propagates_exception(mock_console, mock_header, mock_jh, mock_agg): - """handle_command re-raises exceptions from downstream handlers.""" - from aipass.api.apps.modules import usage_tracker - - mock_agg.get_overall_stats.side_effect = RuntimeError("handler failed") - - with pytest.raises(RuntimeError, match="handler failed"): - usage_tracker.handle_command("stats", [])