Spaces:
Running
Running
feat: sync latest backend with auth, usage tracking, and admin bypass
Browse files- app/api/auth.py +78 -0
- app/api/routes/auth.py +98 -0
- app/api/routes/usage.py +72 -0
- app/core/config.py +18 -5
- app/core/redis.py +2 -14
- app/llm/factory.py +0 -10
- app/llm/fallback_provider.py +4 -10
- app/main.py +12 -12
- app/models/user.py +29 -0
- app/workers/celery_app.py +2 -8
- requirements.txt +3 -0
app/api/auth.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authentication dependencies for FastAPI routes."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
from fastapi import Depends, HTTPException, status
|
| 7 |
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
| 8 |
+
|
| 9 |
+
from jose import JWTError, jwt
|
| 10 |
+
|
| 11 |
+
from app.core.config import settings
|
| 12 |
+
|
| 13 |
+
security = HTTPBearer(auto_error=False)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class AuthenticatedUser:
|
| 18 |
+
"""Authenticated user extracted from JWT token."""
|
| 19 |
+
google_id: str
|
| 20 |
+
email: str
|
| 21 |
+
name: str
|
| 22 |
+
picture: Optional[str] = None
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _decode_token(token: str) -> dict:
|
| 26 |
+
"""Decode and validate a JWT token."""
|
| 27 |
+
try:
|
| 28 |
+
payload = jwt.decode(
|
| 29 |
+
token,
|
| 30 |
+
settings.nextauth_secret,
|
| 31 |
+
algorithms=["HS256"],
|
| 32 |
+
)
|
| 33 |
+
return payload
|
| 34 |
+
except JWTError:
|
| 35 |
+
raise HTTPException(
|
| 36 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 37 |
+
detail="Invalid or expired token",
|
| 38 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
async def get_current_user(
|
| 43 |
+
credentials: HTTPAuthorizationCredentials = Depends(security),
|
| 44 |
+
) -> AuthenticatedUser:
|
| 45 |
+
"""Require a valid JWT and return the authenticated user."""
|
| 46 |
+
if credentials is None:
|
| 47 |
+
raise HTTPException(
|
| 48 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 49 |
+
detail="Authentication required",
|
| 50 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
payload = _decode_token(credentials.credentials)
|
| 54 |
+
return AuthenticatedUser(
|
| 55 |
+
google_id=payload.get("sub", ""),
|
| 56 |
+
email=payload.get("email", ""),
|
| 57 |
+
name=payload.get("name", ""),
|
| 58 |
+
picture=payload.get("picture"),
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
async def get_optional_user(
|
| 63 |
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
| 64 |
+
) -> Optional[AuthenticatedUser]:
|
| 65 |
+
"""Return authenticated user if token is present, otherwise None."""
|
| 66 |
+
if credentials is None:
|
| 67 |
+
return None
|
| 68 |
+
|
| 69 |
+
try:
|
| 70 |
+
payload = _decode_token(credentials.credentials)
|
| 71 |
+
return AuthenticatedUser(
|
| 72 |
+
google_id=payload.get("sub", ""),
|
| 73 |
+
email=payload.get("email", ""),
|
| 74 |
+
name=payload.get("name", ""),
|
| 75 |
+
picture=payload.get("picture"),
|
| 76 |
+
)
|
| 77 |
+
except HTTPException:
|
| 78 |
+
return None
|
app/api/routes/auth.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authentication routes for user profile management."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends
|
| 8 |
+
from pydantic import BaseModel
|
| 9 |
+
|
| 10 |
+
from app.api.auth import AuthenticatedUser, get_current_user
|
| 11 |
+
from app.core.config import settings
|
| 12 |
+
from app.core.redis import get_redis
|
| 13 |
+
from app.models.user import UserProfile
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
router = APIRouter()
|
| 17 |
+
|
| 18 |
+
REDIS_USER_PREFIX = "user:"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class UpdateProfileRequest(BaseModel):
|
| 22 |
+
"""Request body for profile updates."""
|
| 23 |
+
name: str | None = None
|
| 24 |
+
preferred_intensity: str | None = None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@router.get("/user/me")
|
| 28 |
+
async def get_me(user: AuthenticatedUser = Depends(get_current_user)):
|
| 29 |
+
"""Get current user profile. Auto-creates on first login."""
|
| 30 |
+
redis = await get_redis()
|
| 31 |
+
key = f"{REDIS_USER_PREFIX}{user.google_id}"
|
| 32 |
+
|
| 33 |
+
existing = await redis.get(key)
|
| 34 |
+
if existing:
|
| 35 |
+
profile = UserProfile.model_validate_json(existing)
|
| 36 |
+
# Update picture/email in case they changed on Google side
|
| 37 |
+
profile.picture = user.picture or profile.picture
|
| 38 |
+
profile.email = user.email or profile.email
|
| 39 |
+
profile.updated_at = datetime.utcnow()
|
| 40 |
+
await redis.set(
|
| 41 |
+
key,
|
| 42 |
+
profile.model_dump_json(),
|
| 43 |
+
ex=settings.user_profile_ttl_seconds,
|
| 44 |
+
)
|
| 45 |
+
return profile.model_dump()
|
| 46 |
+
|
| 47 |
+
# First login — create new profile
|
| 48 |
+
profile = UserProfile(
|
| 49 |
+
google_id=user.google_id,
|
| 50 |
+
email=user.email,
|
| 51 |
+
name=user.name,
|
| 52 |
+
picture=user.picture,
|
| 53 |
+
)
|
| 54 |
+
await redis.set(
|
| 55 |
+
key,
|
| 56 |
+
profile.model_dump_json(),
|
| 57 |
+
ex=settings.user_profile_ttl_seconds,
|
| 58 |
+
)
|
| 59 |
+
logger.info(f"Created new user profile for {user.email}")
|
| 60 |
+
return profile.model_dump()
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@router.put("/user/profile")
|
| 64 |
+
async def update_profile(
|
| 65 |
+
body: UpdateProfileRequest,
|
| 66 |
+
user: AuthenticatedUser = Depends(get_current_user),
|
| 67 |
+
):
|
| 68 |
+
"""Update user profile fields."""
|
| 69 |
+
redis = await get_redis()
|
| 70 |
+
key = f"{REDIS_USER_PREFIX}{user.google_id}"
|
| 71 |
+
|
| 72 |
+
existing = await redis.get(key)
|
| 73 |
+
if existing:
|
| 74 |
+
profile = UserProfile.model_validate_json(existing)
|
| 75 |
+
else:
|
| 76 |
+
# Shouldn't happen but handle gracefully
|
| 77 |
+
profile = UserProfile(
|
| 78 |
+
google_id=user.google_id,
|
| 79 |
+
email=user.email,
|
| 80 |
+
name=user.name,
|
| 81 |
+
picture=user.picture,
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
if body.name is not None:
|
| 85 |
+
profile.name = body.name
|
| 86 |
+
if body.preferred_intensity is not None:
|
| 87 |
+
if body.preferred_intensity in ("conservative", "moderate", "aggressive"):
|
| 88 |
+
profile.preferred_intensity = body.preferred_intensity
|
| 89 |
+
|
| 90 |
+
profile.updated_at = datetime.utcnow()
|
| 91 |
+
|
| 92 |
+
await redis.set(
|
| 93 |
+
key,
|
| 94 |
+
profile.model_dump_json(),
|
| 95 |
+
ex=settings.user_profile_ttl_seconds,
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
return profile.model_dump()
|
app/api/routes/usage.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Usage tracking for freemium gating."""
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Depends
|
| 6 |
+
|
| 7 |
+
from app.api.auth import AuthenticatedUser, get_current_user
|
| 8 |
+
from app.core.config import settings
|
| 9 |
+
from app.core.redis import get_redis
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
|
| 13 |
+
USAGE_PREFIX = "usage:"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _usage_key(google_id: str) -> str:
|
| 17 |
+
"""Return Redis key for current month's usage."""
|
| 18 |
+
month = datetime.utcnow().strftime("%Y-%m")
|
| 19 |
+
return f"{USAGE_PREFIX}{google_id}:{month}"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _resets_at() -> str:
|
| 23 |
+
"""Return ISO date of first day of next month."""
|
| 24 |
+
now = datetime.utcnow()
|
| 25 |
+
if now.month == 12:
|
| 26 |
+
first_next = datetime(now.year + 1, 1, 1)
|
| 27 |
+
else:
|
| 28 |
+
first_next = datetime(now.year, now.month + 1, 1)
|
| 29 |
+
return first_next.strftime("%Y-%m-%d")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _is_admin(user: AuthenticatedUser) -> bool:
|
| 33 |
+
"""Check if the user is an admin (unlimited usage)."""
|
| 34 |
+
return user.email.lower() in settings.admin_email_set
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@router.get("/usage")
|
| 38 |
+
async def get_usage(user: AuthenticatedUser = Depends(get_current_user)):
|
| 39 |
+
"""Get current month's usage for authenticated user."""
|
| 40 |
+
# Admins get unlimited usage
|
| 41 |
+
if _is_admin(user):
|
| 42 |
+
return {"used": 0, "limit": 999999, "resets_at": _resets_at(), "is_admin": True}
|
| 43 |
+
|
| 44 |
+
redis = await get_redis()
|
| 45 |
+
key = _usage_key(user.google_id)
|
| 46 |
+
raw = await redis.get(key)
|
| 47 |
+
used = int(raw) if raw else 0
|
| 48 |
+
return {
|
| 49 |
+
"used": used,
|
| 50 |
+
"limit": settings.free_tier_limit,
|
| 51 |
+
"resets_at": _resets_at(),
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@router.post("/usage/increment")
|
| 56 |
+
async def increment_usage(user: AuthenticatedUser = Depends(get_current_user)):
|
| 57 |
+
"""Increment usage counter. Called when a customization completes."""
|
| 58 |
+
# Admins skip counting entirely
|
| 59 |
+
if _is_admin(user):
|
| 60 |
+
return {"used": 0, "limit": 999999, "resets_at": _resets_at(), "is_admin": True}
|
| 61 |
+
|
| 62 |
+
redis = await get_redis()
|
| 63 |
+
key = _usage_key(user.google_id)
|
| 64 |
+
new_count = await redis.incr(key)
|
| 65 |
+
# Set TTL only on first increment (when count is 1)
|
| 66 |
+
if new_count == 1:
|
| 67 |
+
await redis.expire(key, settings.usage_ttl_seconds)
|
| 68 |
+
return {
|
| 69 |
+
"used": new_count,
|
| 70 |
+
"limit": settings.free_tier_limit,
|
| 71 |
+
"resets_at": _resets_at(),
|
| 72 |
+
}
|
app/core/config.py
CHANGED
|
@@ -1,4 +1,3 @@
|
|
| 1 |
-
from pydantic import field_validator
|
| 2 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 3 |
|
| 4 |
|
|
@@ -22,10 +21,24 @@ class Settings(BaseSettings):
|
|
| 22 |
# Session
|
| 23 |
session_ttl_seconds: int = 7200
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
settings = Settings()
|
|
|
|
|
|
|
| 1 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 2 |
|
| 3 |
|
|
|
|
| 21 |
# Session
|
| 22 |
session_ttl_seconds: int = 7200
|
| 23 |
|
| 24 |
+
# Auth
|
| 25 |
+
nextauth_secret: str = ""
|
| 26 |
+
frontend_url: str = "http://localhost:3000"
|
| 27 |
+
user_profile_ttl_seconds: int = 30 * 24 * 60 * 60 # 30 days
|
| 28 |
+
|
| 29 |
+
# Usage limits
|
| 30 |
+
free_tier_limit: int = 3
|
| 31 |
+
usage_ttl_seconds: int = 35 * 24 * 60 * 60 # 35 days
|
| 32 |
+
|
| 33 |
+
# Admin emails (comma-separated) — unlimited usage, no gating
|
| 34 |
+
admin_emails: str = ""
|
| 35 |
+
|
| 36 |
+
@property
|
| 37 |
+
def admin_email_set(self) -> set[str]:
|
| 38 |
+
"""Parse comma-separated admin emails into a set for fast lookup."""
|
| 39 |
+
if not self.admin_emails:
|
| 40 |
+
return set()
|
| 41 |
+
return {e.strip().lower() for e in self.admin_emails.split(",") if e.strip()}
|
| 42 |
|
| 43 |
|
| 44 |
settings = Settings()
|
app/core/redis.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
-
import ssl
|
| 3 |
from contextlib import asynccontextmanager
|
| 4 |
from typing import Optional
|
| 5 |
|
|
@@ -10,20 +9,11 @@ from app.core.config import settings
|
|
| 10 |
_fastapi_client: Optional[redis.Redis] = None
|
| 11 |
|
| 12 |
|
| 13 |
-
def _redis_kwargs() -> dict:
|
| 14 |
-
"""Extra kwargs for rediss:// (TLS) connections like Upstash."""
|
| 15 |
-
if settings.redis_url.startswith("rediss://"):
|
| 16 |
-
return {"ssl_cert_reqs": None}
|
| 17 |
-
return {}
|
| 18 |
-
|
| 19 |
-
|
| 20 |
async def get_redis() -> redis.Redis:
|
| 21 |
"""Get Redis client for FastAPI (reuses connection)."""
|
| 22 |
global _fastapi_client
|
| 23 |
if _fastapi_client is None:
|
| 24 |
-
_fastapi_client = redis.from_url(
|
| 25 |
-
settings.redis_url, decode_responses=True, **_redis_kwargs()
|
| 26 |
-
)
|
| 27 |
return _fastapi_client
|
| 28 |
|
| 29 |
|
|
@@ -38,9 +28,7 @@ async def close_redis():
|
|
| 38 |
@asynccontextmanager
|
| 39 |
async def get_redis_for_worker():
|
| 40 |
"""Get fresh Redis client for Celery workers (new connection per task)."""
|
| 41 |
-
client = redis.from_url(
|
| 42 |
-
settings.redis_url, decode_responses=True, **_redis_kwargs()
|
| 43 |
-
)
|
| 44 |
try:
|
| 45 |
yield client
|
| 46 |
finally:
|
|
|
|
| 1 |
from __future__ import annotations
|
|
|
|
| 2 |
from contextlib import asynccontextmanager
|
| 3 |
from typing import Optional
|
| 4 |
|
|
|
|
| 9 |
_fastapi_client: Optional[redis.Redis] = None
|
| 10 |
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
async def get_redis() -> redis.Redis:
|
| 13 |
"""Get Redis client for FastAPI (reuses connection)."""
|
| 14 |
global _fastapi_client
|
| 15 |
if _fastapi_client is None:
|
| 16 |
+
_fastapi_client = redis.from_url(settings.redis_url, decode_responses=True)
|
|
|
|
|
|
|
| 17 |
return _fastapi_client
|
| 18 |
|
| 19 |
|
|
|
|
| 28 |
@asynccontextmanager
|
| 29 |
async def get_redis_for_worker():
|
| 30 |
"""Get fresh Redis client for Celery workers (new connection per task)."""
|
| 31 |
+
client = redis.from_url(settings.redis_url, decode_responses=True)
|
|
|
|
|
|
|
| 32 |
try:
|
| 33 |
yield client
|
| 34 |
finally:
|
app/llm/factory.py
CHANGED
|
@@ -5,8 +5,6 @@ from typing import Dict, List, Tuple, Type
|
|
| 5 |
from app.llm.base import LLMProvider
|
| 6 |
from app.llm.openai_provider import OpenAIProvider
|
| 7 |
from app.llm.google_provider import GoogleProvider
|
| 8 |
-
from app.llm.zai_provider import ZAIProvider
|
| 9 |
-
from app.llm.groq_provider import GroqProvider
|
| 10 |
from app.llm.fallback_provider import FallbackLLMProvider
|
| 11 |
from app.core.config import settings
|
| 12 |
|
|
@@ -17,15 +15,11 @@ class LLMFactory:
|
|
| 17 |
_providers: Dict[str, Type[LLMProvider]] = {
|
| 18 |
"openai": OpenAIProvider,
|
| 19 |
"google": GoogleProvider,
|
| 20 |
-
"zai": ZAIProvider,
|
| 21 |
-
"groq": GroqProvider,
|
| 22 |
}
|
| 23 |
|
| 24 |
_default_models: Dict[str, Tuple[str, str]] = {
|
| 25 |
"openai": ("gpt-4o-mini", "gpt-4o"),
|
| 26 |
"google": ("gemini-3-flash-preview", "gemini-3-flash-preview"),
|
| 27 |
-
"zai": ("glm-4.7", "glm-4.7"),
|
| 28 |
-
"groq": ("llama-3.1-8b-instant", "llama-3.1-8b-instant"),
|
| 29 |
}
|
| 30 |
|
| 31 |
@classmethod
|
|
@@ -43,8 +37,6 @@ class LLMFactory:
|
|
| 43 |
api_keys = {
|
| 44 |
"openai": settings.openai_api_key,
|
| 45 |
"google": settings.google_api_key,
|
| 46 |
-
"zai": settings.zai_api_key,
|
| 47 |
-
"groq": settings.groq_api_key,
|
| 48 |
}
|
| 49 |
|
| 50 |
# Get primary provider's model
|
|
@@ -89,7 +81,5 @@ class LLMFactory:
|
|
| 89 |
keys = {
|
| 90 |
"openai": settings.openai_api_key,
|
| 91 |
"google": settings.google_api_key,
|
| 92 |
-
"zai": settings.zai_api_key,
|
| 93 |
-
"groq": settings.groq_api_key,
|
| 94 |
}
|
| 95 |
return keys.get(settings.llm_provider, "")
|
|
|
|
| 5 |
from app.llm.base import LLMProvider
|
| 6 |
from app.llm.openai_provider import OpenAIProvider
|
| 7 |
from app.llm.google_provider import GoogleProvider
|
|
|
|
|
|
|
| 8 |
from app.llm.fallback_provider import FallbackLLMProvider
|
| 9 |
from app.core.config import settings
|
| 10 |
|
|
|
|
| 15 |
_providers: Dict[str, Type[LLMProvider]] = {
|
| 16 |
"openai": OpenAIProvider,
|
| 17 |
"google": GoogleProvider,
|
|
|
|
|
|
|
| 18 |
}
|
| 19 |
|
| 20 |
_default_models: Dict[str, Tuple[str, str]] = {
|
| 21 |
"openai": ("gpt-4o-mini", "gpt-4o"),
|
| 22 |
"google": ("gemini-3-flash-preview", "gemini-3-flash-preview"),
|
|
|
|
|
|
|
| 23 |
}
|
| 24 |
|
| 25 |
@classmethod
|
|
|
|
| 37 |
api_keys = {
|
| 38 |
"openai": settings.openai_api_key,
|
| 39 |
"google": settings.google_api_key,
|
|
|
|
|
|
|
| 40 |
}
|
| 41 |
|
| 42 |
# Get primary provider's model
|
|
|
|
| 81 |
keys = {
|
| 82 |
"openai": settings.openai_api_key,
|
| 83 |
"google": settings.google_api_key,
|
|
|
|
|
|
|
| 84 |
}
|
| 85 |
return keys.get(settings.llm_provider, "")
|
app/llm/fallback_provider.py
CHANGED
|
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|
| 2 |
import asyncio
|
| 3 |
import logging
|
| 4 |
import re
|
| 5 |
-
import time
|
| 6 |
from typing import Any, Dict, List, Optional
|
| 7 |
|
| 8 |
from app.llm.base import LLMProvider
|
|
@@ -19,9 +18,9 @@ class FallbackLLMProvider(LLMProvider):
|
|
| 19 |
"""LLM provider with automatic fallback and retry with exponential backoff."""
|
| 20 |
|
| 21 |
# Retry configuration
|
| 22 |
-
MAX_RETRIES_PER_PROVIDER =
|
| 23 |
-
INITIAL_BACKOFF_SECONDS =
|
| 24 |
-
MAX_BACKOFF_SECONDS =
|
| 25 |
|
| 26 |
def __init__(self, providers: List[LLMProvider]):
|
| 27 |
if not providers:
|
|
@@ -74,12 +73,7 @@ class FallbackLLMProvider(LLMProvider):
|
|
| 74 |
|
| 75 |
for attempt in range(self.MAX_RETRIES_PER_PROVIDER):
|
| 76 |
try:
|
| 77 |
-
|
| 78 |
-
start_time = time.time()
|
| 79 |
-
result = await call_func(*args, **kwargs)
|
| 80 |
-
ttft = (time.time() - start_time) * 1000 # Convert to milliseconds
|
| 81 |
-
logger.info(f"⏱️ {provider.__class__.__name__} TTFT: {ttft:.2f}ms ({ttft/1000:.3f}s)")
|
| 82 |
-
return result
|
| 83 |
except Exception as e:
|
| 84 |
last_error = e
|
| 85 |
|
|
|
|
| 2 |
import asyncio
|
| 3 |
import logging
|
| 4 |
import re
|
|
|
|
| 5 |
from typing import Any, Dict, List, Optional
|
| 6 |
|
| 7 |
from app.llm.base import LLMProvider
|
|
|
|
| 18 |
"""LLM provider with automatic fallback and retry with exponential backoff."""
|
| 19 |
|
| 20 |
# Retry configuration
|
| 21 |
+
MAX_RETRIES_PER_PROVIDER = 2
|
| 22 |
+
INITIAL_BACKOFF_SECONDS = 2
|
| 23 |
+
MAX_BACKOFF_SECONDS = 30
|
| 24 |
|
| 25 |
def __init__(self, providers: List[LLMProvider]):
|
| 26 |
if not providers:
|
|
|
|
| 73 |
|
| 74 |
for attempt in range(self.MAX_RETRIES_PER_PROVIDER):
|
| 75 |
try:
|
| 76 |
+
return await call_func(*args, **kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
except Exception as e:
|
| 78 |
last_error = e
|
| 79 |
|
app/main.py
CHANGED
|
@@ -4,7 +4,8 @@ from fastapi import FastAPI, Request
|
|
| 4 |
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
from fastapi.responses import JSONResponse
|
| 6 |
from app.core.redis import close_redis
|
| 7 |
-
from app.api.routes import upload, analyze, progress, result, export,
|
|
|
|
| 8 |
|
| 9 |
logger = logging.getLogger(__name__)
|
| 10 |
|
|
@@ -22,19 +23,17 @@ app = FastAPI(
|
|
| 22 |
lifespan=lifespan,
|
| 23 |
)
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
if vercel_url:
|
| 33 |
-
allowed_origins.append(vercel_url)
|
| 34 |
|
| 35 |
app.add_middleware(
|
| 36 |
CORSMiddleware,
|
| 37 |
-
allow_origins=
|
| 38 |
allow_credentials=True,
|
| 39 |
allow_methods=["*"],
|
| 40 |
allow_headers=["*"],
|
|
@@ -86,7 +85,8 @@ app.include_router(analyze.router, prefix="/api", tags=["analyze"])
|
|
| 86 |
app.include_router(progress.router, prefix="/api", tags=["progress"])
|
| 87 |
app.include_router(result.router, prefix="/api", tags=["result"])
|
| 88 |
app.include_router(export.router, prefix="/api", tags=["export"])
|
| 89 |
-
app.include_router(
|
|
|
|
| 90 |
|
| 91 |
|
| 92 |
@app.get("/health")
|
|
|
|
| 4 |
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
from fastapi.responses import JSONResponse
|
| 6 |
from app.core.redis import close_redis
|
| 7 |
+
from app.api.routes import upload, analyze, progress, result, export, auth, usage
|
| 8 |
+
from app.core.config import settings
|
| 9 |
|
| 10 |
logger = logging.getLogger(__name__)
|
| 11 |
|
|
|
|
| 23 |
lifespan=lifespan,
|
| 24 |
)
|
| 25 |
|
| 26 |
+
# Build CORS origins list: always include localhost for dev, plus production URL
|
| 27 |
+
cors_origins = ["http://localhost:3000"]
|
| 28 |
+
if settings.frontend_url and settings.frontend_url != "http://localhost:3000":
|
| 29 |
+
cors_origins.append(settings.frontend_url)
|
| 30 |
+
# Also accept any Vercel preview deployments
|
| 31 |
+
if settings.frontend_url and "vercel.app" in settings.frontend_url:
|
| 32 |
+
cors_origins.append("https://mycv-buddy.vercel.app")
|
|
|
|
|
|
|
| 33 |
|
| 34 |
app.add_middleware(
|
| 35 |
CORSMiddleware,
|
| 36 |
+
allow_origins=cors_origins,
|
| 37 |
allow_credentials=True,
|
| 38 |
allow_methods=["*"],
|
| 39 |
allow_headers=["*"],
|
|
|
|
| 85 |
app.include_router(progress.router, prefix="/api", tags=["progress"])
|
| 86 |
app.include_router(result.router, prefix="/api", tags=["result"])
|
| 87 |
app.include_router(export.router, prefix="/api", tags=["export"])
|
| 88 |
+
app.include_router(auth.router, prefix="/api", tags=["auth"])
|
| 89 |
+
app.include_router(usage.router, prefix="/api", tags=["usage"])
|
| 90 |
|
| 91 |
|
| 92 |
@app.get("/health")
|
app/models/user.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""User profile model stored in Redis."""
|
| 2 |
+
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
from pydantic import BaseModel, Field
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class UserProfile(BaseModel):
|
| 9 |
+
"""User profile stored as JSON in Redis with key user:{google_id}."""
|
| 10 |
+
|
| 11 |
+
google_id: str
|
| 12 |
+
email: str
|
| 13 |
+
name: str
|
| 14 |
+
picture: Optional[str] = None
|
| 15 |
+
|
| 16 |
+
# Preferences
|
| 17 |
+
preferred_intensity: str = "moderate" # conservative | moderate | aggressive
|
| 18 |
+
|
| 19 |
+
# Session linking
|
| 20 |
+
linked_sessions: List[str] = Field(default_factory=list)
|
| 21 |
+
|
| 22 |
+
# Timestamps
|
| 23 |
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
| 24 |
+
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
| 25 |
+
|
| 26 |
+
class Config:
|
| 27 |
+
json_encoders = {
|
| 28 |
+
datetime: lambda v: v.isoformat(),
|
| 29 |
+
}
|
app/workers/celery_app.py
CHANGED
|
@@ -1,16 +1,10 @@
|
|
| 1 |
from celery import Celery
|
| 2 |
from app.core.config import settings
|
| 3 |
|
| 4 |
-
# Celery requires explicit ssl_cert_reqs param for rediss:// URLs
|
| 5 |
-
_redis_url = settings.redis_url
|
| 6 |
-
if _redis_url.startswith("rediss://") and "ssl_cert_reqs" not in _redis_url:
|
| 7 |
-
sep = "&" if "?" in _redis_url else "?"
|
| 8 |
-
_redis_url = f"{_redis_url}{sep}ssl_cert_reqs=CERT_NONE"
|
| 9 |
-
|
| 10 |
celery_app = Celery(
|
| 11 |
"cv_buddy",
|
| 12 |
-
broker=
|
| 13 |
-
backend=
|
| 14 |
include=["app.workers.tasks"],
|
| 15 |
)
|
| 16 |
|
|
|
|
| 1 |
from celery import Celery
|
| 2 |
from app.core.config import settings
|
| 3 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
celery_app = Celery(
|
| 5 |
"cv_buddy",
|
| 6 |
+
broker=settings.redis_url,
|
| 7 |
+
backend=settings.redis_url,
|
| 8 |
include=["app.workers.tasks"],
|
| 9 |
)
|
| 10 |
|
requirements.txt
CHANGED
|
@@ -23,6 +23,9 @@ beautifulsoup4>=4.12.0
|
|
| 23 |
openai>=1.10.0
|
| 24 |
google-generativeai>=0.4.0
|
| 25 |
|
|
|
|
|
|
|
|
|
|
| 26 |
# Utilities
|
| 27 |
pydantic>=2.5.0
|
| 28 |
pydantic-settings>=2.1.0
|
|
|
|
| 23 |
openai>=1.10.0
|
| 24 |
google-generativeai>=0.4.0
|
| 25 |
|
| 26 |
+
# Auth
|
| 27 |
+
python-jose[cryptography]>=3.3.0
|
| 28 |
+
|
| 29 |
# Utilities
|
| 30 |
pydantic>=2.5.0
|
| 31 |
pydantic-settings>=2.1.0
|