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:
@@ -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("/")
|
||||
|
||||
297
src/openrouter_monitor/routers/public_api.py
Normal file
297
src/openrouter_monitor/routers/public_api.py
Normal 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),
|
||||
)
|
||||
Reference in New Issue
Block a user