Files
openrouter-watcher/prompt/prompt-ingaggio-public-api.md
Luca Sacchi Ricciardi 3ae5d736ce feat(tasks): T55-T58 implement background tasks for OpenRouter sync
- T55: Setup APScheduler with AsyncIOScheduler and @scheduled_job decorator
- T56: Implement hourly usage stats sync from OpenRouter API
- T57: Implement daily API key validation job
- T58: Implement weekly cleanup job for old usage stats
- Add usage_stats_retention_days config option
- Integrate scheduler with FastAPI lifespan events
- Add 26 unit tests for scheduler, sync, and cleanup tasks
- Add apscheduler to requirements.txt

The background tasks now automatically:
- Sync usage stats every hour from OpenRouter
- Validate API keys daily at 2 AM UTC
- Clean up old data weekly on Sunday at 3 AM UTC
2026-04-07 17:41:24 +02:00

19 KiB

Prompt di Ingaggio: API Pubblica (T35-T40)

🎯 MISSIONE

Implementare la fase API Pubblica del progetto OpenRouter API Key Monitor seguendo rigorosamente TDD.

Task da completare: T35, T36, T37, T38, T39, T40


📋 CONTESTO

AGENTE: @tdd-developer

Repository: /home/google/Sources/LucaSacchiNet/openrouter-watcher

Stato Attuale:

  • Setup (T01-T05): 59 test
  • Database & Models (T06-T11): 73 test
  • Security Services (T12-T16): 70 test
  • User Authentication (T17-T22): 34 test
  • Gestione API Keys (T23-T29): 61 test
  • Dashboard & Statistiche (T30-T34): 27 test
  • 🎯 Totale: 324+ test, ~98% coverage su moduli implementati

Servizi Pronti:

  • EncryptionService - Cifratura/decifratura
  • get_current_user() - Autenticazione JWT
  • generate_api_token(), verify_api_token() - Token API pubblica
  • get_dashboard_data(), get_usage_stats() - Aggregazione dati
  • ApiKey, UsageStats, ApiToken models

Documentazione:

  • PRD: /home/google/Sources/LucaSacchiNet/openrouter-watcher/prd.md (sezione 2.4)
  • Architecture: /home/google/Sources/LucaSacchiNet/openrouter-watcher/export/architecture.md (sezione 5.2.3)

🔧 TASK DA IMPLEMENTARE

T35: Creare Pydantic Schemas per API Pubblica

File: src/openrouter_monitor/schemas/public_api.py

Requisiti:

  • PublicStatsResponse: summary (requests, cost, tokens), period (start_date, end_date)
  • PublicUsageResponse: items (list), pagination (page, limit, total, pages)
  • PublicKeyInfo: id, name, is_active, stats (total_requests, total_cost)
  • PublicKeyListResponse: items (list[PublicKeyInfo]), total
  • ApiTokenCreate: name (str, 1-100 chars)
  • ApiTokenResponse: id, name, created_at, last_used_at, is_active (NO token!)
  • ApiTokenCreateResponse: id, name, token (plaintext, solo al momento creazione), created_at

Implementazione:

from pydantic import BaseModel, Field
from datetime import date, datetime
from typing import List, Optional
from decimal import Decimal

class PeriodInfo(BaseModel):
    start_date: date
    end_date: date
    days: int

class PublicStatsSummary(BaseModel):
    total_requests: int
    total_cost: Decimal
    total_tokens_input: int
    total_tokens_output: int

class PublicStatsResponse(BaseModel):
    summary: PublicStatsSummary
    period: PeriodInfo

class PublicUsageItem(BaseModel):
    date: date
    model: str
    requests_count: int
    tokens_input: int
    tokens_output: int
    cost: Decimal

class PaginationInfo(BaseModel):
    page: int
    limit: int
    total: int
    pages: int

class PublicUsageResponse(BaseModel):
    items: List[PublicUsageItem]
    pagination: PaginationInfo

class PublicKeyStats(BaseModel):
    total_requests: int
    total_cost: Decimal

class PublicKeyInfo(BaseModel):
    id: int
    name: str
    is_active: bool
    stats: PublicKeyStats

