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
This commit is contained in:
Luca Sacchi Ricciardi
2026-04-07 16:15:49 +02:00
parent 3253293dd4
commit 88b43afa7e
2 changed files with 299 additions and 0 deletions

View File

@@ -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("/")

View File

@@ -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),
)