feat(devpulse): API branch reinstate.
Co-Authored-By: @devpulse <devpulse@aipass>
This commit is contained in:
@@ -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/`
|
||||
@@ -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.
|
||||
@@ -0,0 +1,14 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
*.egg-info/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""API - External API integrations for AIPass."""
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
# Apps package
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -0,0 +1,104 @@
|
||||
# =================== 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
|
||||
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
|
||||
env_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write template
|
||||
with open(env_path, 'w', encoding='utf-8') as f:
|
||||
f.write(env_template)
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
@@ -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-",
|
||||
"min_length": 20
|
||||
},
|
||||
"openai": {
|
||||
"prefix": "sk-",
|
||||
"min_length": 20
|
||||
},
|
||||
"anthropic": {
|
||||
"prefix": "sk-ant-",
|
||||
"min_length": 20
|
||||
},
|
||||
# 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
|
||||
@@ -0,0 +1,251 @@
|
||||
# =================== 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.
|
||||
"""
|
||||
|
||||
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."""
|
||||
SECRETS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(CREDS_PATH, "w", encoding="utf-8") as f:
|
||||
f.write(creds.to_json())
|
||||
@@ -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
@@ -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,376 @@
|
||||
# =================== 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 and keys
|
||||
"""
|
||||
return {
|
||||
"cached_clients": len(_client_cache),
|
||||
"max_cache_size": MAX_CACHED_CLIENTS,
|
||||
"cache_keys": list(_client_cache.keys())
|
||||
}
|
||||
@@ -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,131 @@
|
||||
# =================== 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_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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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,301 @@
|
||||
# =================== 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 statistics display workflow"""
|
||||
header("Usage Statistics")
|
||||
console.print()
|
||||
|
||||
# Call handler for session summary
|
||||
stats = aggregation.get_session_summary()
|
||||
|
||||
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)}")
|
||||
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 cleanup.cleanup_old_data(data_path, days):
|
||||
success(f"Cleaned up data 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:
|
||||
error("Cleanup failed")
|
||||
|
||||
|
||||
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,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.
|
||||
@@ -0,0 +1 @@
|
||||
# Plugins package - Pluggable components for branch capabilities
|
||||
@@ -0,0 +1,3 @@
|
||||
# Docs
|
||||
|
||||
Documentation, design plans, and reference material for the API branch.
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
# Tests package for api
|
||||
@@ -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
|
||||
@@ -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 = "sk-test1234567890abcdef"
|
||||
|
||||
result = api_key.handle_command("get-key", ["openrouter"])
|
||||
|
||||
assert result is True
|
||||
mock_keys.get_api_key.assert_called_once_with("openrouter")
|
||||
|
||||
|
||||
@patch(PATCH_CONSOLE)
|
||||
@patch(PATCH_HEADER)
|
||||
@patch(PATCH_SUCCESS)
|
||||
@patch(PATCH_ERROR)
|
||||
@patch(PATCH_JSON_HANDLER)
|
||||
@patch(PATCH_KEYS)
|
||||
def test_handle_command_routes_validate(mock_keys, mock_jh, mock_error, mock_success, mock_header, mock_console):
|
||||
"""validate command should route to validate_key with args."""
|
||||
mock_keys.get_api_key.return_value = "sk-test1234567890abcdef"
|
||||
mock_keys.validate_key.return_value = True
|
||||
|
||||
result = api_key.handle_command("validate", ["openrouter"])
|
||||
|
||||
assert result is True
|
||||
mock_keys.get_api_key.assert_called_once_with("openrouter")
|
||||
mock_keys.validate_key.assert_called_once_with("sk-test1234567890abcdef", "openrouter")
|
||||
|
||||
|
||||
@patch(PATCH_CONSOLE)
|
||||
@patch(PATCH_HEADER)
|
||||
@patch(PATCH_JSON_HANDLER)
|
||||
def test_handle_command_routes_list_providers(mock_jh, mock_header, mock_console):
|
||||
"""list-providers command should route to list_providers."""
|
||||
result = api_key.handle_command("list-providers", [])
|
||||
|
||||
assert result is True
|
||||
mock_header.assert_called_once_with("Available Providers")
|
||||
|
||||
|
||||
@patch(PATCH_CONSOLE)
|
||||
@patch(PATCH_HEADER)
|
||||
@patch(PATCH_SUCCESS)
|
||||
@patch(PATCH_ERROR)
|
||||
@patch(PATCH_JSON_HANDLER)
|
||||
@patch(PATCH_ENV)
|
||||
def test_handle_command_routes_init(mock_env, mock_jh, mock_error, mock_success, mock_header, mock_console):
|
||||
"""init command should route to init_env."""
|
||||
with patch("aipass.api.apps.modules.api_key.Path") as mock_path_cls:
|
||||
mock_env_path = MagicMock()
|
||||
mock_env_path.exists.return_value = True
|
||||
mock_path_cls.home.return_value.__truediv__ = MagicMock(return_value=mock_env_path)
|
||||
# Simpler: just mock the whole Path.home() chain
|
||||
mock_path_cls.home.return_value = MagicMock()
|
||||
mock_path_cls.home.return_value.__truediv__ = MagicMock()
|
||||
mock_home = MagicMock()
|
||||
mock_secrets = MagicMock()
|
||||
mock_aipass = MagicMock()
|
||||
mock_env_path = MagicMock()
|
||||
mock_env_path.exists.return_value = True
|
||||
mock_home.__truediv__ = MagicMock(return_value=mock_secrets)
|
||||
mock_secrets.__truediv__ = MagicMock(return_value=mock_aipass)
|
||||
mock_aipass.__truediv__ = MagicMock(return_value=mock_env_path)
|
||||
mock_path_cls.home.return_value = mock_home
|
||||
|
||||
result = api_key.handle_command("init", [])
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
# =============================================
|
||||
# handle_command — gate tests
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(PATCH_CONSOLE)
|
||||
@patch(PATCH_HEADER)
|
||||
@patch(PATCH_JSON_HANDLER)
|
||||
def test_handle_command_help_gate(mock_jh, mock_header, mock_console):
|
||||
"""--help arg should trigger print_help and return True without logging."""
|
||||
result = api_key.handle_command("get-key", ["--help"])
|
||||
|
||||
assert result is True
|
||||
# Help gate fires before log_operation
|
||||
mock_jh.log_operation.assert_not_called()
|
||||
|
||||
|
||||
@patch(PATCH_CONSOLE)
|
||||
@patch(PATCH_HEADER)
|
||||
@patch(PATCH_JSON_HANDLER)
|
||||
def test_handle_command_help_gate_short_flag(mock_jh, mock_header, mock_console):
|
||||
"""Short -h flag should also trigger help gate."""
|
||||
result = api_key.handle_command("validate", ["-h"])
|
||||
|
||||
assert result is True
|
||||
mock_jh.log_operation.assert_not_called()
|
||||
|
||||
|
||||
@patch(PATCH_CONSOLE)
|
||||
@patch(PATCH_HEADER)
|
||||
@patch(PATCH_SUCCESS)
|
||||
@patch(PATCH_ERROR)
|
||||
@patch(PATCH_JSON_HANDLER)
|
||||
@patch(PATCH_KEYS)
|
||||
def test_handle_command_get_key_no_args_defaults_to_openrouter(
|
||||
mock_keys, mock_jh, mock_error, mock_success, mock_header, mock_console
|
||||
):
|
||||
"""get-key with no args should execute with default provider 'openrouter'."""
|
||||
mock_keys.get_api_key.return_value = "sk-or-test-key-123"
|
||||
|
||||
result = api_key.handle_command("get-key", [])
|
||||
|
||||
assert result is True
|
||||
mock_keys.get_api_key.assert_called_once_with("openrouter")
|
||||
mock_header.assert_called_with("Get API Key - openrouter")
|
||||
|
||||
|
||||
@patch(PATCH_CONSOLE)
|
||||
@patch(PATCH_HEADER)
|
||||
@patch(PATCH_SUCCESS)
|
||||
@patch(PATCH_ERROR)
|
||||
@patch(PATCH_JSON_HANDLER)
|
||||
@patch(PATCH_KEYS)
|
||||
def test_handle_command_validate_no_args_defaults_to_openrouter(
|
||||
mock_keys, mock_jh, mock_error, mock_success, mock_header, mock_console
|
||||
):
|
||||
"""validate with no args should execute with default provider 'openrouter'."""
|
||||
mock_keys.get_api_key.return_value = "sk-or-test-key-123"
|
||||
mock_keys.validate_key.return_value = True
|
||||
|
||||
result = api_key.handle_command("validate", [])
|
||||
|
||||
assert result is True
|
||||
mock_keys.get_api_key.assert_called_once_with("openrouter")
|
||||
mock_header.assert_called_with("Validate API Key - openrouter")
|
||||
|
||||
|
||||
@patch(PATCH_CONSOLE)
|
||||
@patch(PATCH_HEADER)
|
||||
@patch(PATCH_JSON_HANDLER)
|
||||
def test_handle_command_list_providers_standalone(mock_jh, mock_header, mock_console):
|
||||
"""list-providers should work without args and not hit introspection gate."""
|
||||
result = api_key.handle_command("list-providers", [])
|
||||
|
||||
assert result is True
|
||||
# Should call "Available Providers" header, not introspection
|
||||
mock_header.assert_called_with("Available Providers")
|
||||
|
||||
|
||||
# =============================================
|
||||
# get_key tests
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(PATCH_CONSOLE)
|
||||
@patch(PATCH_HEADER)
|
||||
@patch(PATCH_SUCCESS)
|
||||
@patch(PATCH_ERROR)
|
||||
@patch(PATCH_KEYS)
|
||||
def test_get_key_success(mock_keys, mock_error, mock_success, mock_header, mock_console):
|
||||
"""Successful key retrieval should call success()."""
|
||||
mock_keys.get_api_key.return_value = "sk-test1234567890abcdef"
|
||||
|
||||
api_key.get_key(["openrouter"])
|
||||
|
||||
mock_keys.get_api_key.assert_called_once_with("openrouter")
|
||||
mock_success.assert_called_once_with("API key retrieved for openrouter")
|
||||
mock_error.assert_not_called()
|
||||
|
||||
|
||||
@patch(PATCH_CONSOLE)
|
||||
@patch(PATCH_HEADER)
|
||||
@patch(PATCH_SUCCESS)
|
||||
@patch(PATCH_ERROR)
|
||||
@patch(PATCH_KEYS)
|
||||
def test_get_key_failure(mock_keys, mock_error, mock_success, mock_header, mock_console):
|
||||
"""Failed key retrieval (None returned) should call error()."""
|
||||
mock_keys.get_api_key.return_value = None
|
||||
|
||||
api_key.get_key(["openrouter"])
|
||||
|
||||
mock_keys.get_api_key.assert_called_once_with("openrouter")
|
||||
mock_error.assert_called_once_with("Failed to retrieve API key for openrouter")
|
||||
mock_success.assert_not_called()
|
||||
|
||||
|
||||
@patch(PATCH_CONSOLE)
|
||||
@patch(PATCH_HEADER)
|
||||
@patch(PATCH_SUCCESS)
|
||||
@patch(PATCH_ERROR)
|
||||
@patch(PATCH_KEYS)
|
||||
def test_get_key_default_provider(mock_keys, mock_error, mock_success, mock_header, mock_console):
|
||||
"""Empty args list should default to 'openrouter' provider."""
|
||||
mock_keys.get_api_key.return_value = "sk-test1234567890abcdef"
|
||||
|
||||
api_key.get_key([])
|
||||
|
||||
mock_keys.get_api_key.assert_called_once_with("openrouter")
|
||||
|
||||
|
||||
# =============================================
|
||||
# validate_key tests
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(PATCH_CONSOLE)
|
||||
@patch(PATCH_HEADER)
|
||||
@patch(PATCH_SUCCESS)
|
||||
@patch(PATCH_ERROR)
|
||||
@patch(PATCH_KEYS)
|
||||
def test_validate_key_valid(mock_keys, mock_error, mock_success, mock_header, mock_console):
|
||||
"""Valid key should call success()."""
|
||||
mock_keys.get_api_key.return_value = "sk-test1234567890abcdef"
|
||||
mock_keys.validate_key.return_value = True
|
||||
|
||||
api_key.validate_key(["openrouter"])
|
||||
|
||||
mock_keys.get_api_key.assert_called_once_with("openrouter")
|
||||
mock_keys.validate_key.assert_called_once_with("sk-test1234567890abcdef", "openrouter")
|
||||
mock_success.assert_called_once_with("API key for openrouter is valid")
|
||||
mock_error.assert_not_called()
|
||||
|
||||
|
||||
@patch(PATCH_CONSOLE)
|
||||
@patch(PATCH_HEADER)
|
||||
@patch(PATCH_SUCCESS)
|
||||
@patch(PATCH_ERROR)
|
||||
@patch(PATCH_KEYS)
|
||||
def test_validate_key_no_key(mock_keys, mock_error, mock_success, mock_header, mock_console):
|
||||
"""No key found should call error() and skip validation."""
|
||||
mock_keys.get_api_key.return_value = None
|
||||
|
||||
api_key.validate_key(["openrouter"])
|
||||
|
||||
mock_keys.get_api_key.assert_called_once_with("openrouter")
|
||||
mock_keys.validate_key.assert_not_called()
|
||||
mock_error.assert_called_once_with("No API key found for openrouter")
|
||||
mock_success.assert_not_called()
|
||||
|
||||
|
||||
@patch(PATCH_CONSOLE)
|
||||
@patch(PATCH_HEADER)
|
||||
@patch(PATCH_SUCCESS)
|
||||
@patch(PATCH_ERROR)
|
||||
@patch(PATCH_KEYS)
|
||||
def test_validate_key_invalid(mock_keys, mock_error, mock_success, mock_header, mock_console):
|
||||
"""Invalid key should call error()."""
|
||||
mock_keys.get_api_key.return_value = "sk-test1234567890abcdef"
|
||||
mock_keys.validate_key.return_value = False
|
||||
|
||||
api_key.validate_key(["openrouter"])
|
||||
|
||||
mock_keys.get_api_key.assert_called_once_with("openrouter")
|
||||
mock_keys.validate_key.assert_called_once_with("sk-test1234567890abcdef", "openrouter")
|
||||
mock_error.assert_called_once_with("API key for openrouter is invalid")
|
||||
mock_success.assert_not_called()
|
||||
|
||||
|
||||
# =============================================
|
||||
# init_env tests
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(PATCH_CONSOLE)
|
||||
@patch(PATCH_HEADER)
|
||||
@patch(PATCH_SUCCESS)
|
||||
@patch(PATCH_ERROR)
|
||||
@patch(PATCH_ENV)
|
||||
def test_init_env_already_exists(mock_env, mock_error, mock_success, mock_header, mock_console):
|
||||
"""Existing env file should call success() and skip creation."""
|
||||
with patch("aipass.api.apps.modules.api_key.Path") as mock_path_cls:
|
||||
mock_env_path = MagicMock()
|
||||
mock_env_path.exists.return_value = True
|
||||
|
||||
mock_home = MagicMock()
|
||||
mock_secrets = MagicMock()
|
||||
mock_aipass_dir = MagicMock()
|
||||
mock_home.__truediv__ = MagicMock(return_value=mock_secrets)
|
||||
mock_secrets.__truediv__ = MagicMock(return_value=mock_aipass_dir)
|
||||
mock_aipass_dir.__truediv__ = MagicMock(return_value=mock_env_path)
|
||||
mock_path_cls.home.return_value = mock_home
|
||||
|
||||
api_key.init_env()
|
||||
|
||||
mock_success.assert_called_once()
|
||||
assert "already exists" in mock_success.call_args[0][0]
|
||||
mock_env.create_env_template.assert_not_called()
|
||||
mock_error.assert_not_called()
|
||||
|
||||
|
||||
@patch(PATCH_CONSOLE)
|
||||
@patch(PATCH_HEADER)
|
||||
@patch(PATCH_SUCCESS)
|
||||
@patch(PATCH_ERROR)
|
||||
@patch(PATCH_ENV)
|
||||
def test_init_env_creates_template(mock_env, mock_error, mock_success, mock_header, mock_console):
|
||||
"""Missing env file should call create_env_template and success() on True."""
|
||||
mock_env.create_env_template.return_value = True
|
||||
|
||||
with patch("aipass.api.apps.modules.api_key.Path") as mock_path_cls:
|
||||
mock_env_path = MagicMock()
|
||||
mock_env_path.exists.return_value = False
|
||||
|
||||
mock_home = MagicMock()
|
||||
mock_secrets = MagicMock()
|
||||
mock_aipass_dir = MagicMock()
|
||||
mock_home.__truediv__ = MagicMock(return_value=mock_secrets)
|
||||
mock_secrets.__truediv__ = MagicMock(return_value=mock_aipass_dir)
|
||||
mock_aipass_dir.__truediv__ = MagicMock(return_value=mock_env_path)
|
||||
mock_path_cls.home.return_value = mock_home
|
||||
|
||||
api_key.init_env()
|
||||
|
||||
mock_env.create_env_template.assert_called_once()
|
||||
mock_success.assert_called_once()
|
||||
assert "template created" in mock_success.call_args[0][0]
|
||||
mock_error.assert_not_called()
|
||||
|
||||
|
||||
@patch(PATCH_CONSOLE)
|
||||
@patch(PATCH_HEADER)
|
||||
@patch(PATCH_SUCCESS)
|
||||
@patch(PATCH_ERROR)
|
||||
@patch(PATCH_ENV)
|
||||
def test_init_env_create_failure(mock_env, mock_error, mock_success, mock_header, mock_console):
|
||||
"""Failed template creation should call error()."""
|
||||
mock_env.create_env_template.return_value = False
|
||||
|
||||
with patch("aipass.api.apps.modules.api_key.Path") as mock_path_cls:
|
||||
mock_env_path = MagicMock()
|
||||
mock_env_path.exists.return_value = False
|
||||
|
||||
mock_home = MagicMock()
|
||||
mock_secrets = MagicMock()
|
||||
mock_aipass_dir = MagicMock()
|
||||
mock_home.__truediv__ = MagicMock(return_value=mock_secrets)
|
||||
mock_secrets.__truediv__ = MagicMock(return_value=mock_aipass_dir)
|
||||
mock_aipass_dir.__truediv__ = MagicMock(return_value=mock_env_path)
|
||||
mock_path_cls.home.return_value = mock_home
|
||||
|
||||
api_key.init_env()
|
||||
|
||||
mock_env.create_env_template.assert_called_once()
|
||||
mock_error.assert_called_once_with("Failed to create environment template")
|
||||
mock_success.assert_not_called()
|
||||
|
||||
|
||||
# =============================================
|
||||
# list_providers tests
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(PATCH_CONSOLE)
|
||||
@patch(PATCH_HEADER)
|
||||
def test_list_providers_prints_header(mock_header, mock_console):
|
||||
"""list_providers should print 'Available Providers' header."""
|
||||
api_key.list_providers()
|
||||
|
||||
mock_header.assert_called_once_with("Available Providers")
|
||||
|
||||
|
||||
@patch(PATCH_CONSOLE)
|
||||
@patch(PATCH_HEADER)
|
||||
def test_list_providers_prints_openrouter(mock_header, mock_console):
|
||||
"""list_providers should print openrouter as an available provider."""
|
||||
api_key.list_providers()
|
||||
|
||||
# Check that console.print was called with the openrouter provider line
|
||||
print_calls = [str(c) for c in mock_console.print.call_args_list]
|
||||
found = any("openrouter" in c for c in print_calls)
|
||||
assert found, f"Expected 'openrouter' in console output, got: {print_calls}"
|
||||
|
||||
|
||||
# =============================================
|
||||
# log_operation tests
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(PATCH_CONSOLE)
|
||||
@patch(PATCH_HEADER)
|
||||
@patch(PATCH_JSON_HANDLER)
|
||||
def test_handle_command_logs_operation(mock_jh, mock_header, mock_console):
|
||||
"""Valid command should call json_handler.log_operation with command context."""
|
||||
api_key.handle_command("list-providers", [])
|
||||
|
||||
mock_jh.log_operation.assert_called_once_with(
|
||||
"api_key_list-providers",
|
||||
{"command": "list-providers"},
|
||||
)
|
||||
|
||||
|
||||
@patch(PATCH_CONSOLE)
|
||||
@patch(PATCH_HEADER)
|
||||
@patch(PATCH_SUCCESS)
|
||||
@patch(PATCH_ERROR)
|
||||
@patch(PATCH_JSON_HANDLER)
|
||||
@patch(PATCH_KEYS)
|
||||
def test_handle_command_logs_operation_for_get_key(mock_keys, mock_jh, mock_error, mock_success, mock_header, mock_console):
|
||||
"""get-key command should log api_key_get-key operation."""
|
||||
mock_keys.get_api_key.return_value = "sk-test1234567890abcdef"
|
||||
|
||||
api_key.handle_command("get-key", ["openrouter"])
|
||||
|
||||
mock_jh.log_operation.assert_called_once_with(
|
||||
"api_key_get-key",
|
||||
{"command": "get-key"},
|
||||
)
|
||||
|
||||
|
||||
# =============================================
|
||||
# print_introspection tests
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(PATCH_CONSOLE)
|
||||
@patch(PATCH_HEADER)
|
||||
def test_print_introspection_shows_header(mock_header, mock_console):
|
||||
"""print_introspection should display the module header."""
|
||||
api_key.print_introspection()
|
||||
|
||||
mock_header.assert_called_once_with("API Key Module Introspection")
|
||||
|
||||
|
||||
@patch(PATCH_CONSOLE)
|
||||
@patch(PATCH_HEADER)
|
||||
def test_print_introspection_shows_handlers(mock_header, mock_console):
|
||||
"""print_introspection should list connected handlers."""
|
||||
api_key.print_introspection()
|
||||
|
||||
print_calls = [str(c) for c in mock_console.print.call_args_list]
|
||||
found_keys = any("auth.keys" in c for c in print_calls)
|
||||
found_env = any("auth.env" in c for c in print_calls)
|
||||
found_provider = any("config.provider" in c for c in print_calls)
|
||||
assert found_keys, "Expected auth.keys handler listed"
|
||||
assert found_env, "Expected auth.env handler listed"
|
||||
assert found_provider, "Expected config.provider handler listed"
|
||||
|
||||
|
||||
# =============================================
|
||||
# handle_command — exception propagation
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(PATCH_KEYS)
|
||||
@patch(PATCH_JSON_HANDLER)
|
||||
@patch(PATCH_CONSOLE)
|
||||
@patch(PATCH_HEADER)
|
||||
def test_handle_command_propagates_exception(mock_header, mock_console, mock_jh, mock_keys):
|
||||
"""handle_command re-raises exceptions from downstream handlers."""
|
||||
mock_keys.get_api_key.side_effect = RuntimeError("handler failed")
|
||||
|
||||
with pytest.raises(RuntimeError, match="handler failed"):
|
||||
api_key.handle_command("get-key", ["openrouter"])
|
||||
|
||||
|
||||
# =============================================
|
||||
# get_key_from_config tests (auth.keys handler)
|
||||
# =============================================
|
||||
|
||||
from aipass.api.apps.handlers.auth import keys as auth_keys
|
||||
|
||||
|
||||
class TestGetKeyFromConfig:
|
||||
"""Tests for auth.keys.get_key_from_config()."""
|
||||
|
||||
def test_returns_key_from_valid_config(self, tmp_path, monkeypatch):
|
||||
"""Valid config JSON should return the API key string."""
|
||||
import json
|
||||
|
||||
config_dir = tmp_path / "api_json"
|
||||
config_dir.mkdir()
|
||||
config_file = config_dir / "api_connect_config.json"
|
||||
config_file.write_text(json.dumps({
|
||||
"config": {
|
||||
"providers": {
|
||||
"openrouter": {"api_key": "sk-or-test-key-abc123"}
|
||||
}
|
||||
}
|
||||
}), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(auth_keys, "API_JSON_DIR", config_dir)
|
||||
|
||||
result = auth_keys.get_key_from_config("openrouter")
|
||||
assert result == "sk-or-test-key-abc123"
|
||||
|
||||
def test_returns_none_when_config_file_missing(self, tmp_path, monkeypatch):
|
||||
"""Missing config file should return None."""
|
||||
config_dir = tmp_path / "api_json"
|
||||
config_dir.mkdir()
|
||||
monkeypatch.setattr(auth_keys, "API_JSON_DIR", config_dir)
|
||||
|
||||
result = auth_keys.get_key_from_config("openrouter")
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_when_provider_not_in_config(self, tmp_path, monkeypatch):
|
||||
"""Config exists but provider not listed should return None."""
|
||||
import json
|
||||
|
||||
config_dir = tmp_path / "api_json"
|
||||
config_dir.mkdir()
|
||||
config_file = config_dir / "api_connect_config.json"
|
||||
config_file.write_text(json.dumps({
|
||||
"config": {
|
||||
"providers": {
|
||||
"openai": {"api_key": "sk-openai-key-123"}
|
||||
}
|
||||
}
|
||||
}), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(auth_keys, "API_JSON_DIR", config_dir)
|
||||
|
||||
result = auth_keys.get_key_from_config("openrouter")
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_when_api_key_empty(self, tmp_path, monkeypatch):
|
||||
"""Provider present but api_key is empty string should return None."""
|
||||
import json
|
||||
|
||||
config_dir = tmp_path / "api_json"
|
||||
config_dir.mkdir()
|
||||
config_file = config_dir / "api_connect_config.json"
|
||||
config_file.write_text(json.dumps({
|
||||
"config": {
|
||||
"providers": {
|
||||
"openrouter": {"api_key": ""}
|
||||
}
|
||||
}
|
||||
}), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(auth_keys, "API_JSON_DIR", config_dir)
|
||||
|
||||
result = auth_keys.get_key_from_config("openrouter")
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_when_config_missing_config_key(self, tmp_path, monkeypatch):
|
||||
"""JSON file without 'config' top-level key should return None."""
|
||||
import json
|
||||
|
||||
config_dir = tmp_path / "api_json"
|
||||
config_dir.mkdir()
|
||||
config_file = config_dir / "api_connect_config.json"
|
||||
config_file.write_text(json.dumps({"other": "data"}), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(auth_keys, "API_JSON_DIR", config_dir)
|
||||
|
||||
result = auth_keys.get_key_from_config("openrouter")
|
||||
assert result is None
|
||||
|
||||
@patch("aipass.api.apps.handlers.auth.keys.logger")
|
||||
def test_returns_none_on_invalid_json(self, mock_logger, tmp_path, monkeypatch):
|
||||
"""Malformed JSON should return None and log error."""
|
||||
config_dir = tmp_path / "api_json"
|
||||
config_dir.mkdir()
|
||||
config_file = config_dir / "api_connect_config.json"
|
||||
config_file.write_text("not valid json {{{", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(auth_keys, "API_JSON_DIR", config_dir)
|
||||
|
||||
result = auth_keys.get_key_from_config("openrouter")
|
||||
assert result is None
|
||||
mock_logger.error.assert_called_once()
|
||||
|
||||
def test_reads_different_providers(self, tmp_path, monkeypatch):
|
||||
"""Should retrieve keys for different provider names."""
|
||||
import json
|
||||
|
||||
config_dir = tmp_path / "api_json"
|
||||
config_dir.mkdir()
|
||||
config_file = config_dir / "api_connect_config.json"
|
||||
config_file.write_text(json.dumps({
|
||||
"config": {
|
||||
"providers": {
|
||||
"openrouter": {"api_key": "sk-or-key"},
|
||||
"openai": {"api_key": "sk-openai-key"},
|
||||
"anthropic": {"api_key": "sk-ant-key"},
|
||||
}
|
||||
}
|
||||
}), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(auth_keys, "API_JSON_DIR", config_dir)
|
||||
|
||||
assert auth_keys.get_key_from_config("openrouter") == "sk-or-key"
|
||||
assert auth_keys.get_key_from_config("openai") == "sk-openai-key"
|
||||
assert auth_keys.get_key_from_config("anthropic") == "sk-ant-key"
|
||||
|
||||
|
||||
# =============================================
|
||||
# get_validation_rules tests (auth.keys handler)
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestGetValidationRulesAuthKeys:
|
||||
"""Tests for auth.keys.get_validation_rules()."""
|
||||
|
||||
def test_openrouter_rules(self):
|
||||
"""openrouter should have prefix 'sk-or-' and min_length 20."""
|
||||
rules = auth_keys.get_validation_rules("openrouter")
|
||||
assert rules["prefix"] == "sk-or-"
|
||||
assert rules["min_length"] == 20
|
||||
|
||||
def test_openai_rules(self):
|
||||
"""openai should have prefix 'sk-' and min_length 20."""
|
||||
rules = auth_keys.get_validation_rules("openai")
|
||||
assert rules["prefix"] == "sk-"
|
||||
assert rules["min_length"] == 20
|
||||
|
||||
def test_anthropic_rules(self):
|
||||
"""anthropic should have prefix 'sk-ant-' and min_length 20."""
|
||||
rules = auth_keys.get_validation_rules("anthropic")
|
||||
assert rules["prefix"] == "sk-ant-"
|
||||
assert rules["min_length"] == 20
|
||||
|
||||
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)
|
||||
@@ -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()
|
||||
@@ -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}"
|
||||
@@ -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"
|
||||
@@ -0,0 +1,343 @@
|
||||
# =================== 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-valid-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
|
||||
|
||||
result = get_api_key("openrouter")
|
||||
|
||||
assert result == "sk-or-v1-valid-key-that-is-long-enough"
|
||||
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-secret-key-long-enough-here\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-secret-key-long-enough-here"
|
||||
|
||||
@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-abcdefghijklmnopqrst"
|
||||
assert validate_key(key, "openrouter") is True
|
||||
|
||||
def test_key_too_short(self):
|
||||
"""Key shorter than min_length fails validation."""
|
||||
from aipass.api.apps.handlers.auth.keys import validate_key
|
||||
|
||||
key = "sk-or-v1-short"
|
||||
assert len(key) < 20
|
||||
assert validate_key(key, "openrouter") is False
|
||||
|
||||
def test_wrong_prefix(self):
|
||||
"""Key with wrong prefix for provider fails validation."""
|
||||
from aipass.api.apps.handlers.auth.keys import validate_key
|
||||
|
||||
key = "sk-wrong-prefix-but-long-enough-to-pass-length"
|
||||
assert validate_key(key, "openrouter") is False
|
||||
|
||||
def test_empty_key(self):
|
||||
"""Empty string key fails validation."""
|
||||
from aipass.api.apps.handlers.auth.keys import validate_key
|
||||
|
||||
assert validate_key("", "openrouter") is False
|
||||
|
||||
def test_none_key(self):
|
||||
"""None key fails validation."""
|
||||
from aipass.api.apps.handlers.auth.keys import validate_key
|
||||
|
||||
assert validate_key(None, "openrouter") is False # type: ignore[arg-type]
|
||||
|
||||
def test_generic_provider_no_prefix_required(self):
|
||||
"""Generic provider only checks min_length, no prefix required."""
|
||||
from aipass.api.apps.handlers.auth.keys import validate_key
|
||||
|
||||
key = "any-key-that-is-long-enough"
|
||||
assert validate_key(key, "unknown_provider") is True
|
||||
|
||||
def test_generic_provider_too_short(self):
|
||||
"""Generic provider rejects keys shorter than 10 chars."""
|
||||
from aipass.api.apps.handlers.auth.keys import validate_key
|
||||
|
||||
assert validate_key("short", "unknown_provider") is False
|
||||
|
||||
|
||||
# =============================================
|
||||
# 3. get_response() tests
|
||||
# =============================================
|
||||
|
||||
|
||||
MODULE = "aipass.api.apps.handlers.openrouter.client"
|
||||
|
||||
|
||||
class TestGetResponse:
|
||||
"""Tests for get_response() — main API call orchestrator."""
|
||||
|
||||
@patch(f"{MODULE}.track_usage")
|
||||
@patch(f"{MODULE}.extract_response")
|
||||
@patch(f"{MODULE}.make_api_request")
|
||||
@patch(f"{MODULE}.get_cached_client")
|
||||
@patch(f"{MODULE}.get_api_key")
|
||||
@patch(f"{MODULE}.ensure_caller_config")
|
||||
@patch(f"{MODULE}.get_caller_info")
|
||||
def test_successful_call(
|
||||
self,
|
||||
mock_caller_info,
|
||||
mock_ensure,
|
||||
mock_get_key,
|
||||
mock_get_client,
|
||||
mock_make_req,
|
||||
mock_extract,
|
||||
mock_track,
|
||||
):
|
||||
"""Full successful pipeline: detect caller, get key, make request, extract, track."""
|
||||
from aipass.api.apps.handlers.openrouter.client import get_response
|
||||
|
||||
mock_caller_info.return_value = {"caller_name": "test-branch"}
|
||||
mock_get_key.return_value = "sk-or-v1-test-key-long-enough"
|
||||
mock_get_client.return_value = MagicMock()
|
||||
mock_make_req.return_value = MagicMock()
|
||||
mock_extract.return_value = {
|
||||
"content": "Hello, world!",
|
||||
"id": "gen-abc123",
|
||||
"model": "anthropic/claude-3.5-sonnet",
|
||||
}
|
||||
|
||||
result = get_response(
|
||||
"What is Python?", model="anthropic/claude-3.5-sonnet"
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["content"] == "Hello, world!"
|
||||
assert result["id"] == "gen-abc123"
|
||||
mock_track.assert_called_once()
|
||||
|
||||
@patch(f"{MODULE}.get_api_key")
|
||||
@patch(f"{MODULE}.ensure_caller_config")
|
||||
@patch(f"{MODULE}.get_caller_info")
|
||||
def test_no_model_returns_none(
|
||||
self, mock_caller_info, mock_ensure, mock_get_key
|
||||
):
|
||||
"""Missing model parameter returns None without making API call."""
|
||||
from aipass.api.apps.handlers.openrouter.client import get_response
|
||||
|
||||
mock_caller_info.return_value = {"caller_name": "test"}
|
||||
|
||||
result = get_response("What is Python?", model=None)
|
||||
|
||||
assert result is None
|
||||
mock_get_key.assert_not_called()
|
||||
|
||||
@patch(f"{MODULE}.get_api_key")
|
||||
@patch(f"{MODULE}.ensure_caller_config")
|
||||
@patch(f"{MODULE}.get_caller_info")
|
||||
def test_no_api_key_returns_none(
|
||||
self, mock_caller_info, mock_ensure, mock_get_key
|
||||
):
|
||||
"""No API key available returns None."""
|
||||
from aipass.api.apps.handlers.openrouter.client import get_response
|
||||
|
||||
mock_caller_info.return_value = {"caller_name": "test"}
|
||||
mock_get_key.return_value = None
|
||||
|
||||
result = get_response(
|
||||
"What is Python?", model="anthropic/claude-3.5-sonnet"
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
# =============================================
|
||||
# 4. extract_response() tests
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestExtractResponse:
|
||||
"""Tests for extract_response() — response content extraction from OpenAI objects."""
|
||||
|
||||
def test_valid_response(self):
|
||||
"""Extracts content, id, and model from a well-formed response."""
|
||||
from aipass.api.apps.handlers.openrouter.client import extract_response
|
||||
|
||||
choice = MagicMock()
|
||||
choice.message.content = "The answer is 42."
|
||||
choice.finish_reason = "stop"
|
||||
|
||||
response = MagicMock()
|
||||
response.choices = [choice]
|
||||
response.id = "gen-xyz789"
|
||||
response.model = "anthropic/claude-3.5-sonnet"
|
||||
|
||||
result = extract_response(response)
|
||||
|
||||
assert result is not None
|
||||
assert result["content"] == "The answer is 42."
|
||||
assert result["id"] == "gen-xyz789"
|
||||
assert result["model"] == "anthropic/claude-3.5-sonnet"
|
||||
|
||||
def test_none_response(self):
|
||||
"""None response returns None."""
|
||||
from aipass.api.apps.handlers.openrouter.client import extract_response
|
||||
|
||||
assert extract_response(None) is None
|
||||
|
||||
def test_response_no_choices(self):
|
||||
"""Response with empty choices list returns None."""
|
||||
from aipass.api.apps.handlers.openrouter.client import extract_response
|
||||
|
||||
response = MagicMock()
|
||||
response.choices = []
|
||||
|
||||
assert extract_response(response) is None
|
||||
|
||||
def test_response_no_content(self):
|
||||
"""Response with choice but no content returns None."""
|
||||
from aipass.api.apps.handlers.openrouter.client import extract_response
|
||||
|
||||
choice = MagicMock()
|
||||
choice.message.content = None
|
||||
|
||||
response = MagicMock()
|
||||
response.choices = [choice]
|
||||
response.id = "gen-empty"
|
||||
response.model = "test/model"
|
||||
|
||||
assert extract_response(response) is None
|
||||
|
||||
def test_response_missing_message(self):
|
||||
"""Response choice without message attribute returns None."""
|
||||
from aipass.api.apps.handlers.openrouter.client import extract_response
|
||||
|
||||
choice = MagicMock(spec=[]) # No attributes at all
|
||||
response = MagicMock()
|
||||
response.choices = [choice]
|
||||
|
||||
assert extract_response(response) is None
|
||||
@@ -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)
|
||||
@@ -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 = "sk-or-test-key"
|
||||
mock_models.fetch_models_from_api.return_value = [{"id": "m1"}, {"id": "m2"}, {"id": "m3"}]
|
||||
|
||||
openrouter_client.test_connection()
|
||||
|
||||
mock_models.fetch_models_from_api.assert_called_once_with("sk-or-test-key")
|
||||
mock_success.assert_called_once()
|
||||
assert "3 models" in mock_success.call_args[0][0]
|
||||
|
||||
|
||||
@patch(f"{_MOD}.error")
|
||||
@patch(f"{_MOD}.success")
|
||||
@patch(f"{_MOD}.models")
|
||||
@patch(f"{_MOD}.keys")
|
||||
@patch(f"{_MOD}.header")
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_test_connection_success_no_error(mock_console, mock_header, mock_keys, mock_models, mock_success, mock_error):
|
||||
"""Successful connection must not call error()."""
|
||||
from aipass.api.apps.modules import openrouter_client
|
||||
|
||||
mock_keys.get_api_key.return_value = "sk-or-test-key"
|
||||
mock_models.fetch_models_from_api.return_value = [{"id": "m1"}]
|
||||
|
||||
openrouter_client.test_connection()
|
||||
|
||||
mock_error.assert_not_called()
|
||||
|
||||
|
||||
@patch(f"{_MOD}.error")
|
||||
@patch(f"{_MOD}.keys")
|
||||
@patch(f"{_MOD}.header")
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_test_connection_no_key(mock_console, mock_header, mock_keys, mock_error):
|
||||
"""Missing API key triggers error with diagnosis."""
|
||||
from aipass.api.apps.modules import openrouter_client
|
||||
|
||||
mock_keys.get_api_key.return_value = None
|
||||
mock_keys.diagnose_key.return_value = "No key found in env"
|
||||
|
||||
openrouter_client.test_connection()
|
||||
|
||||
mock_keys.diagnose_key.assert_called_once_with("openrouter")
|
||||
mock_error.assert_called_once_with("No key found in env")
|
||||
|
||||
|
||||
@patch(f"{_MOD}.success")
|
||||
@patch(f"{_MOD}.error")
|
||||
@patch(f"{_MOD}.keys")
|
||||
@patch(f"{_MOD}.header")
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_test_connection_no_key_no_success(mock_console, mock_header, mock_keys, mock_error, mock_success):
|
||||
"""Missing API key path must not call success()."""
|
||||
from aipass.api.apps.modules import openrouter_client
|
||||
|
||||
mock_keys.get_api_key.return_value = None
|
||||
mock_keys.diagnose_key.return_value = "No key found in env"
|
||||
|
||||
openrouter_client.test_connection()
|
||||
|
||||
mock_success.assert_not_called()
|
||||
|
||||
|
||||
@patch(f"{_MOD}.success")
|
||||
@patch(f"{_MOD}.error")
|
||||
@patch(f"{_MOD}.models")
|
||||
@patch(f"{_MOD}.keys")
|
||||
@patch(f"{_MOD}.header")
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_test_connection_api_failure_no_success(mock_console, mock_header, mock_keys, mock_models, mock_error, mock_success):
|
||||
"""API failure path must not call success()."""
|
||||
from aipass.api.apps.modules import openrouter_client
|
||||
|
||||
mock_keys.get_api_key.return_value = "sk-or-test-key"
|
||||
mock_models.fetch_models_from_api.return_value = None
|
||||
|
||||
openrouter_client.test_connection()
|
||||
|
||||
mock_success.assert_not_called()
|
||||
|
||||
|
||||
@patch(f"{_MOD}.error")
|
||||
@patch(f"{_MOD}.models")
|
||||
@patch(f"{_MOD}.keys")
|
||||
@patch(f"{_MOD}.header")
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_test_connection_api_failure(mock_console, mock_header, mock_keys, mock_models, mock_error):
|
||||
"""API returning None triggers connection-failed error."""
|
||||
from aipass.api.apps.modules import openrouter_client
|
||||
|
||||
mock_keys.get_api_key.return_value = "sk-or-test-key"
|
||||
mock_models.fetch_models_from_api.return_value = None
|
||||
|
||||
openrouter_client.test_connection()
|
||||
|
||||
mock_error.assert_called_once()
|
||||
assert "failed" in mock_error.call_args[0][0].lower()
|
||||
|
||||
|
||||
# =============================================
|
||||
# list_models
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{_MOD}.success")
|
||||
@patch(f"{_MOD}.models")
|
||||
@patch(f"{_MOD}.keys")
|
||||
@patch(f"{_MOD}.header")
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_list_models_success(mock_console, mock_header, mock_keys, mock_models, mock_success):
|
||||
"""Successful model listing prints success and table rows."""
|
||||
from aipass.api.apps.modules import openrouter_client
|
||||
|
||||
mock_keys.get_api_key.return_value = "sk-or-test-key"
|
||||
fake_models = [
|
||||
{
|
||||
"id": f"provider/model-{i}",
|
||||
"context_length": 128000,
|
||||
"pricing": {"prompt": "0.001", "completion": "0.002"},
|
||||
}
|
||||
for i in range(3)
|
||||
]
|
||||
mock_models.fetch_models_from_api.return_value = fake_models
|
||||
|
||||
openrouter_client.list_models([])
|
||||
|
||||
mock_success.assert_called_once()
|
||||
assert "3 models" in mock_success.call_args[0][0]
|
||||
# Header row + separator + 3 data rows = at least 5 console.print calls after header
|
||||
assert mock_console.print.call_count >= 5
|
||||
|
||||
|
||||
@patch(f"{_MOD}.error")
|
||||
@patch(f"{_MOD}.success")
|
||||
@patch(f"{_MOD}.models")
|
||||
@patch(f"{_MOD}.keys")
|
||||
@patch(f"{_MOD}.header")
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_list_models_success_no_error(mock_console, mock_header, mock_keys, mock_models, mock_success, mock_error):
|
||||
"""Successful model listing must not call error()."""
|
||||
from aipass.api.apps.modules import openrouter_client
|
||||
|
||||
mock_keys.get_api_key.return_value = "sk-or-test-key"
|
||||
mock_models.fetch_models_from_api.return_value = [
|
||||
{"id": "p/m", "context_length": 4096, "pricing": {"prompt": "0", "completion": "0"}}
|
||||
]
|
||||
|
||||
openrouter_client.list_models([])
|
||||
|
||||
mock_error.assert_not_called()
|
||||
|
||||
|
||||
@patch(f"{_MOD}.success")
|
||||
@patch(f"{_MOD}.error")
|
||||
@patch(f"{_MOD}.keys")
|
||||
@patch(f"{_MOD}.header")
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_list_models_no_key_no_success(mock_console, mock_header, mock_keys, mock_error, mock_success):
|
||||
"""Missing API key on list_models must not call success()."""
|
||||
from aipass.api.apps.modules import openrouter_client
|
||||
|
||||
mock_keys.get_api_key.return_value = None
|
||||
mock_keys.diagnose_key.return_value = "Key not set"
|
||||
|
||||
openrouter_client.list_models([])
|
||||
|
||||
mock_success.assert_not_called()
|
||||
|
||||
|
||||
@patch(f"{_MOD}.error")
|
||||
@patch(f"{_MOD}.keys")
|
||||
@patch(f"{_MOD}.header")
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_list_models_no_key(mock_console, mock_header, mock_keys, mock_error):
|
||||
"""Missing API key triggers error with diagnosis."""
|
||||
from aipass.api.apps.modules import openrouter_client
|
||||
|
||||
mock_keys.get_api_key.return_value = None
|
||||
mock_keys.diagnose_key.return_value = "Key not set"
|
||||
|
||||
openrouter_client.list_models([])
|
||||
|
||||
mock_error.assert_called_once_with("Key not set")
|
||||
|
||||
|
||||
@patch(f"{_MOD}.success")
|
||||
@patch(f"{_MOD}.models")
|
||||
@patch(f"{_MOD}.keys")
|
||||
@patch(f"{_MOD}.header")
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_list_models_limits_to_10(mock_console, mock_header, mock_keys, mock_models, mock_success):
|
||||
"""Without --all flag, only 10 models are displayed from a larger list."""
|
||||
from aipass.api.apps.modules import openrouter_client
|
||||
|
||||
mock_keys.get_api_key.return_value = "sk-or-test-key"
|
||||
fake_models = [
|
||||
{
|
||||
"id": f"provider/model-{i}",
|
||||
"context_length": 4096,
|
||||
"pricing": {"prompt": "0", "completion": "0"},
|
||||
}
|
||||
for i in range(25)
|
||||
]
|
||||
mock_models.fetch_models_from_api.return_value = fake_models
|
||||
|
||||
openrouter_client.list_models([])
|
||||
|
||||
# Count data rows: calls that contain a model ID pattern
|
||||
data_row_calls = [
|
||||
c for c in mock_console.print.call_args_list
|
||||
if c.args and isinstance(c.args[0], str) and "provider/model-" in c.args[0]
|
||||
]
|
||||
assert len(data_row_calls) == 10
|
||||
|
||||
# Should show "Showing 10 of 25" truncation notice
|
||||
all_output = " ".join(
|
||||
str(c) for c in mock_console.print.call_args_list
|
||||
)
|
||||
assert "10 of 25" in all_output
|
||||
|
||||
|
||||
@patch(f"{_MOD}.success")
|
||||
@patch(f"{_MOD}.models")
|
||||
@patch(f"{_MOD}.keys")
|
||||
@patch(f"{_MOD}.header")
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_list_models_all_flag_shows_everything(mock_console, mock_header, mock_keys, mock_models, mock_success):
|
||||
"""With --all flag, all models are displayed."""
|
||||
from aipass.api.apps.modules import openrouter_client
|
||||
|
||||
mock_keys.get_api_key.return_value = "sk-or-test-key"
|
||||
fake_models = [
|
||||
{
|
||||
"id": f"provider/model-{i}",
|
||||
"context_length": 4096,
|
||||
"pricing": {"prompt": "0", "completion": "0"},
|
||||
}
|
||||
for i in range(25)
|
||||
]
|
||||
mock_models.fetch_models_from_api.return_value = fake_models
|
||||
|
||||
openrouter_client.list_models(["--all"])
|
||||
|
||||
data_row_calls = [
|
||||
c for c in mock_console.print.call_args_list
|
||||
if c.args and isinstance(c.args[0], str) and "provider/model-" in c.args[0]
|
||||
]
|
||||
assert len(data_row_calls) == 25
|
||||
|
||||
|
||||
# =============================================
|
||||
# check_status
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{_MOD}.client")
|
||||
@patch(f"{_MOD}.keys")
|
||||
@patch(f"{_MOD}.header")
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_check_status_with_key(mock_console, mock_header, mock_keys, mock_client):
|
||||
"""When API key exists, status shows masked key and cache stats."""
|
||||
from aipass.api.apps.modules import openrouter_client
|
||||
|
||||
mock_keys.get_api_key.return_value = "sk-or-v1-abcdefgh12345678"
|
||||
mock_client.get_cache_stats.return_value = {"cached_clients": 2, "max_cache_size": 5}
|
||||
|
||||
openrouter_client.check_status()
|
||||
|
||||
all_output = " ".join(str(c) for c in mock_console.print.call_args_list)
|
||||
# Key should be shown as masked
|
||||
assert "sk-or-v1" in all_output
|
||||
assert "5678" in all_output
|
||||
# "yes" for key configured
|
||||
assert "yes" in all_output
|
||||
# Cache stats shown
|
||||
assert "2/5" in all_output
|
||||
|
||||
|
||||
@patch(f"{_MOD}.client")
|
||||
@patch(f"{_MOD}.keys")
|
||||
@patch(f"{_MOD}.header")
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_check_status_no_key(mock_console, mock_header, mock_keys, mock_client):
|
||||
"""When API key is missing, status shows 'no' and diagnosis."""
|
||||
from aipass.api.apps.modules import openrouter_client
|
||||
|
||||
mock_keys.get_api_key.return_value = None
|
||||
mock_keys.diagnose_key.return_value = "OPENROUTER_API_KEY not set"
|
||||
mock_client.get_cache_stats.return_value = {"cached_clients": 0, "max_cache_size": 5}
|
||||
|
||||
openrouter_client.check_status()
|
||||
|
||||
all_output = " ".join(str(c) for c in mock_console.print.call_args_list)
|
||||
assert "no" in all_output
|
||||
assert "OPENROUTER_API_KEY not set" in all_output
|
||||
|
||||
|
||||
# =============================================
|
||||
# get_response — delegation
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{_MOD}.client")
|
||||
def test_get_response_delegates_to_handler(mock_client):
|
||||
"""get_response passes through to client.get_response and returns its result."""
|
||||
from aipass.api.apps.modules import openrouter_client
|
||||
|
||||
mock_client.get_response.return_value = {
|
||||
"content": "Hello!",
|
||||
"id": "gen-123",
|
||||
"model": "anthropic/claude-3.5-sonnet",
|
||||
}
|
||||
|
||||
result = openrouter_client.get_response(
|
||||
"Hi there",
|
||||
caller="flow",
|
||||
model="anthropic/claude-3.5-sonnet",
|
||||
temperature=0.5,
|
||||
)
|
||||
|
||||
mock_client.get_response.assert_called_once_with(
|
||||
"Hi there",
|
||||
"flow",
|
||||
"anthropic/claude-3.5-sonnet",
|
||||
temperature=0.5,
|
||||
)
|
||||
assert result is not None
|
||||
assert result["content"] == "Hello!"
|
||||
assert result["model"] == "anthropic/claude-3.5-sonnet"
|
||||
|
||||
|
||||
@patch(f"{_MOD}.client")
|
||||
def test_get_response_returns_none_on_failure(mock_client):
|
||||
"""get_response returns None when client handler returns None."""
|
||||
from aipass.api.apps.modules import openrouter_client
|
||||
|
||||
mock_client.get_response.return_value = None
|
||||
|
||||
result = openrouter_client.get_response("fail prompt", caller="test")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
# =============================================
|
||||
# list_models — context formatting
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{_MOD}.success")
|
||||
@patch(f"{_MOD}.models")
|
||||
@patch(f"{_MOD}.keys")
|
||||
@patch(f"{_MOD}.header")
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_list_models_formats_million_context(mock_console, mock_header, mock_keys, mock_models, mock_success):
|
||||
"""Context length >= 1M formatted as 'XM'."""
|
||||
from aipass.api.apps.modules import openrouter_client
|
||||
|
||||
mock_keys.get_api_key.return_value = "sk-or-test"
|
||||
mock_models.fetch_models_from_api.return_value = [
|
||||
{"id": "big/model", "context_length": 2_000_000, "pricing": {"prompt": "0", "completion": "0"}}
|
||||
]
|
||||
|
||||
openrouter_client.list_models([])
|
||||
|
||||
data_rows = [
|
||||
c for c in mock_console.print.call_args_list
|
||||
if c.args and isinstance(c.args[0], str) and "big/model" in c.args[0]
|
||||
]
|
||||
assert len(data_rows) == 1
|
||||
assert "2M" in data_rows[0].args[0]
|
||||
|
||||
|
||||
@patch(f"{_MOD}.success")
|
||||
@patch(f"{_MOD}.models")
|
||||
@patch(f"{_MOD}.keys")
|
||||
@patch(f"{_MOD}.header")
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_list_models_formats_thousand_context(mock_console, mock_header, mock_keys, mock_models, mock_success):
|
||||
"""Context length >= 1k formatted as 'Xk'."""
|
||||
from aipass.api.apps.modules import openrouter_client
|
||||
|
||||
mock_keys.get_api_key.return_value = "sk-or-test"
|
||||
mock_models.fetch_models_from_api.return_value = [
|
||||
{"id": "med/model", "context_length": 128_000, "pricing": {"prompt": "0.01", "completion": "0.02"}}
|
||||
]
|
||||
|
||||
openrouter_client.list_models([])
|
||||
|
||||
data_rows = [
|
||||
c for c in mock_console.print.call_args_list
|
||||
if c.args and isinstance(c.args[0], str) and "med/model" in c.args[0]
|
||||
]
|
||||
assert len(data_rows) == 1
|
||||
assert "128k" in data_rows[0].args[0]
|
||||
|
||||
|
||||
@patch(f"{_MOD}.success")
|
||||
@patch(f"{_MOD}.models")
|
||||
@patch(f"{_MOD}.keys")
|
||||
@patch(f"{_MOD}.header")
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_list_models_formats_free_pricing(mock_console, mock_header, mock_keys, mock_models, mock_success):
|
||||
"""Models with zero pricing show 'free'."""
|
||||
from aipass.api.apps.modules import openrouter_client
|
||||
|
||||
mock_keys.get_api_key.return_value = "sk-or-test"
|
||||
mock_models.fetch_models_from_api.return_value = [
|
||||
{"id": "free/model", "context_length": 4096, "pricing": {"prompt": "0", "completion": "0"}}
|
||||
]
|
||||
|
||||
openrouter_client.list_models([])
|
||||
|
||||
data_rows = [
|
||||
c for c in mock_console.print.call_args_list
|
||||
if c.args and isinstance(c.args[0], str) and "free/model" in c.args[0]
|
||||
]
|
||||
assert len(data_rows) == 1
|
||||
assert "free" in data_rows[0].args[0]
|
||||
|
||||
|
||||
# =============================================
|
||||
# make_call — stub behaviour
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{_MOD}.error")
|
||||
@patch(f"{_MOD}.header")
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_make_call_no_model_shows_error(mock_console, mock_header, mock_error):
|
||||
"""make_call without --model shows error."""
|
||||
from aipass.api.apps.modules import openrouter_client
|
||||
|
||||
openrouter_client.make_call(["What is AI?"])
|
||||
|
||||
mock_error.assert_called_once()
|
||||
assert "Model required" in mock_error.call_args[0][0]
|
||||
|
||||
|
||||
@patch(f"{_MOD}.error")
|
||||
@patch(f"{_MOD}.json_handler")
|
||||
@patch(f"{_MOD}.header")
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_handle_command_call_no_args_shows_error(mock_console, mock_header, mock_jh, mock_error):
|
||||
"""call with no args should show error, not introspection."""
|
||||
from aipass.api.apps.modules import openrouter_client
|
||||
|
||||
result = openrouter_client.handle_command("call", [])
|
||||
|
||||
assert result is True
|
||||
mock_error.assert_called_once()
|
||||
assert "Prompt required" in mock_error.call_args[0][0]
|
||||
|
||||
|
||||
# =============================================
|
||||
# list_models — error on fetch failure
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{_MOD}.error")
|
||||
@patch(f"{_MOD}.models")
|
||||
@patch(f"{_MOD}.keys")
|
||||
@patch(f"{_MOD}.header")
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_list_models_fetch_failure(mock_console, mock_header, mock_keys, mock_models, mock_error):
|
||||
"""When fetch_models_from_api returns None, error is shown."""
|
||||
from aipass.api.apps.modules import openrouter_client
|
||||
|
||||
mock_keys.get_api_key.return_value = "sk-or-test"
|
||||
mock_models.fetch_models_from_api.return_value = None
|
||||
|
||||
openrouter_client.list_models([])
|
||||
|
||||
mock_error.assert_called_once()
|
||||
assert "fetch" in mock_error.call_args[0][0].lower()
|
||||
|
||||
|
||||
# =============================================
|
||||
# handle_command — exception propagation
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{_MOD}.keys")
|
||||
@patch(f"{_MOD}.json_handler")
|
||||
@patch(f"{_MOD}.header")
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_handle_command_propagates_exception(mock_console, mock_header, mock_jh, mock_keys):
|
||||
"""handle_command re-raises exceptions from downstream handlers."""
|
||||
from aipass.api.apps.modules import openrouter_client
|
||||
|
||||
mock_keys.get_api_key.side_effect = RuntimeError("handler failed")
|
||||
|
||||
with pytest.raises(RuntimeError, match="handler failed"):
|
||||
openrouter_client.handle_command("test", [])
|
||||
|
||||
|
||||
# =============================================
|
||||
# create_client() — handler-level tests
|
||||
# =============================================
|
||||
|
||||
_CLIENT_MOD = "aipass.api.apps.handlers.openrouter.client"
|
||||
|
||||
|
||||
@patch(f"{_CLIENT_MOD}.OPENAI_AVAILABLE", False)
|
||||
def test_create_client_returns_none_when_sdk_unavailable():
|
||||
"""create_client returns None when OpenAI SDK is not installed."""
|
||||
from aipass.api.apps.handlers.openrouter.client import create_client
|
||||
|
||||
result = create_client("sk-or-test-key")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@patch(f"{_CLIENT_MOD}.OPENAI_AVAILABLE", True)
|
||||
def test_create_client_returns_none_for_empty_key():
|
||||
"""create_client returns None when api_key is empty string."""
|
||||
from aipass.api.apps.handlers.openrouter.client import create_client
|
||||
|
||||
assert create_client("") is None
|
||||
|
||||
|
||||
@patch(f"{_CLIENT_MOD}.OPENAI_AVAILABLE", True)
|
||||
def test_create_client_returns_none_for_none_key():
|
||||
"""create_client returns None when api_key is None."""
|
||||
from aipass.api.apps.handlers.openrouter.client import create_client
|
||||
|
||||
assert create_client(None) is None # type: ignore[arg-type]
|
||||
|
||||
|
||||
@patch(f"{_CLIENT_MOD}.json_handler")
|
||||
@patch(f"{_CLIENT_MOD}.OpenAI")
|
||||
@patch(f"{_CLIENT_MOD}.OPENAI_AVAILABLE", True)
|
||||
def test_create_client_success(mock_openai_cls, mock_jh):
|
||||
"""create_client returns an OpenAI client instance on success."""
|
||||
from aipass.api.apps.handlers.openrouter.client import create_client, OPENROUTER_HEADERS
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_openai_cls.return_value = mock_client
|
||||
|
||||
result = create_client("sk-or-valid-key", base_url="https://openrouter.ai/api/v1", timeout=30)
|
||||
|
||||
assert result is mock_client
|
||||
mock_openai_cls.assert_called_once_with(
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
api_key="sk-or-valid-key",
|
||||
timeout=30,
|
||||
default_headers=OPENROUTER_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
@patch(f"{_CLIENT_MOD}.json_handler")
|
||||
@patch(f"{_CLIENT_MOD}.OpenAI")
|
||||
@patch(f"{_CLIENT_MOD}.OPENAI_AVAILABLE", True)
|
||||
def test_create_client_custom_timeout(mock_openai_cls, mock_jh):
|
||||
"""create_client passes custom timeout to OpenAI constructor."""
|
||||
from aipass.api.apps.handlers.openrouter.client import create_client
|
||||
|
||||
mock_openai_cls.return_value = MagicMock()
|
||||
|
||||
create_client("sk-or-key", timeout=60)
|
||||
|
||||
assert mock_openai_cls.call_args[1]["timeout"] == 60
|
||||
|
||||
|
||||
@patch(f"{_CLIENT_MOD}.OpenAI")
|
||||
@patch(f"{_CLIENT_MOD}.OPENAI_AVAILABLE", True)
|
||||
def test_create_client_returns_none_on_exception(mock_openai_cls):
|
||||
"""create_client returns None when OpenAI constructor raises."""
|
||||
from aipass.api.apps.handlers.openrouter.client import create_client
|
||||
|
||||
mock_openai_cls.side_effect = RuntimeError("connection refused")
|
||||
|
||||
result = create_client("sk-or-key")
|
||||
|
||||
assert result is None
|
||||
@@ -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"])
|
||||
@@ -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", "sk-or-test-key")
|
||||
|
||||
assert result is not None
|
||||
assert result["total_cost"] == 0.0042
|
||||
assert result["tokens_prompt"] == 150
|
||||
assert result["tokens_completion"] == 80
|
||||
assert result["generation_time"] == 1200
|
||||
assert result["latency"] == 1500
|
||||
assert result["provider_name"] == "anthropic"
|
||||
|
||||
# Verify request was made with correct params
|
||||
mock_requests.get.assert_called_once()
|
||||
call_kwargs = mock_requests.get.call_args
|
||||
assert call_kwargs[1]["params"] == {"id": "gen-abc-123"}
|
||||
assert "Bearer sk-or-test-key" in call_kwargs[1]["headers"]["Authorization"]
|
||||
|
||||
|
||||
@patch(f"{_TRACKING_MOD}.requests")
|
||||
def test_get_generation_metrics_non_200(mock_requests: MagicMock):
|
||||
"""Returns None when API returns non-200 status."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 404
|
||||
mock_requests.get.return_value = mock_response
|
||||
|
||||
result = get_generation_metrics("gen-missing", "sk-or-key")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@patch(f"{_TRACKING_MOD}.requests")
|
||||
def test_get_generation_metrics_invalid_structure_no_data_key(mock_requests: MagicMock):
|
||||
"""Returns None when response JSON lacks 'data' key."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"error": "not found"}
|
||||
mock_requests.get.return_value = mock_response
|
||||
|
||||
result = get_generation_metrics("gen-bad", "sk-or-key")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@patch(f"{_TRACKING_MOD}.requests")
|
||||
def test_get_generation_metrics_invalid_structure_empty_response(mock_requests: MagicMock):
|
||||
"""Returns None when response JSON is empty/None."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = None
|
||||
mock_requests.get.return_value = mock_response
|
||||
|
||||
result = get_generation_metrics("gen-empty", "sk-or-key")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@patch(f"{_TRACKING_MOD}.requests")
|
||||
def test_get_generation_metrics_request_exception(mock_requests: MagicMock):
|
||||
"""Returns None when requests raises an exception."""
|
||||
import requests as real_requests
|
||||
|
||||
mock_requests.get.side_effect = real_requests.exceptions.ConnectionError("refused")
|
||||
mock_requests.exceptions = real_requests.exceptions
|
||||
|
||||
result = get_generation_metrics("gen-fail", "sk-or-key")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@patch(f"{_TRACKING_MOD}.requests")
|
||||
def test_get_generation_metrics_timeout(mock_requests: MagicMock):
|
||||
"""Returns None on request timeout."""
|
||||
import requests as real_requests
|
||||
|
||||
mock_requests.get.side_effect = real_requests.exceptions.Timeout("timed out")
|
||||
mock_requests.exceptions = real_requests.exceptions
|
||||
|
||||
result = get_generation_metrics("gen-timeout", "sk-or-key")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@patch(f"{_TRACKING_MOD}.requests")
|
||||
def test_get_generation_metrics_defaults_missing_fields(mock_requests: MagicMock):
|
||||
"""Missing fields in metrics default to 0 / 'unknown'."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"data": {}}
|
||||
mock_requests.get.return_value = mock_response
|
||||
|
||||
result = get_generation_metrics("gen-sparse", "sk-or-key")
|
||||
|
||||
assert result is not None
|
||||
assert result["total_cost"] == 0.0
|
||||
assert result["tokens_prompt"] == 0
|
||||
assert result["tokens_completion"] == 0
|
||||
assert result["generation_time"] == 0
|
||||
assert result["latency"] == 0
|
||||
assert result["provider_name"] == "unknown"
|
||||
|
||||
|
||||
# =============================================
|
||||
# store_usage_data tests
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{_TRACKING_MOD}.API_JSON_DIR")
|
||||
def test_store_usage_data_creates_new_file(mock_dir: MagicMock, tmp_path: Path):
|
||||
"""store_usage_data creates initial structure when file doesn't exist."""
|
||||
mock_dir.__truediv__ = lambda self, other: tmp_path / other
|
||||
mock_dir.mkdir = MagicMock()
|
||||
|
||||
metrics = {
|
||||
"total_cost": 0.005,
|
||||
"tokens_prompt": 100,
|
||||
"tokens_completion": 50,
|
||||
"generation_time": 800,
|
||||
"latency": 1000,
|
||||
"provider_name": "anthropic",
|
||||
}
|
||||
|
||||
result = store_usage_data("test_caller", "anthropic/claude-3.5-sonnet", "gen-001", metrics)
|
||||
|
||||
assert result is True
|
||||
|
||||
data_path = tmp_path / "usage_tracker_data.json"
|
||||
assert data_path.exists()
|
||||
|
||||
with open(data_path, "r", encoding="utf-8") as f:
|
||||
wrapper = json.load(f)
|
||||
|
||||
assert wrapper["module_name"] == "api_usage"
|
||||
data = wrapper["data"]
|
||||
|
||||
# Session totals
|
||||
assert data["current_session"]["total_requests"] == 1
|
||||
assert data["current_session"]["total_cost"] == 0.005
|
||||
assert data["current_session"]["total_tokens"] == 150
|
||||
|
||||
# Per-caller stats
|
||||
assert "test_caller" in data["usage_by_caller"]
|
||||
caller_data = data["usage_by_caller"]["test_caller"]
|
||||
assert caller_data["requests"] == 1
|
||||
assert caller_data["total_cost"] == 0.005
|
||||
assert caller_data["total_tokens"] == 150
|
||||
assert caller_data["models_used"]["anthropic/claude-3.5-sonnet"] == 1
|
||||
|
||||
# Generation tracking
|
||||
assert "gen-001" in data["generation_tracking"]
|
||||
entry = data["generation_tracking"]["gen-001"]
|
||||
assert entry["caller"] == "test_caller"
|
||||
assert entry["model"] == "anthropic/claude-3.5-sonnet"
|
||||
assert entry["usage_data"] == metrics
|
||||
|
||||
|
||||
@patch(f"{_TRACKING_MOD}.API_JSON_DIR")
|
||||
def test_store_usage_data_updates_existing(mock_dir: MagicMock, tmp_path: Path):
|
||||
"""store_usage_data increments counters in existing file."""
|
||||
mock_dir.__truediv__ = lambda self, other: tmp_path / other
|
||||
mock_dir.mkdir = MagicMock()
|
||||
|
||||
metrics = {
|
||||
"total_cost": 0.01,
|
||||
"tokens_prompt": 200,
|
||||
"tokens_completion": 100,
|
||||
"generation_time": 500,
|
||||
"latency": 700,
|
||||
"provider_name": "openai",
|
||||
}
|
||||
|
||||
# First call creates the file
|
||||
store_usage_data("caller_a", "openai/gpt-4", "gen-100", metrics)
|
||||
|
||||
# Second call updates
|
||||
result = store_usage_data("caller_a", "openai/gpt-4", "gen-101", metrics)
|
||||
|
||||
assert result is True
|
||||
|
||||
data_path = tmp_path / "usage_tracker_data.json"
|
||||
with open(data_path, "r", encoding="utf-8") as f:
|
||||
wrapper = json.load(f)
|
||||
|
||||
data = wrapper["data"]
|
||||
assert data["current_session"]["total_requests"] == 2
|
||||
assert data["current_session"]["total_cost"] == pytest.approx(0.02)
|
||||
assert data["current_session"]["total_tokens"] == 600
|
||||
|
||||
caller_data = data["usage_by_caller"]["caller_a"]
|
||||
assert caller_data["requests"] == 2
|
||||
assert caller_data["models_used"]["openai/gpt-4"] == 2
|
||||
|
||||
|
||||
@patch(f"{_TRACKING_MOD}.API_JSON_DIR")
|
||||
def test_store_usage_data_newest_first_ordering(mock_dir: MagicMock, tmp_path: Path):
|
||||
"""Generation tracking stores newest entry first."""
|
||||
mock_dir.__truediv__ = lambda self, other: tmp_path / other
|
||||
mock_dir.mkdir = MagicMock()
|
||||
|
||||
metrics = {
|
||||
"total_cost": 0.001,
|
||||
"tokens_prompt": 10,
|
||||
"tokens_completion": 5,
|
||||
"generation_time": 100,
|
||||
"latency": 200,
|
||||
"provider_name": "test",
|
||||
}
|
||||
|
||||
store_usage_data("caller", "model/a", "gen-first", metrics)
|
||||
store_usage_data("caller", "model/a", "gen-second", metrics)
|
||||
|
||||
data_path = tmp_path / "usage_tracker_data.json"
|
||||
with open(data_path, "r", encoding="utf-8") as f:
|
||||
wrapper = json.load(f)
|
||||
|
||||
tracking_keys = list(wrapper["data"]["generation_tracking"].keys())
|
||||
assert tracking_keys[0] == "gen-second"
|
||||
assert tracking_keys[1] == "gen-first"
|
||||
|
||||
|
||||
@patch(f"{_TRACKING_MOD}.API_JSON_DIR")
|
||||
def test_store_usage_data_multiple_callers(mock_dir: MagicMock, tmp_path: Path):
|
||||
"""store_usage_data tracks multiple callers independently."""
|
||||
mock_dir.__truediv__ = lambda self, other: tmp_path / other
|
||||
mock_dir.mkdir = MagicMock()
|
||||
|
||||
metrics = {
|
||||
"total_cost": 0.003,
|
||||
"tokens_prompt": 50,
|
||||
"tokens_completion": 25,
|
||||
"generation_time": 300,
|
||||
"latency": 400,
|
||||
"provider_name": "test",
|
||||
}
|
||||
|
||||
store_usage_data("caller_x", "model/x", "gen-x1", metrics)
|
||||
store_usage_data("caller_y", "model/y", "gen-y1", metrics)
|
||||
|
||||
data_path = tmp_path / "usage_tracker_data.json"
|
||||
with open(data_path, "r", encoding="utf-8") as f:
|
||||
wrapper = json.load(f)
|
||||
|
||||
by_caller = wrapper["data"]["usage_by_caller"]
|
||||
assert "caller_x" in by_caller
|
||||
assert "caller_y" in by_caller
|
||||
assert by_caller["caller_x"]["requests"] == 1
|
||||
assert by_caller["caller_y"]["requests"] == 1
|
||||
assert by_caller["caller_x"]["models_used"]["model/x"] == 1
|
||||
assert by_caller["caller_y"]["models_used"]["model/y"] == 1
|
||||
|
||||
|
||||
@patch(f"{_TRACKING_MOD}.API_JSON_DIR")
|
||||
def test_store_usage_data_daily_totals(mock_dir: MagicMock, tmp_path: Path):
|
||||
"""store_usage_data updates daily totals for today's date."""
|
||||
from datetime import datetime
|
||||
|
||||
mock_dir.__truediv__ = lambda self, other: tmp_path / other
|
||||
mock_dir.mkdir = MagicMock()
|
||||
|
||||
metrics = {
|
||||
"total_cost": 0.002,
|
||||
"tokens_prompt": 40,
|
||||
"tokens_completion": 20,
|
||||
"generation_time": 200,
|
||||
"latency": 300,
|
||||
"provider_name": "test",
|
||||
}
|
||||
|
||||
store_usage_data("caller", "model/a", "gen-daily", metrics)
|
||||
|
||||
data_path = tmp_path / "usage_tracker_data.json"
|
||||
with open(data_path, "r", encoding="utf-8") as f:
|
||||
wrapper = json.load(f)
|
||||
|
||||
today = datetime.now().date().isoformat()
|
||||
daily = wrapper["data"]["daily_totals"]
|
||||
assert today in daily
|
||||
assert daily[today]["requests"] == 1
|
||||
assert daily[today]["cost"] == 0.002
|
||||
assert daily[today]["tokens"] == 60
|
||||
|
||||
|
||||
@patch(f"{_TRACKING_MOD}.API_JSON_DIR")
|
||||
def test_store_usage_data_returns_false_on_exception(mock_dir: MagicMock, tmp_path: Path):
|
||||
"""store_usage_data returns False when an exception occurs."""
|
||||
# Point to a path that will fail (parent is a file, not a dir)
|
||||
blocker = tmp_path / "blocker_file"
|
||||
blocker.write_text("not a dir", encoding="utf-8")
|
||||
mock_dir.__truediv__ = lambda self, other: blocker / other
|
||||
mock_dir.mkdir = MagicMock(side_effect=OSError("cannot create"))
|
||||
|
||||
metrics = {
|
||||
"total_cost": 0.0,
|
||||
"tokens_prompt": 0,
|
||||
"tokens_completion": 0,
|
||||
"generation_time": 0,
|
||||
"latency": 0,
|
||||
"provider_name": "test",
|
||||
}
|
||||
|
||||
result = store_usage_data("caller", "model", "gen-err", metrics)
|
||||
|
||||
assert result is False
|
||||
@@ -0,0 +1,460 @@
|
||||
# =================== 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_session_summary.return_value = {
|
||||
"total_requests": 42,
|
||||
"total_cost": 0.123456,
|
||||
"total_tokens": 9001,
|
||||
}
|
||||
|
||||
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_session_summary.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() when handler returns truthy."""
|
||||
from aipass.api.apps.modules import usage_tracker
|
||||
|
||||
mock_cleanup_handler.cleanup_old_data.return_value = True
|
||||
|
||||
with patch(f"{PATCH_ROOT}.Path") as mock_path_cls:
|
||||
mock_path_cls.__file__ = MagicMock()
|
||||
# Let Path(__file__).resolve().parent chain work
|
||||
mock_resolved = MagicMock()
|
||||
mock_path_cls.return_value.resolve.return_value.parent.parent.parent.__truediv__ = MagicMock()
|
||||
|
||||
# Simpler approach: just let the real Path work -- it resolves against
|
||||
# the actual source file, but cleanup_old_data is mocked anyway.
|
||||
pass
|
||||
|
||||
# Call directly without patching Path -- cleanup handler is mocked
|
||||
usage_tracker.cleanup_data(["45"])
|
||||
|
||||
mock_cleanup_handler.cleanup_old_data.assert_called_once()
|
||||
mock_success.assert_called_once_with("Cleaned up data older than 45 days")
|
||||
mock_error.assert_not_called()
|
||||
|
||||
|
||||
@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_failure(mock_cleanup_handler, mock_header, mock_console, mock_success, mock_error):
|
||||
"""cleanup_data calls error() when handler returns falsy."""
|
||||
from aipass.api.apps.modules import usage_tracker
|
||||
|
||||
mock_cleanup_handler.cleanup_old_data.return_value = False
|
||||
|
||||
usage_tracker.cleanup_data(["30"])
|
||||
|
||||
mock_cleanup_handler.cleanup_old_data.assert_called_once()
|
||||
mock_error.assert_called_once_with("Cleanup failed")
|
||||
mock_success.assert_not_called()
|
||||
|
||||
|
||||
@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 = True
|
||||
|
||||
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 data 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 = True
|
||||
|
||||
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 data 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_session_summary.side_effect = RuntimeError("handler failed")
|
||||
|
||||
with pytest.raises(RuntimeError, match="handler failed"):
|
||||
usage_tracker.handle_command("stats", [])
|
||||
@@ -0,0 +1,74 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: hook_sounds_plugin.py
|
||||
# Description: Toggle hook notification sounds on/off
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-09
|
||||
# Modified: 2026-04-09
|
||||
# =============================================
|
||||
|
||||
"""Toggle hook notification sounds on/off.
|
||||
|
||||
Creates or removes a flag file that hook sound scripts check before
|
||||
playing audio. When muted, hooks still run their essential logic
|
||||
(prompt injection, logging) but skip audio playback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
|
||||
MUTE_FLAG = Path("/tmp/aipass-hooks-muted")
|
||||
|
||||
|
||||
def mute() -> bool:
|
||||
"""Create the mute flag file — silences hook sounds."""
|
||||
MUTE_FLAG.touch()
|
||||
logger.info("Hook sounds muted (flag: %s)", MUTE_FLAG)
|
||||
return True
|
||||
|
||||
|
||||
def unmute() -> bool:
|
||||
"""Remove the mute flag file — re-enables hook sounds."""
|
||||
MUTE_FLAG.unlink(missing_ok=True)
|
||||
logger.info("Hook sounds unmuted (flag removed: %s)", MUTE_FLAG)
|
||||
return True
|
||||
|
||||
|
||||
def is_muted() -> bool:
|
||||
"""Check if hook sounds are currently muted."""
|
||||
return MUTE_FLAG.exists()
|
||||
|
||||
|
||||
def handle_command(command: str | None = None, args: list[str] | None = None) -> bool:
|
||||
"""Route hook-sounds commands.
|
||||
|
||||
Args:
|
||||
command: "on", "off", or None (show status).
|
||||
args: Not used.
|
||||
|
||||
Returns:
|
||||
True on success.
|
||||
"""
|
||||
json_handler.log_operation("handle_command", {"plugin": "hook_sounds", "command": command})
|
||||
|
||||
if command == "off":
|
||||
mute()
|
||||
print("Hook sounds: MUTED")
|
||||
return True
|
||||
|
||||
if command == "on":
|
||||
unmute()
|
||||
print("Hook sounds: ACTIVE")
|
||||
return True
|
||||
|
||||
# No command = show status
|
||||
if is_muted():
|
||||
print("Hook sounds: MUTED (off)")
|
||||
print(f" Flag: {MUTE_FLAG}")
|
||||
else:
|
||||
print("Hook sounds: ACTIVE (on)")
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Tests for the hook-sounds plugin — mute/unmute hook notification sounds."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from aipass.drone.apps.plugins.hook_sounds.hook_sounds_plugin import (
|
||||
MUTE_FLAG,
|
||||
handle_command,
|
||||
is_muted,
|
||||
mute,
|
||||
unmute,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate(tmp_path, monkeypatch):
|
||||
"""Redirect MUTE_FLAG to a temp path so tests don't touch /tmp."""
|
||||
flag = tmp_path / "aipass-hooks-muted"
|
||||
monkeypatch.setattr(
|
||||
"aipass.drone.apps.plugins.hook_sounds.hook_sounds_plugin.MUTE_FLAG",
|
||||
flag,
|
||||
)
|
||||
with patch(
|
||||
"aipass.drone.apps.plugins.hook_sounds.hook_sounds_plugin.json_handler"
|
||||
):
|
||||
yield flag
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMute:
|
||||
def test_mute_creates_flag(self, _isolate):
|
||||
flag = _isolate
|
||||
assert not flag.exists()
|
||||
mute()
|
||||
assert flag.exists()
|
||||
|
||||
def test_mute_returns_true(self, _isolate):
|
||||
assert mute() is True
|
||||
|
||||
def test_mute_idempotent(self, _isolate):
|
||||
flag = _isolate
|
||||
mute()
|
||||
mute()
|
||||
assert flag.exists()
|
||||
|
||||
|
||||
class TestUnmute:
|
||||
def test_unmute_removes_flag(self, _isolate):
|
||||
flag = _isolate
|
||||
flag.touch()
|
||||
unmute()
|
||||
assert not flag.exists()
|
||||
|
||||
def test_unmute_returns_true(self, _isolate):
|
||||
assert unmute() is True
|
||||
|
||||
def test_unmute_no_flag_no_error(self, _isolate):
|
||||
flag = _isolate
|
||||
assert not flag.exists()
|
||||
unmute()
|
||||
assert not flag.exists()
|
||||
|
||||
|
||||
class TestIsMuted:
|
||||
def test_not_muted_by_default(self, _isolate):
|
||||
assert is_muted() is False
|
||||
|
||||
def test_muted_after_mute(self, _isolate):
|
||||
mute()
|
||||
assert is_muted() is True
|
||||
|
||||
def test_not_muted_after_unmute(self, _isolate):
|
||||
mute()
|
||||
unmute()
|
||||
assert is_muted() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# handle_command routing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHandleCommand:
|
||||
def test_off_mutes(self, _isolate, capsys):
|
||||
result = handle_command("off")
|
||||
assert result is True
|
||||
assert _isolate.exists()
|
||||
assert "MUTED" in capsys.readouterr().out
|
||||
|
||||
def test_on_unmutes(self, _isolate, capsys):
|
||||
_isolate.touch()
|
||||
result = handle_command("on")
|
||||
assert result is True
|
||||
assert not _isolate.exists()
|
||||
assert "ACTIVE" in capsys.readouterr().out
|
||||
|
||||
def test_no_command_shows_active(self, _isolate, capsys):
|
||||
result = handle_command(None)
|
||||
assert result is True
|
||||
assert "ACTIVE" in capsys.readouterr().out
|
||||
|
||||
def test_no_command_shows_muted(self, _isolate, capsys):
|
||||
_isolate.touch()
|
||||
result = handle_command(None)
|
||||
assert result is True
|
||||
out = capsys.readouterr().out
|
||||
assert "MUTED" in out
|
||||
|
||||
def test_off_then_on_roundtrip(self, _isolate, capsys):
|
||||
handle_command("off")
|
||||
assert _isolate.exists()
|
||||
handle_command("on")
|
||||
assert not _isolate.exists()
|
||||
@@ -0,0 +1,42 @@
|
||||
# {{BRANCHNAME}}
|
||||
|
||||
## Startup
|
||||
|
||||
On any greeting, silently read these files and run the commands — no narration, no announcing steps. Just do it and respond with the status.
|
||||
|
||||
**Read:** `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`, `STATUS.local.md`
|
||||
**Check:** If `.ai_mail.local/inbox.json` exists, read it. Process any mail.
|
||||
**Run:** `git status`
|
||||
|
||||
## Identity
|
||||
|
||||
You are **{{BRANCHNAME}}** — an AIPass citizen.
|
||||
|
||||
- **Module:** `aipass.{{MODULE}}`
|
||||
- **Role:** {{ROLE}}
|
||||
- **Purpose:** {{PURPOSE_BRIEF}}
|
||||
|
||||
## Memories
|
||||
|
||||
Update `.trinity/` at natural breakpoints, after milestones, and on `/memo`.
|
||||
|
||||
- `local.json` — Session history, key learnings, active tasks
|
||||
- `observations.json` — Collaboration patterns, insights
|
||||
- `passport.json` — Identity (rarely changes)
|
||||
|
||||
## AIPass Context
|
||||
|
||||
This branch is part of the AIPass multi-agent framework. Key concepts:
|
||||
|
||||
- **Branch** — your directory (`src/aipass/{{BRANCH}}/`). Your home.
|
||||
- **Citizen** — the identity that lives in a branch. Has a passport, memories, mailbox.
|
||||
- **Agent** — a disposable worker spawned for a task. No passport, no memory.
|
||||
|
||||
## Commands
|
||||
|
||||
```
|
||||
drone systems # List available infrastructure
|
||||
drone @ai_mail inbox # Check mailbox
|
||||
drone @ai_mail send @branch "Subject" "Body" # Send mail
|
||||
drone @seedgo audit @{{BRANCH}} # Run standards audit
|
||||
```
|
||||
Reference in New Issue
Block a user