class PublicKeyListResponse(BaseModel):
    items: List[PublicKeyInfo]
    total: int

class ApiTokenCreate(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)

class ApiTokenResponse(BaseModel):
    id: int
    name: str
    created_at: datetime
    last_used_at: Optional[datetime]
    is_active: bool

class ApiTokenCreateResponse(BaseModel):
    id: int
    name: str
    token: str  # PLAINTEXT - shown only once!
    created_at: datetime

Test: tests/unit/schemas/test_public_api_schemas.py (10+ test)


T36: Implementare Endpoint GET /api/v1/stats (API Pubblica)

File: src/openrouter_monitor/routers/public_api.py

Requisiti:

  • Endpoint: GET /api/v1/stats
  • Auth: API Token (non JWT!) - get_current_user_from_api_token()
  • Query params:
    • start_date (optional, default 30 giorni fa)
    • end_date (optional, default oggi)
  • Verifica token valido e attivo
  • Aggiorna last_used_at del token
  • Ritorna: PublicStatsResponse
  • Solo lettura, nessuna modifica

Implementazione:

from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from datetime import date, timedelta

from openrouter_monitor.database import get_db
from openrouter_monitor.dependencies import get_current_user_from_api_token
from openrouter_monitor.models import User
from openrouter_monitor.schemas import PublicStatsResponse
from openrouter_monitor.services.stats import get_public_stats

router = APIRouter(prefix="/api/v1", tags=["public-api"])

@router.get("/stats", response_model=PublicStatsResponse)
async def get_public_stats_endpoint(
    start_date: Optional[date] = Query(default=None),
    end_date: Optional[date] = Query(default=None),
    current_user: User = Depends(get_current_user_from_api_token),
    db: Session = Depends(get_db)
):
    """Get usage statistics via API token authentication.
    
    Authentication: Bearer <api_token>
    Returns aggregated statistics for the authenticated user's API keys.
    """
    # Default to last 30 days if dates not provided
    if not end_date:
        end_date = date.today()
    if not start_date:
        start_date = end_date - timedelta(days=29)
    
    # Get stats using existing service
    stats = await get_public_stats(db, current_user.id, start_date, end_date)
    
    return PublicStatsResponse(
        summary=stats,
        period=PeriodInfo(
            start_date=start_date,
            end_date=end_date,
            days=(end_date - start_date).days + 1
        )
    )

Test:

  • Test con token valido (200)
  • Test con token invalido (401)
  • Test con token scaduto/revocado (401)
  • Test date default (30 giorni)
  • Test date custom
  • Test aggiornamento last_used_at

T37: Implementare Endpoint GET /api/v1/usage (API Pubblica)

File: src/openrouter_monitor/routers/public_api.py

Requisiti:

  • Endpoint: GET /api/v1/usage
  • Auth: API Token
  • Query params:
    • start_date (required)
    • end_date (required)
    • page (default 1)
    • limit (default 100, max 1000)
  • Paginazione con offset/limit
  • Ritorna: PublicUsageResponse

Implementazione:

@router.get("/usage", response_model=PublicUsageResponse)
async def get_public_usage_endpoint(
    start_date: date,
    end_date: date,
    page: int = Query(default=1, ge=1),
    limit: int = Query(default=100, ge=1, le=1000),
    current_user: User = Depends(get_current_user_from_api_token),
    db: Session = Depends(get_db)
):
    """Get detailed usage data via API token authentication.
    
    Returns paginated usage records aggregated by date and model.
    """
    skip = (page - 1) * limit
    
    # Get usage data
    items, total = await get_public_usage(
        db, current_user.id, start_date, end_date, skip, limit
    )
    
    pages = (total + limit - 1) // limit
    
    return PublicUsageResponse(
        items=items,
        pagination=PaginationInfo(
            page=page,
            limit=limit,
            total=total,
            pages=pages
        )
    )

Test:

  • Test con filtri date (200)
  • Test paginazione
  • Test limit max 1000
  • Test senza token (401)
  • Test token scaduto (401)

T38: Implementare Endpoint GET /api/v1/keys (API Pubblica)

