feat(system): S85: Restore api branch + gitleaks config (merge sync) (#228)

* feat(system): SECURITY: gitleaks config + pre-commit for API key leak prevention

Co-Authored-By: @devpulse <devpulse@aipass>

* feat(system): S84: Restore api branch to repo — normal branch, gitleaks protection, no real keys outside .secrets

Co-Authored-By: @devpulse <devpulse@aipass>

---------

Co-authored-by: @devpulse <devpulse@aipass>
This commit is contained in:
AIPass
2026-04-10 10:22:28 -07:00
committed by GitHub
co-authored by @devpulse
parent 2ebf576e9b
commit 040611d098
66 changed files with 10640 additions and 2 deletions
+1 -2
View File
@@ -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
+66
View File
@@ -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''',
]
+14
View File
@@ -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)
+3
View File
@@ -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.
@@ -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 <gen_id> [caller]` | Track generation usage |
| `stats` | Usage statistics |
| `session` | Session usage summary |
| `caller-usage <caller>` | 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/`
+5
View File
@@ -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.
+14
View File
@@ -0,0 +1,14 @@
__pycache__/
*.pyc
*.pyo
.env
*.egg-info/
.coverage
htmlcov/
.pytest_cache/
.mypy_cache/
dist/
build/
*.log
*.tmp
*.swp
+5
View File
@@ -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.
+104
View File
@@ -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"
}
}
}
+140
View File
@@ -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 <gen_id> # Track API usage metrics
drone @api stats # Display API usage statistics
drone @api session # Show session usage data
drone @api caller-usage <caller> # 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)
+1
View File
@@ -0,0 +1 @@
"""API - External API integrations for AIPass."""
+8
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
# Apps package
+286
View File
@@ -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())
@@ -0,0 +1 @@
# Extensions package - Drop-in extensions for branch functionality
+5
View File
@@ -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.
+77
View File
@@ -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 ("<string>", "<stdin>"):
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.<module> import <function>\n"
f"{'='*60}"
)
_guard_branch_access()
@@ -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"
+109
View File
@@ -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
+305
View File
@@ -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_root>/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"
@@ -0,0 +1,7 @@
"""
Configuration Domain
Handlers for provider configuration management.
Load, validate, and update API provider settings.
"""
__version__ = "1.0.0"
@@ -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
@@ -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
+254
View File
@@ -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)
@@ -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
@@ -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
@@ -0,0 +1 @@
"""JSON Handlers - Universal JSON operations for API branch"""
+250
View File
@@ -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()
@@ -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"
@@ -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()
@@ -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,
}
@@ -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 []
@@ -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 {}
@@ -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"
@@ -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 {}
@@ -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
@@ -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
+5
View File
@@ -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.
+233
View File
@@ -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 <command> [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)
@@ -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)
@@ -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 <prompt> [--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)
@@ -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 <caller>
drone @api stats
drone @api session
drone @api caller-usage <caller>
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 <generation_id> [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)
+5
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
# Plugins package - Pluggable components for branch capabilities
+3
View File
@@ -0,0 +1,3 @@
# Docs
Documentation, design plans, and reference material for the API branch.
+101
View File
@@ -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
+18
View File
@@ -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
+5
View File
@@ -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).
+6
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
# Tests package for api
+157
View File
@@ -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
+696
View File
@@ -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)
+91
View File
@@ -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()
+120
View File
@@ -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
@@ -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}"
+200
View File
@@ -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"
+344
View File
@@ -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
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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)
+471
View File
@@ -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
@@ -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
+321
View File
@@ -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"])
+354
View File
@@ -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
+457
View File
@@ -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", [])