import time import logging from typing import Optional from fastapi import HTTPException, Security, status from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials import redis.asyncio as aioredis from inferroute.config import settings from inferroute.observability import RATE_LIMITED_TOTAL logger = logging.getLogger("inferroute.auth") security = HTTPBearer() # Static API key mapping for MVP demo API_KEYS = { "sk-inferroute-demo": "acme_corp", "sk-inferroute-dev": "internal_dev", settings.ADMIN_API_KEY: "admin" } # Redis Client Instance placeholder (initialized in main.py) redis_client: Optional[aioredis.Redis] = None def get_redis_client() -> Optional[aioredis.Redis]: global redis_client return redis_client from inferroute.database import async_session from inferroute.models import UserWallet from sqlalchemy import select async def check_wallet_balance(tenant_id: str) -> None: """ Checks if a tenant has a positive wallet balance. If not, raises 402 Payment Required. Automatically creates a wallet with a trial balance if it doesn't exist. Fails open (logs a warning and continues) if Database is unavailable. """ if tenant_id == "admin": return try: async with async_session() as session: result = await session.execute( select(UserWallet).where(UserWallet.tenant_id == tenant_id) ) wallet = result.scalar_one_or_none() if wallet is None: wallet = UserWallet(tenant_id=tenant_id, balance_usd=5.0) session.add(wallet) await session.commit() logger.info(f"Created new trial wallet for tenant={tenant_id} with $5.00") return if wallet.balance_usd <= 0.0: raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail="Payment Required: Wallet balance dry. Please recharge." ) except HTTPException: raise except Exception as e: logger.error(f"Database error during wallet balance check: {e}. Bypassing wallet check.") return async def verify_api_key(credentials: HTTPAuthorizationCredentials = Security(security)) -> str: """ Verifies the provided API key and returns the associated tenant ID. Supports standard Bearer token header. """ token = credentials.credentials if token in API_KEYS: tenant_id = API_KEYS[token] await check_wallet_balance(tenant_id) return tenant_id raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API Key" ) async def check_rate_limit(tenant_id: str) -> None: """ Checks request rate limits for a given tenant using Redis. Limits requests per minute (RPM). Fails open (logs a warning and continues) if Redis is unavailable. """ client = get_redis_client() if client is None: logger.warning("Redis is not configured. Skipping rate limit checks.") return try: # Determine tenant limit limit = settings.DEFAULT_RATE_LIMIT_RPM if tenant_id == "admin": limit = 999999 # effectively unlimited current_minute = int(time.time() / 60) key = f"rate_limit:{tenant_id}:{current_minute}" # Increment request count for current minute window requests = await client.incr(key) if requests == 1: await client.expire(key, 60) if requests > limit: RATE_LIMITED_TOTAL.labels(scope="tenant").inc() raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=f"Rate limit exceeded. Maximum allowed is {limit} RPM." ) except HTTPException: raise except Exception as e: logger.error(f"Redis rate limiter exception: {e}. Bypassing rate limiting.") return def extract_byok_keys(request) -> dict[str, str]: """ Extracts Bring Your Own Key (BYOK) headers from the incoming request. Supported headers: - X-OpenAI-Api-Key - X-Gemini-Api-Key - X-Anthropic-Api-Key """ if not request or not hasattr(request, "headers"): return {} byok = {} header_map = { "x-openai-api-key": "openai", "x-gemini-api-key": "gemini", "x-anthropic-api-key": "anthropic", } for header_name, provider in header_map.items(): val = request.headers.get(header_name) if val: byok[provider] = val return byok async def check_budget_limits(tenant_id: str, estimated_cost_usd: float = 0.0, max_cost_limit_usd: float = 0.50) -> None: """ Validates per-request estimated budget limit for a tenant. Rejects requests that exceed single-request USD cost caps. """ if tenant_id == "admin": return if estimated_cost_usd > max_cost_limit_usd: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Budget Guardrail Triggered: Request estimated cost (${estimated_cost_usd:.4f}) exceeds maximum allowed cap (${max_cost_limit_usd:.4f})." )