From 88b43afa7e77ca2351b9df71e335d7fa73bfa4c0 Mon Sep 17 00:00:00 2001 From: Luca Sacchi Ricciardi Date: Tue, 7 Apr 2026 16:15:49 +0200 Subject: [PATCH] feat(public-api): T36-T38 implement public API endpoints - GET /api/v1/stats: aggregated stats with date range (default 30 days) - GET /api/v1/usage: paginated usage with required date filters - GET /api/v1/keys: key list with stats, no key values exposed - All endpoints use API token auth and rate limiting --- src/openrouter_monitor/main.py | 2 + src/openrouter_monitor/routers/public_api.py | 297 +++++++++++++++++++ 2 files changed, 299 insertions(+) create mode 100644 src/openrouter_monitor/routers/public_api.py diff --git a/src/openrouter_monitor/main.py b/src/openrouter_monitor/main.py index f7ee135..e5aac48 100644 --- a/src/openrouter_monitor/main.py +++ b/src/openrouter_monitor/main.py @@ -8,6 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware from openrouter_monitor.config import get_settings from openrouter_monitor.routers import api_keys from openrouter_monitor.routers import auth +from openrouter_monitor.routers import public_api from openrouter_monitor.routers import stats settings = get_settings() @@ -33,6 +34,7 @@ app.add_middleware( app.include_router(auth.router, prefix="/api/auth", tags=["authentication"]) app.include_router(api_keys.router, prefix="/api/keys", tags=["api-keys"]) app.include_router(stats.router) +app.include_router(public_api.router) @app.get("/") diff --git a/src/openrouter_monitor/routers/public_api.py b/src/openrouter_monitor/routers/public_api.py new file mode 100644 index 0000000..6de62ef --- /dev/null +++ b/src/openrouter_monitor/routers/public_api.py @@ -0,0 +1,297 @@ +"""Public API v1 router for OpenRouter API Key Monitor. + +T36-T38: Public API endpoints for external access. +These endpoints use API token authentication (not JWT). +""" +from datetime import date, timedelta +from decimal import Decimal +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status +from sqlalchemy import func +from sqlalchemy.orm import Session + +from openrouter_monitor.database import get_db +from openrouter_monitor.dependencies import get_current_user_from_api_token, rate_limit_dependency +from openrouter_monitor.models import ApiKey, UsageStats, User +from openrouter_monitor.schemas.public_api import ( + PaginationInfo, + PeriodInfo, + PublicKeyInfo, + PublicKeyListResponse, + PublicStatsResponse, + PublicUsageItem, + PublicUsageResponse, + SummaryInfo, +) + +# Create router +router = APIRouter( + prefix="/api/v1", + tags=["Public API v1"], + responses={ + 401: {"description": "Invalid or missing API token"}, + 429: {"description": "Rate limit exceeded"}, + } +) + + +@router.get( + "/stats", + response_model=PublicStatsResponse, + summary="Get aggregated statistics", + description="Get aggregated usage statistics for the authenticated user's API keys. " + "Default period is last 30 days if dates not specified.", +) +async def get_stats( + response: Response, + start_date: Optional[date] = Query( + None, + description="Start date for the period (default: 30 days ago)" + ), + end_date: Optional[date] = Query( + None, + description="End date for the period (default: today)" + ), + current_user: User = Depends(get_current_user_from_api_token), + db: Session = Depends(get_db), + rate_limit: dict = Depends(rate_limit_dependency), +) -> PublicStatsResponse: + """Get aggregated statistics for the user's API keys. + + Args: + start_date: Start of period (default: 30 days ago) + end_date: End of period (default: today) + current_user: Authenticated user from API token + db: Database session + + Returns: + PublicStatsResponse with summary and period info + """ + # Set default dates if not provided + if end_date is None: + end_date = date.today() + if start_date is None: + start_date = end_date - timedelta(days=29) # 30 days total + + # Build query with join to ApiKey for user filtering + query = ( + db.query( + func.coalesce(func.sum(UsageStats.requests_count), 0).label("total_requests"), + func.coalesce(func.sum(UsageStats.cost), Decimal("0")).label("total_cost"), + func.coalesce(func.sum(UsageStats.tokens_input), 0).label("total_tokens_input"), + func.coalesce(func.sum(UsageStats.tokens_output), 0).label("total_tokens_output"), + ) + .join(ApiKey, UsageStats.api_key_id == ApiKey.id) + .filter(ApiKey.user_id == current_user.id) + .filter(UsageStats.date >= start_date) + .filter(UsageStats.date <= end_date) + ) + + result = query.first() + + # Calculate total tokens + total_tokens = ( + int(result.total_tokens_input or 0) + + int(result.total_tokens_output or 0) + ) + + # Calculate days in period + days = (end_date - start_date).days + 1 + + summary = SummaryInfo( + total_requests=int(result.total_requests or 0), + total_cost=Decimal(str(result.total_cost or 0)), + total_tokens=total_tokens, + ) + + period = PeriodInfo( + start_date=start_date, + end_date=end_date, + days=days, + ) + + # Add rate limit headers + response.headers["X-RateLimit-Limit"] = str(rate_limit["X-RateLimit-Limit"]) + response.headers["X-RateLimit-Remaining"] = str(rate_limit["X-RateLimit-Remaining"]) + + return PublicStatsResponse(summary=summary, period=period) + + +@router.get( + "/usage", + response_model=PublicUsageResponse, + summary="Get detailed usage data", + description="Get paginated detailed usage statistics. Start and end dates are required.", +) +async def get_usage( + response: Response, + start_date: date = Query( + ..., + description="Start date for the query period (required)" + ), + end_date: date = Query( + ..., + description="End date for the query period (required)" + ), + page: int = Query( + 1, + ge=1, + description="Page number (1-indexed)" + ), + limit: int = Query( + 100, + ge=1, + le=1000, + description="Items per page (max 1000)" + ), + current_user: User = Depends(get_current_user_from_api_token), + db: Session = Depends(get_db), + rate_limit: dict = Depends(rate_limit_dependency), +) -> PublicUsageResponse: + """Get detailed usage statistics with pagination. + + Args: + start_date: Start of query period (required) + end_date: End of query period (required) + page: Page number (default: 1) + limit: Items per page (default: 100, max: 1000) + current_user: Authenticated user from API token + db: Database session + + Returns: + PublicUsageResponse with items and pagination info + """ + # Calculate offset for pagination + offset = (page - 1) * limit + + # Build query with join to ApiKey for user filtering and name + query = ( + db.query( + UsageStats.date, + ApiKey.name.label("api_key_name"), + UsageStats.model, + UsageStats.requests_count, + UsageStats.tokens_input, + UsageStats.tokens_output, + UsageStats.cost, + ) + .join(ApiKey, UsageStats.api_key_id == ApiKey.id) + .filter(ApiKey.user_id == current_user.id) + .filter(UsageStats.date >= start_date) + .filter(UsageStats.date <= end_date) + ) + + # Get total count for pagination + count_query = ( + db.query(func.count(UsageStats.id)) + .join(ApiKey, UsageStats.api_key_id == ApiKey.id) + .filter(ApiKey.user_id == current_user.id) + .filter(UsageStats.date >= start_date) + .filter(UsageStats.date <= end_date) + ) + total = count_query.scalar() or 0 + + # Apply ordering and pagination + results = ( + query.order_by(UsageStats.date.desc(), ApiKey.name, UsageStats.model) + .offset(offset) + .limit(limit) + .all() + ) + + # Convert to response items + items = [ + PublicUsageItem( + date=row.date, + api_key_name=row.api_key_name, + model=row.model, + requests_count=row.requests_count, + tokens_input=row.tokens_input, + tokens_output=row.tokens_output, + cost=row.cost, + ) + for row in results + ] + + # Calculate total pages + pages = (total + limit - 1) // limit + + pagination = PaginationInfo( + page=page, + limit=limit, + total=total, + pages=pages, + ) + + # Add rate limit headers + response.headers["X-RateLimit-Limit"] = str(rate_limit["X-RateLimit-Limit"]) + response.headers["X-RateLimit-Remaining"] = str(rate_limit["X-RateLimit-Remaining"]) + + return PublicUsageResponse(items=items, pagination=pagination) + + +@router.get( + "/keys", + response_model=PublicKeyListResponse, + summary="Get API keys with statistics", + description="Get list of API keys with aggregated statistics. " + "NOTE: Actual API key values are NOT returned for security.", +) +async def get_keys( + response: Response, + current_user: User = Depends(get_current_user_from_api_token), + db: Session = Depends(get_db), + rate_limit: dict = Depends(rate_limit_dependency), +) -> PublicKeyListResponse: + """Get API keys with aggregated statistics. + + IMPORTANT: This endpoint does NOT return the actual API key values, + only metadata and aggregated statistics. + + Args: + current_user: Authenticated user from API token + db: Database session + + Returns: + PublicKeyListResponse with key info and statistics + """ + # Get all API keys for the user + api_keys = ( + db.query(ApiKey) + .filter(ApiKey.user_id == current_user.id) + .all() + ) + + # Build key info with statistics + items = [] + for key in api_keys: + # Get aggregated stats for this key + stats_result = ( + db.query( + func.coalesce(func.sum(UsageStats.requests_count), 0).label("total_requests"), + func.coalesce(func.sum(UsageStats.cost), Decimal("0")).label("total_cost"), + ) + .filter(UsageStats.api_key_id == key.id) + .first() + ) + + key_info = PublicKeyInfo( + id=key.id, + name=key.name, + is_active=key.is_active, + stats={ + "total_requests": int(stats_result.total_requests or 0), + "total_cost": str(Decimal(str(stats_result.total_cost or 0))), + } + ) + items.append(key_info) + + # Add rate limit headers + response.headers["X-RateLimit-Limit"] = str(rate_limit["X-RateLimit-Limit"]) + response.headers["X-RateLimit-Remaining"] = str(rate_limit["X-RateLimit-Remaining"]) + + return PublicKeyListResponse( + items=items, + total=len(items), + ) \ No newline at end of file