Files
openrouter-watcher/export/architecture.md
Luca Sacchi Ricciardi 75f40acb17 feat(setup): T01 create project directory structure
- Create src/openrouter_monitor/ package structure
- Create models/, routers/, services/, utils/ subpackages
- Create tests/unit/ and tests/integration/ structure
- Create alembic/, docs/, scripts/ directories
- Add test_project_structure.py with 13 unit tests
- All tests passing (13/13)

Refs: T01
2026-04-07 09:44:41 +02:00

34 KiB

Architecture Document

OpenRouter API Key Monitor - Fase 1 (MVP)


1. Stack Tecnologico

Componente Tecnologia Versione Note
Runtime Python 3.11+ Type hints, async/await
Web Framework FastAPI 0.104+ Async, automatic OpenAPI docs
Database SQLite 3.39+ Embedded, zero-config
ORM SQLAlchemy 2.0+ Modern declarative syntax
Migrations Alembic 1.12+ Database versioning
Auth python-jose 3.3+ JWT tokens
Password Hash bcrypt 4.1+ Industry standard
Encryption cryptography 41.0+ AES-256-GCM
Frontend HTMX 1.9+ Server-side rendering
CSS Tailwind CSS 3.4+ Utility-first
Background Tasks APScheduler 3.10+ Cron jobs
Testing pytest 7.4+ Async support
HTTP Client httpx 0.25+ Async requests
Validation Pydantic 2.5+ Data validation

2. Struttura Cartelle Progetto

/home/google/Sources/LucaSacchiNet/openrouter-watcher/
├── app/
│   ├── __init__.py
│   ├── main.py              # Entry point FastAPI
│   ├── config.py            # Configuration management
│   ├── database.py          # DB connection & session
│   ├── dependencies.py      # FastAPI dependencies
│   ├── models/              # SQLAlchemy models
│   │   ├── __init__.py
│   │   ├── user.py
│   │   ├── api_key.py
│   │   ├── usage_stats.py
│   │   └── api_token.py
│   ├── schemas/             # Pydantic schemas
│   │   ├── __init__.py
│   │   ├── auth.py
│   │   ├── user.py
│   │   ├── api_key.py
│   │   └── stats.py
│   ├── routers/             # API endpoints
│   │   ├── __init__.py
│   │   ├── auth.py          # Login/register/logout
│   │   ├── web.py           # HTML pages (HTMX)
│   │   ├── api_keys.py      # CRUD API keys
│   │   └── public_api.py    # Public API v1
│   ├── services/            # Business logic
│   │   ├── __init__.py
│   │   ├── auth_service.py
│   │   ├── encryption.py
│   │   ├── openrouter.py
│   │   └── stats_service.py
│   ├── templates/           # Jinja2 templates
│   │   ├── base.html
│   │   ├── login.html
│   │   ├── register.html
│   │   ├── dashboard.html
│   │   ├── keys.html
│   │   └── partials/        # HTMX fragments
│   ├── static/              # CSS, JS, images
│   │   ├── css/
│   │   └── js/
│   └── tasks/               # Background jobs
│       ├── __init__.py
│       └── sync.py
├── alembic/                 # Database migrations
│   ├── versions/
│   └── env.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py
│   ├── test_auth.py
│   ├── test_api_keys.py
│   └── test_public_api.py
├── docs/
├── export/                  # Project specifications
│   ├── prd.md
│   ├── architecture.md
│   ├── kanban.md
│   └── progress.md
├── .env.example             # Environment template
├── requirements.txt
├── requirements-dev.txt
├── alembic.ini
├── pytest.ini
└── README.md

3. Schema Database

3.1 Diagramma ER (ASCII)