File: src/openrouter_monitor/routers/public_api.py

Requisiti:

  • Endpoint: GET /api/v1/keys
  • Auth: API Token
  • Ritorna: lista API keys con statistiche aggregate
  • NO key values (cifrate comunque)
  • Solo: id, name, is_active, stats (totali)

Implementazione:

@router.get("/keys", response_model=PublicKeyListResponse)
async def get_public_keys_endpoint(
    current_user: User = Depends(get_current_user_from_api_token),
    db: Session = Depends(get_db)
):
    """Get API keys list with aggregated statistics.
    
    Returns non-sensitive key information with usage stats.
    Key values are never exposed.
    """
    from sqlalchemy import func
    
    # Query API keys with aggregated stats
    results = db.query(
        ApiKey.id,
        ApiKey.name,
        ApiKey.is_active,
        func.coalesce(func.sum(UsageStats.requests_count), 0).label('total_requests'),
        func.coalesce(func.sum(UsageStats.cost), 0).label('total_cost')
    ).outerjoin(UsageStats).filter(
        ApiKey.user_id == current_user.id
    ).group_by(ApiKey.id).all()
    
    items = [
        PublicKeyInfo(
            id=r.id,
            name=r.name,
            is_active=r.is_active,
            stats=PublicKeyStats(
                total_requests=r.total_requests,
                total_cost=Decimal(str(r.total_cost))
            )
        )
        for r in results
    ]
    
    return PublicKeyListResponse(items=items, total=len(items))

Test:

  • Test lista keys con stats (200)
  • Test NO key values in risposta
  • Test senza token (401)

T39: Implementare Rate Limiting su API Pubblica

File: src/openrouter_monitor/middleware/rate_limit.py o src/openrouter_monitor/dependencies/rate_limit.py

Requisiti:

  • Rate limit per API token: 100 richieste/ora (default)
  • Rate limit per IP: 30 richieste/minuto (fallback)
  • Memorizzare contatori in memory (per MVP, Redis in futuro)
  • Header nelle risposte: X-RateLimit-Limit, X-RateLimit-Remaining
  • Ritorna 429 Too Many Requests quando limite raggiunto

Implementazione:

from fastapi import HTTPException, status, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from datetime import datetime, timedelta
from typing import Dict, Tuple
import time

# Simple in-memory rate limiting (use Redis in production)
class RateLimiter:
    def __init__(self):
        self._storage: Dict[str, Tuple[int, float]] = {}  # key: (count, reset_time)
    
    def is_allowed(self, key: str, limit: int, window_seconds: int) -> Tuple[bool, int, int]:
        """Check if request is allowed. Returns (allowed, remaining, limit)."""
        now = time.time()
        reset_time = now + window_seconds
        
        if key not in self._storage:
            self._storage[key] = (1, reset_time)
            return True, limit - 1, limit
        
        count, current_reset = self._storage[key]
        
        # Reset window if expired
        if now > current_reset:
            self._storage[key] = (1, reset_time)
            return True, limit - 1, limit
        
        # Check limit
        if count >= limit:
            return False, 0, limit
        
        self._storage[key] = (count + 1, current_reset)
        return True, limit - count - 1, limit

rate_limiter = RateLimiter()

async def rate_limit_by_token(
    credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False)),
    request: Request = None
) -> None:
    """Rate limiting dependency for API endpoints."""
    from openrouter_monitor.config import get_settings
    
    settings = get_settings()
    
    # Use token as key if available, otherwise IP
    if credentials:
        key = f"token:{credentials.credentials}"
        limit = settings.rate_limit_requests  # 100/hour
        window = settings.rate_limit_window   # 3600 seconds
    else:
        key = f"ip:{request.client.host}"
        limit = 30  # 30/minute for IP
        window = 60
    
    allowed, remaining, limit_total = rate_limiter.is_allowed(key, limit, window)
    
    if not allowed:
        raise HTTPException(
            status_code=status.HTTP_429_TOO_MANY_REQUESTS,
            detail="Rate limit exceeded. Try again later.",
            headers={"Retry-After": str(window)}
        )
    
    # Add rate limit headers to response (will be added by middleware)
    request.state.rate_limit_remaining = remaining
    request.state.rate_limit_limit = limit_total

