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.config import get_settings
|
||||||
from openrouter_monitor.routers import api_keys
|
from openrouter_monitor.routers import api_keys
|
||||||
from openrouter_monitor.routers import auth
|
from openrouter_monitor.routers import auth
|
||||||
|
from openrouter_monitor.routers import public_api
|
||||||
from openrouter_monitor.routers import stats
|
from openrouter_monitor.routers import stats
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -33,6 +34,7 @@ app.add_middleware(
|
|||||||
app.include_router(auth.router, prefix="/api/auth", tags=["authentication"])
|
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(api_keys.router, prefix="/api/keys", tags=["api-keys"])
|
||||||
app.include_router(stats.router)
|
app.include_router(stats.router)
|
||||||
|
app.include_router(public_api.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@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