┌─────────────┐       ┌─────────────┐       ┌─────────────┐
│    users    │       │   api_keys  │       │ usage_stats │
├─────────────┤       ├─────────────┤       ├─────────────┤
│ PK id       │◄──────┤ FK user_id  │◄──────┤ FK key_id   │
│    email    │   1:N │    name     │   1:N │    date     │
│    password │       │    key_enc  │       │    model    │
│    created  │       │    is_active│       │    requests │
│    updated  │       │    created  │       │    tokens_in│
│    active   │       │    last_used│       │    tokens_out│
└─────────────┘       └─────────────┘       │    cost     │
       ▲                                    └─────────────┘
       │                                           ▲
       │                                           │
       │                                    ┌──────┴──────┐
       │                                    │ api_tokens  │
       │                                    ├─────────────┤
       └────────────────────────────────────┤ FK user_id  │
                                        1:N │    token_hash
                                            │    name     │
                                            │    created  │
                                            │    last_used│
                                            │    active   │
                                            └─────────────┘

3.2 DDL SQL (SQLite)

-- =============================================
-- Table: users
-- =============================================
CREATE TABLE users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    email VARCHAR(255) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    is_active BOOLEAN DEFAULT 1,
    
    CONSTRAINT chk_email_format CHECK (email LIKE '%_@__%.__%')
);

CREATE INDEX idx_users_email ON users(email);