class RateLimitHeadersMiddleware:
    def __init__(self, app):
        self.app = app
    
    async def __call__(self, scope, receive, send):
        if scope["type"] == "http":
            request = Request(scope, receive)
            
            async def send_with_headers(message):
                if message["type"] == "http.response.start":
                    headers = message.get("headers", [])
                    
                    # Add rate limit headers if available
                    if hasattr(request.state, 'rate_limit_remaining'):
                        headers.append(
                            (b"x-ratelimit-remaining", 
                             str(request.state.rate_limit_remaining).encode())
                        )
                        headers.append(
                            (b"x-ratelimit-limit",
                             str(request.state.rate_limit_limit).encode())
                        )
                    
                    message["headers"] = headers
                
                await send(message)
            
            await self.app(scope, receive, send_with_headers)
        else:
            await self.app(scope, receive, send)

Aggiungere ai router:

from openrouter_monitor.dependencies.rate_limit import rate_limit_by_token

@router.get("/stats", response_model=PublicStatsResponse, dependencies=[Depends(rate_limit_by_token)])
async def get_public_stats_endpoint(...):
    ...

Test:

  • Test rate limit token (100/ora)
  • Test rate limit IP (30/minuto)
  • Test 429 quando limite raggiunto
  • Test headers X-RateLimit-* presenti
  • Test reset dopo window

T40: Scrivere Test per API Pubblica

File: tests/unit/routers/test_public_api.py

Requisiti:

  • Test integrazione per tutti gli endpoint API pubblica
  • Mock/generare API token validi per test
  • Test rate limiting
  • Test sicurezza (token invalido, scaduto)
  • Coverage >= 90%

Test da implementare:

  • Stats Tests:

    • GET /api/v1/stats con token valido (200)
    • GET /api/v1/stats date default (30 giorni)
    • GET /api/v1/stats date custom
    • GET /api/v1/stats token invalido (401)
    • GET /api/v1/stats token scaduto (401)
    • GET /api/v1/stats aggiorna last_used_at
  • Usage Tests:

    • GET /api/v1/usage con filtri (200)
    • GET /api/v1/usage paginazione
    • GET /api/v1/usage senza token (401)
  • Keys Tests:

    • GET /api/v1/keys lista (200)
    • GET /api/v1/keys NO key values in risposta
  • Rate Limit Tests:

    • Test 100 richieste/ora
    • Test 429 dopo limite
    • Test headers rate limit
  • Security Tests:

    • User A non vede dati di user B con token di A
    • Token JWT non funziona su API pubblica (401)

🔄 WORKFLOW TDD

Per OGNI task:

  1. RED: Scrivi test che fallisce (prima del codice!)
  2. GREEN: Implementa codice minimo per passare il test
  3. REFACTOR: Migliora codice, test rimangono verdi

📁 STRUTTURA FILE DA CREARE

src/openrouter_monitor/
├── schemas/
│   ├── __init__.py              # Aggiungi export public_api
│   └── public_api.py            # T35
├── routers/
│   ├── __init__.py              # Aggiungi export public_api
│   └── public_api.py            # T36, T37, T38
├── dependencies/
│   ├── __init__.py              # Aggiungi export
│   ├── auth.py                  # Aggiungi get_current_user_from_api_token
│   └── rate_limit.py            # T39
├── middleware/
│   └── rate_limit.py            # T39 (opzionale)
└── main.py                      # Registra public_api router + middleware

tests/unit/
├── schemas/
│   └── test_public_api_schemas.py    # T35 + T40
├── dependencies/
│   └── test_rate_limit.py            # T39 + T40
└── routers/
    └── test_public_api.py            # T36-T38 + T40

🧪 ESEMPI TEST

Test Dependency API Token

@pytest.mark.asyncio
async def test_get_current_user_from_api_token_valid_returns_user(db_session, test_user):
    # Arrange
    token, token_hash = generate_api_token()
    api_token = ApiToken(user_id=test_user.id, token_hash=token_hash, name="Test")
    db_session.add(api_token)
    db_session.commit()
    
    # Act
    user = await get_current_user_from_api_token(token, db_session)
    
    # Assert
    assert user.id == test_user.id

