From 040611d098ff9ac1a9b4789438f1176b037784d6 Mon Sep 17 00:00:00 2001 From: AIPass Date: Fri, 10 Apr 2026 10:22:28 -0700 Subject: [PATCH] feat(system): S85: Restore api branch + gitleaks config (merge sync) (#228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(system): SECURITY: gitleaks config + pre-commit for API key leak prevention Co-Authored-By: @devpulse * feat(system): S84: Restore api branch to repo — normal branch, gitleaks protection, no real keys outside .secrets Co-Authored-By: @devpulse --------- Co-authored-by: @devpulse --- .gitignore | 3 +- .gitleaks.toml | 66 + .pre-commit-config.yaml | 14 + src/aipass/api/.aipass/README.md | 3 + src/aipass/api/.aipass/aipass_local_prompt.md | 46 + src/aipass/api/.claude/README.md | 5 + src/aipass/api/.gitignore | 14 + src/aipass/api/.seedgo/README.md | 5 + src/aipass/api/.seedgo/bypass.json | 104 ++ src/aipass/api/README.md | 140 +++ src/aipass/api/__init__.py | 1 + src/aipass/api/apps/README.md | 8 + src/aipass/api/apps/__init__.py | 1 + src/aipass/api/apps/api.py | 286 +++++ src/aipass/api/apps/extensions/__init__.py | 1 + src/aipass/api/apps/handlers/README.md | 5 + src/aipass/api/apps/handlers/__init__.py | 77 ++ src/aipass/api/apps/handlers/auth/__init__.py | 7 + src/aipass/api/apps/handlers/auth/env.py | 109 ++ src/aipass/api/apps/handlers/auth/keys.py | 305 +++++ .../api/apps/handlers/config/__init__.py | 7 + .../api/apps/handlers/config/provider.py | 117 ++ .../api/apps/handlers/google/__init__.py | 12 + src/aipass/api/apps/handlers/google/auth.py | 254 ++++ src/aipass/api/apps/handlers/google/retry.py | 90 ++ .../apps/handlers/google/service_factory.py | 123 ++ src/aipass/api/apps/handlers/json/__init__.py | 1 + .../api/apps/handlers/json/json_handler.py | 250 ++++ .../api/apps/handlers/openrouter/__init__.py | 7 + .../api/apps/handlers/openrouter/caller.py | 224 ++++ .../api/apps/handlers/openrouter/client.py | 375 ++++++ .../api/apps/handlers/openrouter/models.py | 115 ++ .../api/apps/handlers/openrouter/provision.py | 278 +++++ .../api/apps/handlers/usage/__init__.py | 7 + .../api/apps/handlers/usage/aggregation.py | 188 +++ src/aipass/api/apps/handlers/usage/cleanup.py | 126 ++ .../api/apps/handlers/usage/tracking.py | 313 +++++ src/aipass/api/apps/modules/README.md | 5 + src/aipass/api/apps/modules/__init__.py | 0 src/aipass/api/apps/modules/api_key.py | 233 ++++ src/aipass/api/apps/modules/google_client.py | 364 ++++++ .../api/apps/modules/openrouter_client.py | 380 ++++++ src/aipass/api/apps/modules/usage_tracker.py | 311 +++++ src/aipass/api/apps/plugins/README.md | 5 + src/aipass/api/apps/plugins/__init__.py | 1 + src/aipass/api/docs/README.md | 3 + src/aipass/api/docs/SECURITY.md | 101 ++ src/aipass/api/pytest.ini | 18 + src/aipass/api/templates/README.md | 5 + src/aipass/api/tests/README.md | 6 + src/aipass/api/tests/__init__.py | 1 + src/aipass/api/tests/conftest.py | 157 +++ src/aipass/api/tests/test_api_key.py | 696 +++++++++++ src/aipass/api/tests/test_caller.py | 91 ++ src/aipass/api/tests/test_cli_routing.py | 120 ++ src/aipass/api/tests/test_config_provider.py | 199 +++ src/aipass/api/tests/test_contracts.py | 200 +++ src/aipass/api/tests/test_critical_paths.py | 344 ++++++ src/aipass/api/tests/test_error_resilience.py | 140 +++ src/aipass/api/tests/test_google_client.py | 1077 +++++++++++++++++ .../api/tests/test_init_provisioning.py | 147 +++ src/aipass/api/tests/test_json_handler.py | 471 +++++++ .../api/tests/test_openrouter_client.py | 748 ++++++++++++ src/aipass/api/tests/test_provision.py | 321 +++++ src/aipass/api/tests/test_tracking.py | 354 ++++++ src/aipass/api/tests/test_usage_tracker.py | 457 +++++++ 66 files changed, 10640 insertions(+), 2 deletions(-) create mode 100644 .gitleaks.toml create mode 100644 src/aipass/api/.aipass/README.md create mode 100644 src/aipass/api/.aipass/aipass_local_prompt.md create mode 100644 src/aipass/api/.claude/README.md create mode 100644 src/aipass/api/.gitignore create mode 100644 src/aipass/api/.seedgo/README.md create mode 100644 src/aipass/api/.seedgo/bypass.json create mode 100644 src/aipass/api/README.md create mode 100644 src/aipass/api/__init__.py create mode 100644 src/aipass/api/apps/README.md create mode 100644 src/aipass/api/apps/__init__.py create mode 100644 src/aipass/api/apps/api.py create mode 100644 src/aipass/api/apps/extensions/__init__.py create mode 100644 src/aipass/api/apps/handlers/README.md create mode 100644 src/aipass/api/apps/handlers/__init__.py create mode 100644 src/aipass/api/apps/handlers/auth/__init__.py create mode 100644 src/aipass/api/apps/handlers/auth/env.py create mode 100644 src/aipass/api/apps/handlers/auth/keys.py create mode 100644 src/aipass/api/apps/handlers/config/__init__.py create mode 100644 src/aipass/api/apps/handlers/config/provider.py create mode 100644 src/aipass/api/apps/handlers/google/__init__.py create mode 100644 src/aipass/api/apps/handlers/google/auth.py create mode 100644 src/aipass/api/apps/handlers/google/retry.py create mode 100644 src/aipass/api/apps/handlers/google/service_factory.py create mode 100644 src/aipass/api/apps/handlers/json/__init__.py create mode 100755 src/aipass/api/apps/handlers/json/json_handler.py create mode 100644 src/aipass/api/apps/handlers/openrouter/__init__.py create mode 100644 src/aipass/api/apps/handlers/openrouter/caller.py create mode 100644 src/aipass/api/apps/handlers/openrouter/client.py create mode 100644 src/aipass/api/apps/handlers/openrouter/models.py create mode 100644 src/aipass/api/apps/handlers/openrouter/provision.py create mode 100644 src/aipass/api/apps/handlers/usage/__init__.py create mode 100644 src/aipass/api/apps/handlers/usage/aggregation.py create mode 100644 src/aipass/api/apps/handlers/usage/cleanup.py create mode 100644 src/aipass/api/apps/handlers/usage/tracking.py create mode 100644 src/aipass/api/apps/modules/README.md create mode 100644 src/aipass/api/apps/modules/__init__.py create mode 100644 src/aipass/api/apps/modules/api_key.py create mode 100644 src/aipass/api/apps/modules/google_client.py create mode 100644 src/aipass/api/apps/modules/openrouter_client.py create mode 100644 src/aipass/api/apps/modules/usage_tracker.py create mode 100644 src/aipass/api/apps/plugins/README.md create mode 100644 src/aipass/api/apps/plugins/__init__.py create mode 100644 src/aipass/api/docs/README.md create mode 100644 src/aipass/api/docs/SECURITY.md create mode 100644 src/aipass/api/pytest.ini create mode 100644 src/aipass/api/templates/README.md create mode 100644 src/aipass/api/tests/README.md create mode 100644 src/aipass/api/tests/__init__.py create mode 100644 src/aipass/api/tests/conftest.py create mode 100644 src/aipass/api/tests/test_api_key.py create mode 100644 src/aipass/api/tests/test_caller.py create mode 100644 src/aipass/api/tests/test_cli_routing.py create mode 100644 src/aipass/api/tests/test_config_provider.py create mode 100644 src/aipass/api/tests/test_contracts.py create mode 100644 src/aipass/api/tests/test_critical_paths.py create mode 100644 src/aipass/api/tests/test_error_resilience.py create mode 100644 src/aipass/api/tests/test_google_client.py create mode 100644 src/aipass/api/tests/test_init_provisioning.py create mode 100644 src/aipass/api/tests/test_json_handler.py create mode 100644 src/aipass/api/tests/test_openrouter_client.py create mode 100644 src/aipass/api/tests/test_provision.py create mode 100644 src/aipass/api/tests/test_tracking.py create mode 100644 src/aipass/api/tests/test_usage_tracker.py diff --git a/.gitignore b/.gitignore index 826f77a4..ff4a9b42 100644 --- a/.gitignore +++ b/.gitignore @@ -16,8 +16,7 @@ build/- # Secrets .env -# API branch (private infrastructure — contains key handling code) -src/aipass/api/ +# API keys never leave ~/.secrets/ — gitleaks + pre-commit enforce this # Plans (managed by flow, local to each installation) FPLAN-*.md diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..b54b4cdc --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,66 @@ +# Gitleaks configuration for AIPass +# Prevents API keys and secrets from being committed to the repo. +# +# Usage: gitleaks runs automatically via pre-commit hook. +# Manual scan: gitleaks detect --config .gitleaks.toml + +title = "AIPass Secret Detection Rules" + +[extend] +useDefault = true + +# --- Custom rules for AIPass-specific key formats --- + +[[rules]] +id = "openrouter-api-key" +description = "OpenRouter API key (sk-or-v1- prefix with 20+ alphanumeric chars)" +regex = '''sk-or-v1-[a-zA-Z0-9]{20,}''' +keywords = ["sk-or-v1-"] + +[[rules]] +id = "openai-api-key" +description = "OpenAI API key (sk- prefix with 20+ chars, not sk-or/sk-ant)" +regex = '''sk-(?!or|ant)[a-zA-Z0-9]{20,}''' +keywords = ["sk-"] + +[[rules]] +id = "anthropic-api-key" +description = "Anthropic API key (sk-ant- prefix)" +regex = '''sk-ant-[a-zA-Z0-9]{20,}''' +keywords = ["sk-ant-"] + +[[rules]] +id = "google-api-key" +description = "Google API key (AIza prefix)" +regex = '''AIza[0-9A-Za-z\-_]{35,}''' +keywords = ["AIza"] + +[[rules]] +id = "bearer-token-in-code" +description = "Bearer token hardcoded in source (not in test assertions)" +regex = '''Bearer\s+[a-zA-Z0-9_\-\.]{40,}''' +keywords = ["Bearer"] + +[[rules]] +id = "env-file-key-assignment" +description = "API key assigned in code with realistic value" +regex = '''(?:OPENROUTER|OPENAI|ANTHROPIC|GOOGLE)_API_KEY\s*=\s*["']?(?:sk-|AIza)[a-zA-Z0-9\-_]{20,}''' +keywords = ["API_KEY"] + +# --- Allowlists --- + +[allowlist] +# Files that are allowed to contain key-like patterns +paths = [ + '''\.gitleaks\.toml$''', + '''\.pre-commit-config\.yaml$''', +] + +# Strings that are explicitly allowed (FAKE/NOTREAL test keys, prefix patterns) +regexes = [ + '''FAKE-''', + '''NOTREAL''', + '''your-key-here''', + '''sk-or-v1-short''', + '''sk-wrong-prefix''', +] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b21dc652..d9fee1c1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,10 +1,24 @@ # Pre-commit hooks for AIPass # Install: pip install pre-commit && pre-commit install +<<<<<<< HEAD # Manual run: pre-commit run --all-files repos: # Gitleaks — secret detection +======= +# Run manually: pre-commit run --all-files + +repos: +>>>>>>> 190d7a4 (feat(system): SECURITY: gitleaks config + pre-commit for API key leak prevention) - repo: https://github.com/gitleaks/gitleaks rev: v8.21.2 hooks: - id: gitleaks +<<<<<<< HEAD +======= + name: gitleaks (secret detection) + description: Detect hardcoded secrets using gitleaks + entry: gitleaks protect --verbose --redact --staged + language: golang + pass_filenames: false +>>>>>>> 190d7a4 (feat(system): SECURITY: gitleaks config + pre-commit for API key leak prevention) diff --git a/src/aipass/api/.aipass/README.md b/src/aipass/api/.aipass/README.md new file mode 100644 index 00000000..db729ac8 --- /dev/null +++ b/src/aipass/api/.aipass/README.md @@ -0,0 +1,3 @@ +# 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 new file mode 100644 index 00000000..c55fbffa --- /dev/null +++ b/src/aipass/api/.aipass/aipass_local_prompt.md @@ -0,0 +1,46 @@ +# 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 new file mode 100644 index 00000000..dd9e96b4 --- /dev/null +++ b/src/aipass/api/.claude/README.md @@ -0,0 +1,5 @@ +# 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 new file mode 100644 index 00000000..9cf1dfc4 --- /dev/null +++ b/src/aipass/api/.gitignore @@ -0,0 +1,14 @@ +__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 new file mode 100644 index 00000000..59fdc4be --- /dev/null +++ b/src/aipass/api/.seedgo/README.md @@ -0,0 +1,5 @@ +# 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 new file mode 100644 index 00000000..63cb4b9c --- /dev/null +++ b/src/aipass/api/.seedgo/bypass.json @@ -0,0 +1,104 @@ +{ + "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 new file mode 100644 index 00000000..ef4c5d4a --- /dev/null +++ b/src/aipass/api/README.md @@ -0,0 +1,140 @@ +[← 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 new file mode 100644 index 00000000..f2c62315 --- /dev/null +++ b/src/aipass/api/__init__.py @@ -0,0 +1 @@ +"""API - External API integrations for AIPass.""" diff --git a/src/aipass/api/apps/README.md b/src/aipass/api/apps/README.md new file mode 100644 index 00000000..8d9316e1 --- /dev/null +++ b/src/aipass/api/apps/README.md @@ -0,0 +1,8 @@ +# 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 new file mode 100644 index 00000000..803548c8 --- /dev/null +++ b/src/aipass/api/apps/__init__.py @@ -0,0 +1 @@ +# Apps package diff --git a/src/aipass/api/apps/api.py b/src/aipass/api/apps/api.py new file mode 100644 index 00000000..b7c0d4a4 --- /dev/null +++ b/src/aipass/api/apps/api.py @@ -0,0 +1,286 @@ +# =================== 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 new file mode 100644 index 00000000..95322c94 --- /dev/null +++ b/src/aipass/api/apps/extensions/__init__.py @@ -0,0 +1 @@ +# 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 new file mode 100644 index 00000000..e2cbe0e5 --- /dev/null +++ b/src/aipass/api/apps/handlers/README.md @@ -0,0 +1,5 @@ +# 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 new file mode 100644 index 00000000..82b8fd6b --- /dev/null +++ b/src/aipass/api/apps/handlers/__init__.py @@ -0,0 +1,77 @@ +"""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 new file mode 100644 index 00000000..9d77ffe2 --- /dev/null +++ b/src/aipass/api/apps/handlers/auth/__init__.py @@ -0,0 +1,7 @@ +""" +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 new file mode 100644 index 00000000..75000e86 --- /dev/null +++ b/src/aipass/api/apps/handlers/auth/env.py @@ -0,0 +1,109 @@ +# =================== 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 new file mode 100644 index 00000000..bfd3d947 --- /dev/null +++ b/src/aipass/api/apps/handlers/auth/keys.py @@ -0,0 +1,305 @@ +# =================== 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 new file mode 100644 index 00000000..6b424d15 --- /dev/null +++ b/src/aipass/api/apps/handlers/config/__init__.py @@ -0,0 +1,7 @@ +""" +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 new file mode 100644 index 00000000..d26111f9 --- /dev/null +++ b/src/aipass/api/apps/handlers/config/provider.py @@ -0,0 +1,117 @@ +# =================== 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 new file mode 100644 index 00000000..17362827 --- /dev/null +++ b/src/aipass/api/apps/handlers/google/__init__.py @@ -0,0 +1,12 @@ +""" +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 new file mode 100644 index 00000000..745c008f --- /dev/null +++ b/src/aipass/api/apps/handlers/google/auth.py @@ -0,0 +1,254 @@ +# =================== 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 new file mode 100644 index 00000000..44aca88a --- /dev/null +++ b/src/aipass/api/apps/handlers/google/retry.py @@ -0,0 +1,90 @@ +# =================== 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 new file mode 100644 index 00000000..a2fbd94a --- /dev/null +++ b/src/aipass/api/apps/handlers/google/service_factory.py @@ -0,0 +1,123 @@ +# =================== 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 new file mode 100644 index 00000000..287ee1a5 --- /dev/null +++ b/src/aipass/api/apps/handlers/json/__init__.py @@ -0,0 +1 @@ +"""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 new file mode 100755 index 00000000..c158575c --- /dev/null +++ b/src/aipass/api/apps/handlers/json/json_handler.py @@ -0,0 +1,250 @@ +# =================== 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 new file mode 100644 index 00000000..0b5696c2 --- /dev/null +++ b/src/aipass/api/apps/handlers/openrouter/__init__.py @@ -0,0 +1,7 @@ +""" +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 new file mode 100644 index 00000000..8de96cb5 --- /dev/null +++ b/src/aipass/api/apps/handlers/openrouter/caller.py @@ -0,0 +1,224 @@ +# =================== 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 new file mode 100644 index 00000000..e93314ed --- /dev/null +++ b/src/aipass/api/apps/handlers/openrouter/client.py @@ -0,0 +1,375 @@ +# =================== 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 new file mode 100644 index 00000000..ad75939a --- /dev/null +++ b/src/aipass/api/apps/handlers/openrouter/models.py @@ -0,0 +1,115 @@ +# =================== 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 new file mode 100644 index 00000000..9e17fe8a --- /dev/null +++ b/src/aipass/api/apps/handlers/openrouter/provision.py @@ -0,0 +1,278 @@ +# =================== 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 new file mode 100644 index 00000000..dfb934ec --- /dev/null +++ b/src/aipass/api/apps/handlers/usage/__init__.py @@ -0,0 +1,7 @@ +""" +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 new file mode 100644 index 00000000..2f9bca31 --- /dev/null +++ b/src/aipass/api/apps/handlers/usage/aggregation.py @@ -0,0 +1,188 @@ +# =================== 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 new file mode 100644 index 00000000..07eacc4a --- /dev/null +++ b/src/aipass/api/apps/handlers/usage/cleanup.py @@ -0,0 +1,126 @@ +# =================== 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 new file mode 100644 index 00000000..66616bf4 --- /dev/null +++ b/src/aipass/api/apps/handlers/usage/tracking.py @@ -0,0 +1,313 @@ +# =================== 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 new file mode 100644 index 00000000..b7be611c --- /dev/null +++ b/src/aipass/api/apps/modules/README.md @@ -0,0 +1,5 @@ +# 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 new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/api/apps/modules/api_key.py b/src/aipass/api/apps/modules/api_key.py new file mode 100644 index 00000000..c98742c7 --- /dev/null +++ b/src/aipass/api/apps/modules/api_key.py @@ -0,0 +1,233 @@ +# =================== 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 new file mode 100644 index 00000000..05e11710 --- /dev/null +++ b/src/aipass/api/apps/modules/google_client.py @@ -0,0 +1,364 @@ +# =================== 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 new file mode 100644 index 00000000..d1a7e07f --- /dev/null +++ b/src/aipass/api/apps/modules/openrouter_client.py @@ -0,0 +1,380 @@ +# =================== 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 new file mode 100644 index 00000000..56b174de --- /dev/null +++ b/src/aipass/api/apps/modules/usage_tracker.py @@ -0,0 +1,311 @@ +# =================== 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 new file mode 100644 index 00000000..a1cbf5d4 --- /dev/null +++ b/src/aipass/api/apps/plugins/README.md @@ -0,0 +1,5 @@ +# 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 new file mode 100644 index 00000000..69b056dd --- /dev/null +++ b/src/aipass/api/apps/plugins/__init__.py @@ -0,0 +1 @@ +# Plugins package - Pluggable components for branch capabilities diff --git a/src/aipass/api/docs/README.md b/src/aipass/api/docs/README.md new file mode 100644 index 00000000..579fe5fd --- /dev/null +++ b/src/aipass/api/docs/README.md @@ -0,0 +1,3 @@ +# Docs + +Documentation, design plans, and reference material for the API branch. diff --git a/src/aipass/api/docs/SECURITY.md b/src/aipass/api/docs/SECURITY.md new file mode 100644 index 00000000..cb2da3c5 --- /dev/null +++ b/src/aipass/api/docs/SECURITY.md @@ -0,0 +1,101 @@ +# API Branch — Key Management & Security + +## Architecture + +All secrets live **outside** the repository at `~/.secrets/aipass/`: + +``` +~/.secrets/aipass/ +├── .env # API keys (OpenRouter, OpenAI, etc.) +├── google_creds.json # Google OAuth2 tokens +└── google_client_secret.json # Google OAuth app config +``` + +Code never stores, logs, or exposes raw keys. The `api` branch provides authenticated service clients — consumers never touch credentials directly. + +## Credential Flow + +``` +~/.secrets/aipass/.env → keys.py (read + validate) → client.py (use) + ↓ + service object (returned to consumer) +``` + +- `get_api_key()` reads from config JSON first, falls back to `~/.secrets/aipass/.env` +- `validate_key()` checks prefix and minimum length per provider +- Keys are never returned to CLI output unmasked +- `get_cache_stats()` returns count only, never cache keys +- Credential files are created with `0o600` (owner-only read/write) +- Secret directories use `0o700` permissions + +## Provider Key Formats + +| Provider | Prefix | Min Length | +|------------|-------------|------------| +| OpenRouter | `sk-or-v1-` | 40 | +| OpenAI | `sk-` | 40 | +| Anthropic | `sk-ant-` | 40 | +| Google | `AIza` | 39 | +| Generic | (none) | 10 | + +## Defense Layers + +### 1. `.gitignore` +- `.env` — blocks all dotenv files +- `src/aipass/api/` — API branch excluded from public repo (contains key handling code) +- `~/.secrets/` is outside the repo entirely + +### 2. Pre-commit Hook (`.git/hooks/pre-commit`) +- Blocks `sk-or-v1-` + 20 alphanumeric chars +- Blocks API key assignments in code +- Blocks `.env` and credential files from staging +- Allows `FAKE-` and `NOTREAL` prefixed test keys + +### 3. Gitleaks (`.gitleaks.toml` + `.pre-commit-config.yaml`) +- Automated secret scanning via `pre-commit` framework +- Custom rules for OpenRouter, OpenAI, Anthropic, Google key formats +- Allowlist for test key prefixes (`FAKE-`, `NOTREAL`) +- Install: `pip install pre-commit && pre-commit install` + +### 4. GitHub Push Protection +- Enabled by default on public repos +- Catches known secret patterns server-side before push completes + +## Test Key Conventions + +Tests must **never** use strings that could be confused with real keys. + +### For mocked keys (validation bypassed): +```python +# Short, obviously fake — used when the key value doesn't matter +mock_keys.get_api_key.return_value = "FAKE-sk-or-testkey" +``` + +### For keys that must pass validation: +```python +# Correct prefix + NOTREAL marker — passes format validation but clearly fake +key = "sk-or-v1-NOTREAL-test-000000000000000000000000000000000000" +``` + +### Never do this: +```python +# WRONG — looks like a real key, will trigger pre-commit hook +key = "sk-or-v1-FAKE-do-not-use-real-hex-here" + +# WRONG — hex strings that resemble real keys +key = "sk-or-v1-FAKE-no-hex-patterns" +``` + +### Rules: +1. **FAKE-** prefix: for any key that doesn't go through validation +2. **NOTREAL** in the key body: for keys that must have the correct provider prefix +3. Use zeros or repeating patterns, never hex-like random strings +4. Keep test keys as short as possible when length doesn't matter + +## If a Key Leaks + +1. **Rotate immediately** — bots scan public repos within seconds +2. Revoke the old key at the provider dashboard +3. Generate a new key and update `~/.secrets/aipass/.env` +4. Check git history: `git log --all -p -- '*.env' '*.json'` +5. If the key entered git history, consider `git filter-branch` or BFG Repo-Cleaner diff --git a/src/aipass/api/pytest.ini b/src/aipass/api/pytest.ini new file mode 100644 index 00000000..6e0f5894 --- /dev/null +++ b/src/aipass/api/pytest.ini @@ -0,0 +1,18 @@ +[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 new file mode 100644 index 00000000..bd5ca9f6 --- /dev/null +++ b/src/aipass/api/templates/README.md @@ -0,0 +1,5 @@ +# 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 new file mode 100644 index 00000000..1f7e37e7 --- /dev/null +++ b/src/aipass/api/tests/README.md @@ -0,0 +1,6 @@ +# 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 new file mode 100644 index 00000000..711dba10 --- /dev/null +++ b/src/aipass/api/tests/__init__.py @@ -0,0 +1 @@ +# Tests package for api diff --git a/src/aipass/api/tests/conftest.py b/src/aipass/api/tests/conftest.py new file mode 100644 index 00000000..1668730f --- /dev/null +++ b/src/aipass/api/tests/conftest.py @@ -0,0 +1,157 @@ + +# ===================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 new file mode 100644 index 00000000..495b0335 --- /dev/null +++ b/src/aipass/api/tests/test_api_key.py @@ -0,0 +1,696 @@ +# =================== 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 = "FAKE-sk-test1234567890" + + 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 = "FAKE-sk-test1234567890" + 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("FAKE-sk-test1234567890", "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 = "FAKE-sk-or-testkey123" + + 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 = "FAKE-sk-or-testkey123" + 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 = "FAKE-sk-test1234567890" + + 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 = "FAKE-sk-test1234567890" + + 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 = "FAKE-sk-test1234567890" + 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("FAKE-sk-test1234567890", "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 = "FAKE-sk-test1234567890" + 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("FAKE-sk-test1234567890", "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 = "FAKE-sk-test1234567890" + + 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": "FAKE-sk-or-testkey-abc123"} + } + } + }), encoding="utf-8") + + monkeypatch.setattr(auth_keys, "API_JSON_DIR", config_dir) + + result = auth_keys.get_key_from_config("openrouter") + assert result == "FAKE-sk-or-testkey-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": "FAKE-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": "FAKE-sk-or-key"}, + "openai": {"api_key": "FAKE-sk-openai-key"}, + "anthropic": {"api_key": "FAKE-sk-ant-key"}, + } + } + }), encoding="utf-8") + + monkeypatch.setattr(auth_keys, "API_JSON_DIR", config_dir) + + assert auth_keys.get_key_from_config("openrouter") == "FAKE-sk-or-key" + assert auth_keys.get_key_from_config("openai") == "FAKE-sk-openai-key" + assert auth_keys.get_key_from_config("anthropic") == "FAKE-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 new file mode 100644 index 00000000..18aa577e --- /dev/null +++ b/src/aipass/api/tests/test_caller.py @@ -0,0 +1,91 @@ +# =================== 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 new file mode 100644 index 00000000..df07f3ef --- /dev/null +++ b/src/aipass/api/tests/test_cli_routing.py @@ -0,0 +1,120 @@ +# =================== 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 new file mode 100644 index 00000000..139d3b3b --- /dev/null +++ b/src/aipass/api/tests/test_config_provider.py @@ -0,0 +1,199 @@ +# =================== 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 new file mode 100644 index 00000000..01f6f4e6 --- /dev/null +++ b/src/aipass/api/tests/test_contracts.py @@ -0,0 +1,200 @@ +# =================== 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 new file mode 100644 index 00000000..1ed34a54 --- /dev/null +++ b/src/aipass/api/tests/test_critical_paths.py @@ -0,0 +1,344 @@ +# =================== 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": "sk-or-v1-NOTREAL-test-000000000000000000000000000000000000" + } + } + } + } + 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") + #do not add real api kets here + assert result == "sk-or-v1-NOTREAL-test-000000000000000000000000000000000000" + + 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-NOTREAL-test-00000000000000000000000000\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-NOTREAL-test-00000000000000000000000000" + + @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-NOTREAL-test-000000000000000000000000000000000000" + 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 = "FAKE-sk-or-v1-testkey" + 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 new file mode 100644 index 00000000..87356fed --- /dev/null +++ b/src/aipass/api/tests/test_error_resilience.py @@ -0,0 +1,140 @@ +# =================== 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 new file mode 100644 index 00000000..d3fda8b8 --- /dev/null +++ b/src/aipass/api/tests/test_google_client.py @@ -0,0 +1,1077 @@ +# =================== 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 new file mode 100644 index 00000000..331a082d --- /dev/null +++ b/src/aipass/api/tests/test_init_provisioning.py @@ -0,0 +1,147 @@ +# =================== 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 new file mode 100644 index 00000000..dec65971 --- /dev/null +++ b/src/aipass/api/tests/test_json_handler.py @@ -0,0 +1,471 @@ +# =================== 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 new file mode 100644 index 00000000..d7bc438a --- /dev/null +++ b/src/aipass/api/tests/test_openrouter_client.py @@ -0,0 +1,748 @@ +# =================== 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 = "FAKE-sk-or-testkey" + 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("FAKE-sk-or-testkey") + 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 = "FAKE-sk-or-testkey" + 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 = "FAKE-sk-or-testkey" + 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 = "FAKE-sk-or-testkey" + 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 = "FAKE-sk-or-testkey" + 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 = "FAKE-sk-or-testkey" + 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 = "FAKE-sk-or-testkey" + 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 = "FAKE-sk-or-testkey" + 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-NOTREAL-test12345678" + 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 = "FAKE-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 = "FAKE-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 = "FAKE-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 = "FAKE-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("FAKE-sk-or-testkey") + + 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("FAKE-sk-or-validkey", 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="FAKE-sk-or-validkey", + 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("FAKE-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("FAKE-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 new file mode 100644 index 00000000..b4cb7874 --- /dev/null +++ b/src/aipass/api/tests/test_provision.py @@ -0,0 +1,321 @@ +# =================== 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 new file mode 100644 index 00000000..08b75c36 --- /dev/null +++ b/src/aipass/api/tests/test_tracking.py @@ -0,0 +1,354 @@ +# =================== 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", "FAKE-sk-or-testkey") + + 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 FAKE-sk-or-testkey" 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", "FAKE-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", "FAKE-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", "FAKE-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", "FAKE-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", "FAKE-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", "FAKE-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 new file mode 100644 index 00000000..05a317d1 --- /dev/null +++ b/src/aipass/api/tests/test_usage_tracker.py @@ -0,0 +1,457 @@ +# =================== 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", [])