-- =============================================
-- Table: api_keys
-- =============================================
CREATE TABLE api_keys (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id INTEGER NOT NULL,
    name VARCHAR(100) NOT NULL,
    key_encrypted TEXT NOT NULL,  -- AES-256-GCM encrypted
    is_active BOOLEAN DEFAULT 1,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    last_used_at TIMESTAMP,
    
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE INDEX idx_api_keys_user ON api_keys(user_id);
CREATE INDEX idx_api_keys_active ON api_keys(is_active);

-- =============================================
-- Table: usage_stats
-- =============================================
CREATE TABLE usage_stats (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    api_key_id INTEGER NOT NULL,
    date DATE NOT NULL,
    model VARCHAR(100) NOT NULL,
    requests_count INTEGER DEFAULT 0,
    tokens_input INTEGER DEFAULT 0,
    tokens_output INTEGER DEFAULT 0,
    cost DECIMAL(10, 6) DEFAULT 0.0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    
    FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE,
    CONSTRAINT uniq_key_date_model UNIQUE (api_key_id, date, model)
);

CREATE INDEX idx_usage_key ON usage_stats(api_key_id);
CREATE INDEX idx_usage_date ON usage_stats(date);
CREATE INDEX idx_usage_model ON usage_stats(model);

-- =============================================
-- Table: api_tokens
-- =============================================
CREATE TABLE api_tokens (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id INTEGER NOT NULL,
    token_hash VARCHAR(255) NOT NULL,  -- SHA-256 hash of token
    name VARCHAR(100) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    last_used_at TIMESTAMP,
    is_active BOOLEAN DEFAULT 1,
    
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE INDEX idx_api_tokens_user ON api_tokens(user_id);
CREATE INDEX idx_api_tokens_hash ON api_tokens(token_hash);
CREATE INDEX idx_api_tokens_active ON api_tokens(is_active);

-- =============================================
-- Triggers per updated_at
-- =============================================
CREATE TRIGGER update_users_timestamp 
AFTER UPDATE ON users
BEGIN
    UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;

3.3 SQLAlchemy Models

# app/models/user.py
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, Boolean
from sqlalchemy.orm import relationship
from app.database import Base

class User(Base):
    __tablename__ = "users"
    
    id = Column(Integer, primary_key=True, index=True)
    email = Column(String(255), unique=True, index=True, nullable=False)
    password_hash = Column(String(255), nullable=False)
    created_at = Column(DateTime, default=datetime.utcnow)
    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
    is_active = Column(Boolean, default=True)
    
    api_keys = relationship("ApiKey", back_populates="user", cascade="all, delete-orphan")
    api_tokens = relationship("ApiToken", back_populates="user", cascade="all, delete-orphan")

# app/models/api_key.py
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey
from sqlalchemy.orm import relationship
from app.database import Base

class ApiKey(Base):
    __tablename__ = "api_keys"
    
    id = Column(Integer, primary_key=True, index=True)
    user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
    name = Column(String(100), nullable=False)
    key_encrypted = Column(String, nullable=False)
    is_active = Column(Boolean, default=True)
    created_at = Column(DateTime, default=datetime.utcnow)
    last_used_at = Column(DateTime, nullable=True)
    
    user = relationship("User", back_populates="api_keys")
    usage_stats = relationship("UsageStats", back_populates="api_key", cascade="all, delete-orphan")

# app/models/usage_stats.py
from datetime import date, datetime
from sqlalchemy import Column, Integer, String, Date, DateTime, Numeric, ForeignKey
from sqlalchemy.orm import relationship
from app.database import Base

class UsageStats(Base):
    __tablename__ = "usage_stats"
    
    id = Column(Integer, primary_key=True, index=True)
    api_key_id = Column(Integer, ForeignKey("api_keys.id"), nullable=False)
    date = Column(Date, nullable=False)
    model = Column(String(100), nullable=False)
    requests_count = Column(Integer, default=0)
    tokens_input = Column(Integer, default=0)
    tokens_output = Column(Integer, default=0)
    cost = Column(Numeric(10, 6), default=0.0)
    created_at = Column(DateTime, default=datetime.utcnow)
    
    api_key = relationship("ApiKey", back_populates="usage_stats")

# app/models/api_token.py
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey
from sqlalchemy.orm import relationship
from app.database import Base

class ApiToken(Base):
    __tablename__ = "api_tokens"
    
    id = Column(Integer, primary_key=True, index=True)
    user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
    token_hash = Column(String(255), nullable=False, index=True)
    name = Column(String(100), nullable=False)
    created_at = Column(DateTime, default=datetime.utcnow)
    last_used_at = Column(DateTime, nullable=True)
    is_active = Column(Boolean, default=True)
    
    user = relationship("User", back_populates="api_tokens")

4. Diagramma Flusso Autenticazione

4.1 Web Authentication Flow (Session/JWT)

┌──────────┐     POST /auth/login      ┌──────────┐
│  Client  │ ─────────────────────────► │  Server  │
│  (Web)   │    {email, password}       │          │
└──────────┘                            └────┬─────┘
                                             │
                                             ▼
                                    ┌────────────────┐
                                    │  Validate      │
                                    │  credentials   │
                                    │  (bcrypt)      │
                                    └────┬───────────┘
                                         │
                                         ▼
                                    ┌────────────────┐
                                    │  Generate JWT  │
                                    │  (HS256)       │
                                    └────┬───────────┘
                                         │
                                         ▼
┌──────────┐     200 + JWT Cookie    ┌──────────┐
│  Client  │ ◄──────────────────────── │  Server  │
│  (Web)   │    {access_token}         │          │
└────┬─────┘                            └──────────┘
     │
     │ Include JWT in Cookie/Header
     ▼
┌──────────┐     GET /dashboard      ┌──────────┐
│  Client  │ ──────────────────────► │  Server  │
│  (Web)   │    Cookie: jwt=xxx      │          │
└──────────┘                            └────┬─────┘
                                             │
                                             ▼
                                    ┌────────────────┐
                                    │  Verify JWT    │
                                    │  Signature     │
                                    └────┬───────────┘
                                         │
                              Valid? ────┼────┐
                                         │    │ No
                                         ▼    ▼
                                    ┌────────┐  ┌──────────┐
                                    │ Return │  │ 401      │
                                    │ Data   │  │ Unauthorized
                                    └────┬───┘  └────┬─────┘
                                         │           │
                                         ▼           ▼
                                    ┌──────────┐  ┌──────────┐
                                    │  Client  │  │  Client  │
                                    │  (Web)   │  │  (Web)   │
                                    └──────────┘  └──────────┘

4.2 API Token Authentication Flow (Public API)

┌──────────┐    POST /api/v1/tokens   ┌──────────┐
│  Client  │ ───────────────────────► │  Server  │
│  (Web)   │    {name: "My Token"}    │          │
│  (Auth)  │    Bearer <JWT>          │          │
└──────────┘                            └────┬─────┘
                                             │
                                             ▼
                                    ┌────────────────┐
                                    │  Generate      │
                                    │  random token  │
                                    │  (64 chars)    │
                                    └────┬───────────┘
                                         │
                                         ▼
                                    ┌────────────────┐
                                    │  Hash token    │
                                    │  (SHA-256)     │
                                    │  Store in DB   │
                                    └────┬───────────┘
                                         │
                                         ▼
┌──────────┐     201 + Plain Token   ┌──────────┐
│  Client  │ ◄──────────────────────── │  Server  │
│  (Web)   │    {token: "abc123..."}   │          │
│  (Save)  │    ⚠️ Only shown once     │          │
└──────────┘                            └──────────┘

────────────────────────────────────────────────────────────

┌──────────┐     GET /api/v1/stats   ┌──────────┐
│  Client  │ ──────────────────────► │  Server  │
│  (Ext)   │    Authorization:       │          │
│          │    Bearer abc123...     │          │
└──────────┘                            └────┬─────┘
                                             │
                                             ▼
                                    ┌────────────────┐
                                    │  Extract token │
                                    │  from header   │
                                    └────┬───────────┘
                                         │
                                         ▼
                                    ┌────────────────┐
                                    │  Hash token    │
                                    │  (SHA-256)     │
                                    └────┬───────────┘
                                         │
                                         ▼
                                    ┌────────────────┐
                                    │  Lookup in DB  │
                                    │  (api_tokens)  │
                                    └────┬───────────┘
                                         │
                              Found? ────┼────┐
                                         │    │ No
                                         ▼    ▼
                                    ┌────────┐  ┌──────────┐
                                    │ Update │  │ 401      │
                                    │last_used│  │ Invalid  │
                                    └───┬────┘  │ Token    │
                                        │       └────┬─────┘
                                        ▼            │
                                    ┌──────────┐     │
                                    │ Return   │     │
                                    │ Stats    │     │
                                    └────┬─────┘     │
                                         │           │
                                         ▼           ▼
                                    ┌──────────┐  ┌──────────┐
                                    │  Client  │  │  Client  │
                                    │  (Ext)   │  │  (Ext)   │
                                    └──────────┘  └──────────┘

5. Interfacce API

5.1 Web Routes (HTML + HTMX)

Method Path Auth Description Response
GET / No Redirect to dashboard or login 302 Redirect
GET /login No Login form page HTML form
POST /login No Submit login credentials Redirect or error
GET /register No Registration form page HTML form
POST /register No Submit registration Redirect or error
GET /logout Yes Logout user Redirect to login
GET /dashboard Yes Main dashboard view HTML dashboard
GET /keys Yes API keys list page HTML table
POST /keys Yes Create new API key HTMX fragment
PUT /keys/{id} Yes Update API key HTMX fragment
DELETE /keys/{id} Yes Delete API key HTMX fragment (empty)
GET /profile Yes User profile page HTML form
PUT /profile Yes Update profile/password Redirect or error

5.2 REST API (JSON)

Auth Endpoints

POST /api/auth/login

Request:
POST /api/auth/login
Content-Type: application/json

{
  "email": "user@example.com",
  "password": "securepassword"
}

Response 200:
{
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "token_type": "bearer",
  "expires_in": 3600
}

Response 401:
{
  "detail": "Invalid credentials"
}

POST /api/auth/register

Request:
POST /api/auth/register
Content-Type: application/json

{
  "email": "user@example.com",
  "password": "securepassword",
  "password_confirm": "securepassword"
}

Response 201:
{
  "id": 1,
  "email": "user@example.com",
  "created_at": "2024-01-01T00:00:00Z"
}

Response 400:
{
  "detail": "Email already registered"
}

API Keys Management

GET /api/keys

Request:
GET /api/keys
Authorization: Bearer <jwt_token>

Response 200:
{
  "items": [
    {
      "id": 1,
      "name": "Production Key",
      "is_active": true,
      "created_at": "2024-01-01T00:00:00Z",
      "last_used_at": "2024-01-15T10:30:00Z"
    }
  ],
  "total": 1
}

POST /api/keys

Request:
POST /api/keys
Authorization: Bearer <jwt_token>
Content-Type: application/json

{
  "name": "My New Key",
  "key": "sk-or-v1-abc123..."  // OpenRouter API key
}

Response 201:
{
  "id": 2,
  "name": "My New Key",
  "is_active": true,
  "created_at": "2024-01-15T12:00:00Z"
}

Response 400:
{
  "detail": "Invalid API key format"
}

PUT /api/keys/{id}

Request:
PUT /api/keys/1
Authorization: Bearer <jwt_token>
Content-Type: application/json

{
  "name": "Updated Name",
  "is_active": false
}

Response 200:
{
  "id": 1,
  "name": "Updated Name",
  "is_active": false,
  "created_at": "2024-01-01T00:00:00Z"
}

DELETE /api/keys/{id}

Request:
DELETE /api/keys/1
Authorization: Bearer <jwt_token>

Response 204: (No content)

Public API v1 (External Access)

GET /api/v1/stats

Request:
GET /api/v1/stats
Authorization: Bearer <api_token>

Response 200:
{
  "summary": {
    "total_requests": 15234,
    "total_cost": 125.50,
    "total_tokens_input": 450000,
    "total_tokens_output": 180000
  },
  "by_model": [
    {
      "model": "anthropic/claude-3-opus",
      "requests": 5234,
      "cost": 89.30
    },
    {
      "model": "openai/gpt-4",
      "requests": 10000,
      "cost": 36.20
    }
  ]
}

Response 401:
{
  "detail": "Invalid or expired token"
}

GET /api/v1/usage

Request:
GET /api/v1/usage?start_date=2024-01-01&end_date=2024-01-31&page=1&limit=100
Authorization: Bearer <api_token>

Response 200:
{
  "items": [
    {
      "date": "2024-01-15",
      "model": "anthropic/claude-3-opus",
      "requests": 234,
      "tokens_input": 45000,
      "tokens_output": 12000,
      "cost": 8.92
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 100,
    "total": 45,
    "pages": 1
  }
}

GET /api/v1/keys

Request:
GET /api/v1/keys
Authorization: Bearer <api_token>

Response 200:
{
  "items": [
    {
      "id": 1,
      "name": "Production Key",
      "is_active": true,
      "stats": {
        "total_requests": 15234,
        "total_cost": 125.50
      }
    }
  ]
}

API Token Management

POST /api/tokens

Request:
POST /api/tokens
Authorization: Bearer <jwt_token>
Content-Type: application/json

{
  "name": "Integration Token"
}

Response 201:
{
  "id": 1,
  "name": "Integration Token",
  "token": "or_api_abc123xyz789...",  // Only shown once!
  "created_at": "2024-01-15T12:00:00Z"
}

GET /api/tokens

Response 200:
{
  "items": [
    {
      "id": 1,
      "name": "Integration Token",
      "created_at": "2024-01-15T12:00:00Z",
      "last_used_at": "2024-01-15T14:30:00Z",
      "is_active": true
    }
  ]
}

DELETE /api/tokens/{id}

Response 204: (No content)

6. Piano Sicurezza

6.1 Cifratura

Dato Algoritmo Implementazione
API Keys AES-256-GCM cryptography.fernet with custom key
Passwords bcrypt passlib.hash.bcrypt (12 rounds)
API Tokens SHA-256 Only hash stored, never plaintext
JWT HS256 python-jose with 256-bit secret

6.2 Implementation Details

# Encryption Service (app/services/encryption.py)
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
import base64
import os

class EncryptionService:
    """
    AES-256-GCM encryption for sensitive data.
    """
    
    def __init__(self, master_key: str):
        self._fernet = self._derive_key(master_key)
    
    def _derive_key(self, master_key: str) -> Fernet:
        """Derive Fernet key from master key using PBKDF2."""
        kdf = PBKDF2HMAC(
            algorithm=hashes.SHA256(),
            length=32,
            salt=os.urandom(16),
            iterations=100000,
        )
        key = base64.urlsafe_b64encode(kdf.derive(master_key.encode()))
        return Fernet(key)
    
    def encrypt(self, plaintext: str) -> str:
        """Encrypt plaintext and return base64-encoded ciphertext."""
        return self._fernet.encrypt(plaintext.encode()).decode()
    
    def decrypt(self, ciphertext: str) -> str:
        """Decrypt ciphertext and return plaintext."""
        return self._fernet.decrypt(ciphertext.encode()).decode()

# Password Hashing
from passlib.context import CryptContext

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

def hash_password(password: str) -> str:
    return pwd_context.hash(password)

def verify_password(plain_password: str, hashed_password: str) -> bool:
    return pwd_context.verify(plain_password, hashed_password)

# API Token Generation
import secrets
import hashlib

def generate_api_token() -> tuple[str, str]:
    """
    Generate a new API token.
    Returns: (plaintext_token, token_hash)
    """
    token = "or_api_" + secrets.token_urlsafe(48)  # 64 chars total
    token_hash = hashlib.sha256(token.encode()).hexdigest()
    return token, token_hash

6.3 Rate Limiting

# Rate limiting configuration (app/config.py)
from slowapi import Limiter
from slowapi.util import get_remote_address

limiter = Limiter(key_func=get_remote_address)

# Limits
AUTH_RATE_LIMIT = "5/minute"      # Login/register attempts
API_RATE_LIMIT = "100/hour"       # Public API calls per token
WEB_RATE_LIMIT = "30/minute"      # Web endpoint calls per IP

6.4 Security Headers

# FastAPI middleware (app/main.py)
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware

app.add_middleware(
    TrustedHostMiddleware, 
    allowed_hosts=["localhost", "*.example.com"]
)

@app.middleware("http")
async def security_headers(request, call_next):
    response = await call_next(request)
    response.headers["X-Content-Type-Options"] = "nosniff"
    response.headers["X-Frame-Options"] = "DENY"
    response.headers["X-XSS-Protection"] = "1; mode=block"
    response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
    return response

6.5 Input Validation

# Pydantic schemas with validation (app/schemas/auth.py)
from pydantic import BaseModel, EmailStr, Field, validator
import re

class UserRegister(BaseModel):
    email: EmailStr
    password: str = Field(..., min_length=12, max_length=128)
    password_confirm: str
    
    @validator('password')
    def password_strength(cls, v):
        if not re.search(r'[A-Z]', v):
            raise ValueError('Password must contain uppercase letter')
        if not re.search(r'[a-z]', v):
            raise ValueError('Password must contain lowercase letter')
        if not re.search(r'\d', v):
            raise ValueError('Password must contain digit')
        if not re.search(r'[!@#$%^&*]', v):
            raise ValueError('Password must contain special character')
        return v
    
    @validator('password_confirm')
    def passwords_match(cls, v, values):
        if 'password' in values and v != values['password']:
            raise ValueError('Passwords do not match')
        return v

7. Configurazione Ambiente

7.1 Variabili d'Ambiente Richieste

Variable Type Default Required Description
DATABASE_URL str sqlite:///./app.db No SQLite database path
SECRET_KEY str - Yes JWT signing key (min 32 chars)
ENCRYPTION_KEY str - Yes AES-256 master key (32 bytes)
OPENROUTER_API_URL str https://openrouter.ai/api/v1 No OpenRouter base URL
SYNC_INTERVAL_MINUTES int 60 No Stats sync interval
MAX_API_KEYS_PER_USER int 10 No API keys limit per user
RATE_LIMIT_REQUESTS int 100 No API requests per window
RATE_LIMIT_WINDOW int 3600 No Rate limit window (seconds)
JWT_EXPIRATION_HOURS int 24 No JWT token lifetime
DEBUG bool false No Enable debug mode
LOG_LEVEL str INFO No Logging level

7.2 File .env.example

# ===========================================
# OpenRouter API Key Monitor - Configuration
# ===========================================

# Database
DATABASE_URL=sqlite:///./data/app.db

# Security - REQUIRED
# Generate with: openssl rand -hex 32
SECRET_KEY=your-super-secret-jwt-key-min-32-chars
ENCRYPTION_KEY=your-32-byte-encryption-key-here

# OpenRouter Integration
OPENROUTER_API_URL=https://openrouter.ai/api/v1

# Background Tasks
SYNC_INTERVAL_MINUTES=60

# Limits
MAX_API_KEYS_PER_USER=10
RATE_LIMIT_REQUESTS=100
RATE_LIMIT_WINDOW=3600

# JWT
JWT_EXPIRATION_HOURS=24

# Development
DEBUG=false
LOG_LEVEL=INFO

7.3 Pydantic Settings (app/config.py)

from pydantic_settings import BaseSettings
from functools import lru_cache

class Settings(BaseSettings):
    # Database
    database_url: str = "sqlite:///./app.db"
    
    # Security
    secret_key: str
    encryption_key: str
    jwt_expiration_hours: int = 24
    
    # OpenRouter
    openrouter_api_url: str = "https://openrouter.ai/api/v1"
    
    # Task scheduling
    sync_interval_minutes: int = 60
    
    # Limits
    max_api_keys_per_user: int = 10
    rate_limit_requests: int = 100
    rate_limit_window: int = 3600
    
    # App settings
    debug: bool = False
    log_level: str = "INFO"
    
    class Config:
        env_file = ".env"
        env_file_encoding = "utf-8"

@lru_cache()
def get_settings() -> Settings:
    return Settings()

8. Task Scheduler (APScheduler)

8.1 Background Jobs Configuration

# app/tasks/sync.py
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
from app.services.openrouter import OpenRouterService
from app.database import SessionLocal

scheduler = BackgroundScheduler()

def sync_usage_stats():
    """Sync usage statistics from OpenRouter for all active keys."""
    db = SessionLocal()
    try:
        service = OpenRouterService(db)
        service.sync_all_keys()
    finally:
        db.close()

def validate_api_keys():
    """Validate all API keys and update status."""
    db = SessionLocal()
    try:
        service = OpenRouterService(db)
        service.validate_all_keys()
    finally:
        db.close()

# Schedule jobs
scheduler.add_job(
    sync_usage_stats,
    trigger=IntervalTrigger(minutes=60),
    id="sync_usage",
    replace_existing=True
)

scheduler.add_job(
    validate_api_keys,
    trigger=IntervalTrigger(hours=24),
    id="validate_keys",
    replace_existing=True
)

def start_scheduler():
    scheduler.start()

9. Testing Strategy

9.1 Test Structure

tests/
├── conftest.py              # Shared fixtures
├── test_auth.py             # Authentication tests
├── test_api_keys.py         # API key CRUD tests
├── test_public_api.py       # Public API tests
├── test_encryption.py       # Encryption service tests
└── test_integration.py      # Integration tests

9.2 Key Test Cases

Component Test Case Expected
Auth Register with valid data 201, user created
Auth Register with duplicate email 400 error
Auth Login with valid credentials 200, JWT returned
Auth Login with wrong password 401 error
API Keys Create key with valid data 201, key encrypted
API Keys Create key without auth 401 error
API Keys Delete own key 204 success
API Keys Delete other user's key 403 forbidden
Public API Access with valid token 200 + data
Public API Access with invalid token 401 error
Encryption Encrypt and decrypt Original value
Encryption Wrong key decryption Exception raised

10. Deployment

10.1 Docker Configuration

# Dockerfile
FROM python:3.11-slim

WORKDIR /app

# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy application
COPY app/ ./app/
COPY alembic/ ./alembic/
COPY alembic.ini .

# Create data directory
RUN mkdir -p /app/data

# Environment
ENV PYTHONPATH=/app
ENV DATABASE_URL=sqlite:///./data/app.db

# Run migrations and start
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
# docker-compose.yml
version: '3.8'

services:
  app:
    build: .
    ports:
      - "8000:8000"
    environment:
      - SECRET_KEY=${SECRET_KEY}
      - ENCRYPTION_KEY=${ENCRYPTION_KEY}
      - DEBUG=false
    volumes:
      - ./data:/app/data
    restart: unless-stopped

Document Version: 1.0
Last Updated: 2024-01-15
Status: MVP Specification