Test Endpoint Stats

def test_public_stats_with_valid_token_returns_200(client, api_token):
    response = client.get(
        "/api/v1/stats",
        headers={"Authorization": f"Bearer {api_token}"}
    )
    assert response.status_code == 200
    assert "summary" in response.json()

Test Rate Limiting

def test_rate_limit_429_after_100_requests(client, api_token):
    # Make 100 requests
    for _ in range(100):
        response = client.get("/api/v1/stats", headers={"Authorization": f"Bearer {api_token}"})
        assert response.status_code == 200
    
    # 101st request should fail
    response = client.get("/api/v1/stats", headers={"Authorization": f"Bearer {api_token}"})
    assert response.status_code == 429

CRITERI DI ACCETTAZIONE

  • T35: Schemas API pubblica con validazione
  • T36: Endpoint /api/v1/stats con auth API token
  • T37: Endpoint /api/v1/usage con paginazione
  • T38: Endpoint /api/v1/keys con stats aggregate
  • T39: Rate limiting implementato (100/ora, 429)
  • T40: Test completi coverage >= 90%
  • get_current_user_from_api_token() dependency funzionante
  • Headers X-RateLimit-* presenti nelle risposte
  • Token JWT non funziona su API pubblica
  • 6 commit atomici con conventional commits
  • progress.md aggiornato

📝 COMMIT MESSAGES

feat(schemas): T35 add Pydantic public API schemas

feat(auth): add get_current_user_from_api_token dependency

feat(public-api): T36 implement GET /api/v1/stats endpoint

feat(public-api): T37 implement GET /api/v1/usage endpoint with pagination

feat(public-api): T38 implement GET /api/v1/keys endpoint

feat(rate-limit): T39 implement rate limiting for public API

test(public-api): T40 add comprehensive public API endpoint tests

🚀 VERIFICA FINALE

cd /home/google/Sources/LucaSacchiNet/openrouter-watcher

# Test schemas
pytest tests/unit/schemas/test_public_api_schemas.py -v

# Test dependencies
pytest tests/unit/dependencies/test_rate_limit.py -v

# Test routers
pytest tests/unit/routers/test_public_api.py -v --cov=src/openrouter_monitor/routers

# Test completo
pytest tests/unit/ -v --cov=src/openrouter_monitor

# Verifica endpoint manualmente
curl -H "Authorization: Bearer or_api_xxxxx" http://localhost:8000/api/v1/stats

📋 DIFFERENZE CHIAVE: API Pubblica vs Web API

Feature Web API (/api/auth, /api/keys) API Pubblica (/api/v1/*)
Auth JWT Bearer API Token Bearer
Scopo Gestione (CRUD) Lettura dati
Rate Limit No (o diverso) Sì (100/ora)
Audience Frontend web Integrazioni esterne
Token TTL 24 ore Illimitato (fino a revoca)

🔒 CONSIDERAZIONI SICUREZZA

Do's

  • Verificare sempre API token con hash in database
  • Aggiornare last_used_at ad ogni richiesta
  • Rate limiting per prevenire abusi
  • Non esporre mai API key values (cifrate)
  • Validare date (max range 365 giorni)

Don'ts

  • MAI accettare JWT su API pubblica
  • MAI loggare API token in plaintext
  • MAI ritornare dati di altri utenti
  • MAI bypassare rate limiting
  • MAI permettere range date > 365 giorni

📝 NOTE IMPORTANTI

  • Path assoluti: Usa sempre /home/google/Sources/LucaSacchiNet/openrouter-watcher/
  • Dependency: Crea get_current_user_from_api_token() separata da get_current_user()
  • Rate limiting: In-memory per MVP, Redis per produzione
  • Token format: API token inizia con or_api_, JWT no
  • last_used_at: Aggiornare ad ogni chiamata API pubblica

AGENTE: @tdd-developer

INIZIA CON: T35 - Pydantic public API schemas

QUANDO FINITO: Conferma completamento, coverage >= 90%, aggiorna progress.md