Spaces:
Running
Running
Zeetay commited on
Commit Β·
babbdee
1
Parent(s): 2e0c40e
Implement infra hardening: streaming, refresh tokens, structlog, Sentry, Redis
Browse files- SSE streaming on /generate-pseudocode with live token delivery
- HttpOnly refresh token cookies (7d) + silent refresh in AuthContext
- Access tokens in memory only (15min), no localStorage
- Dual SQLite/Postgres with refresh_tokens table
- structlog JSON/console renderer via LOG_JSON env
- Sentry init on SENTRY_DSN (FastAPI + Starlette integrations)
- Optional Redis backend for slowapi rate limiter via REDIS_URL
- Redis response cache on /v1/generate-pseudocode (1h TTL, SHA-256 key)
- Health check at GET /health
- React.lazy for auth modal pages (separate build chunks)
- CORS credentials=True only when CORS_ORIGINS is explicit (not wildcard)
- app.py +152 -73
- auth.py +1 -5
- database.py +205 -48
- requirements.txt +10 -0
- routers/auth.py +78 -8
- utils.py +155 -26
app.py
CHANGED
|
@@ -1,19 +1,28 @@
|
|
|
|
|
| 1 |
import os
|
| 2 |
from contextlib import asynccontextmanager
|
| 3 |
from pathlib import Path
|
| 4 |
|
|
|
|
| 5 |
from dotenv import load_dotenv
|
| 6 |
from fastapi import Depends, FastAPI, Header, HTTPException, Request, APIRouter
|
| 7 |
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
| 8 |
from pydantic import BaseModel, Field
|
| 9 |
from slowapi import Limiter, _rate_limit_exceeded_handler
|
| 10 |
from slowapi.util import get_remote_address
|
| 11 |
from slowapi.errors import RateLimitExceeded
|
| 12 |
from typing import Annotated
|
| 13 |
-
import logging
|
| 14 |
|
| 15 |
from ai_prompts import TEMPLATES
|
| 16 |
-
from utils import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
from database import (
|
| 18 |
init_db,
|
| 19 |
GUEST_DAILY_LIMIT,
|
|
@@ -27,6 +36,19 @@ from routers.auth import router as auth_router
|
|
| 27 |
_BACKEND_DIR = Path(__file__).resolve().parent
|
| 28 |
load_dotenv(dotenv_path=_BACKEND_DIR / ".env")
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
_REQUIRED_ENV = ["PROVIDER"]
|
| 31 |
_PROVIDER_KEYS = {
|
| 32 |
"openai": "OPENAI_API_KEY",
|
|
@@ -50,35 +72,41 @@ def _check_env() -> None:
|
|
| 50 |
|
| 51 |
_check_env()
|
| 52 |
|
| 53 |
-
|
| 54 |
-
level=getattr(logging, os.getenv("LOG_LEVEL", "INFO").upper(), logging.INFO),
|
| 55 |
-
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
| 56 |
-
datefmt="%Y-%m-%d %H:%M:%S",
|
| 57 |
-
)
|
| 58 |
-
logger = logging.getLogger("pseudogen")
|
| 59 |
|
| 60 |
|
| 61 |
@asynccontextmanager
|
| 62 |
async def lifespan(app: FastAPI):
|
| 63 |
init_db()
|
|
|
|
| 64 |
yield
|
|
|
|
| 65 |
|
| 66 |
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
app.state.limiter = limiter
|
| 70 |
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
| 71 |
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
| 78 |
app.add_middleware(
|
| 79 |
CORSMiddleware,
|
| 80 |
-
allow_origins=
|
| 81 |
-
allow_credentials=
|
| 82 |
allow_methods=["*"],
|
| 83 |
allow_headers=["*"],
|
| 84 |
)
|
|
@@ -124,9 +152,7 @@ _STYLE_SYSTEM = {
|
|
| 124 |
),
|
| 125 |
}
|
| 126 |
|
| 127 |
-
|
| 128 |
app.include_router(auth_router)
|
| 129 |
-
|
| 130 |
v1_router = APIRouter(prefix="/v1", tags=["v1"])
|
| 131 |
|
| 132 |
|
|
@@ -135,6 +161,11 @@ async def root():
|
|
| 135 |
return {"service": "Pseudogen API", "version": "1"}
|
| 136 |
|
| 137 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
@app.get("/usage")
|
| 139 |
async def usage(
|
| 140 |
user: dict | None = Depends(get_optional_user),
|
|
@@ -152,12 +183,7 @@ async def usage(
|
|
| 152 |
return {"used": 0, "limit": GUEST_DAILY_LIMIT, "remaining": GUEST_DAILY_LIMIT, "is_guest": True}
|
| 153 |
|
| 154 |
used = get_usage_today(identifier)
|
| 155 |
-
return {
|
| 156 |
-
"used": used,
|
| 157 |
-
"limit": limit,
|
| 158 |
-
"remaining": max(0, limit - used),
|
| 159 |
-
"is_guest": is_guest,
|
| 160 |
-
}
|
| 161 |
|
| 162 |
|
| 163 |
@app.post("/summarize")
|
|
@@ -173,76 +199,124 @@ async def summarize_title(request: Request, req: SummarizeRequest):
|
|
| 173 |
title = title.strip().split("\n")[0][:60]
|
| 174 |
return {"title": title}
|
| 175 |
except Exception:
|
| 176 |
-
logger.exception("
|
| 177 |
raise HTTPException(status_code=502, detail="Summarization failed")
|
| 178 |
|
| 179 |
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
@limiter.limit("30/minute")
|
| 182 |
-
async def
|
| 183 |
request: Request,
|
| 184 |
req: GenerateRequest,
|
| 185 |
user: dict | None = Depends(get_optional_user),
|
| 186 |
x_session_id: str | None = Header(default=None),
|
| 187 |
):
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
|
|
|
|
|
|
|
| 193 |
|
| 194 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
@limiter.limit("30/minute")
|
| 196 |
-
async def
|
| 197 |
request: Request,
|
| 198 |
req: GenerateRequest,
|
| 199 |
user: dict | None = Depends(get_optional_user),
|
| 200 |
x_session_id: str | None = Header(default=None),
|
| 201 |
):
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
if
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
status_code=400,
|
| 217 |
-
detail="A session ID or account is required.",
|
| 218 |
-
)
|
| 219 |
|
| 220 |
used = get_usage_today(identifier)
|
| 221 |
if used >= limit:
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
detail=f"Daily limit of {limit} prompts reached. Resets at midnight UTC.",
|
| 231 |
-
)
|
| 232 |
|
| 233 |
try:
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
f"{_STYLE_SYSTEM.get(req.style, 'You generate pseudocode.')} "
|
| 237 |
-
f"Detail level: {req.detail}. "
|
| 238 |
-
"When asked to modify or improve, update the pseudocode accordingly."
|
| 239 |
-
)
|
| 240 |
-
context = req.context[-10:]
|
| 241 |
-
messages = [
|
| 242 |
-
{"role": "system", "content": system_msg},
|
| 243 |
-
*[{"role": m.role, "content": m.content} for m in context],
|
| 244 |
-
{"role": "user", "content": req.problem_description},
|
| 245 |
-
]
|
| 246 |
response_text = call_llm_messages(messages)
|
| 247 |
else:
|
| 248 |
template = TEMPLATES.get(req.style)
|
|
@@ -253,9 +327,10 @@ async def _generate(req: GenerateRequest, user: dict | None, x_session_id: str |
|
|
| 253 |
except HTTPException:
|
| 254 |
raise
|
| 255 |
except Exception:
|
| 256 |
-
logger.exception("
|
| 257 |
raise HTTPException(status_code=502, detail="Failed to generate pseudocode. Please try again.")
|
| 258 |
|
|
|
|
| 259 |
new_count = increment_usage_today(identifier)
|
| 260 |
remaining = max(0, limit - new_count)
|
| 261 |
|
|
@@ -265,4 +340,8 @@ async def _generate(req: GenerateRequest, user: dict | None, x_session_id: str |
|
|
| 265 |
"limit": limit,
|
| 266 |
"remaining": remaining,
|
| 267 |
"is_guest": is_guest,
|
|
|
|
| 268 |
}
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
import os
|
| 3 |
from contextlib import asynccontextmanager
|
| 4 |
from pathlib import Path
|
| 5 |
|
| 6 |
+
import structlog
|
| 7 |
from dotenv import load_dotenv
|
| 8 |
from fastapi import Depends, FastAPI, Header, HTTPException, Request, APIRouter
|
| 9 |
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
+
from fastapi.responses import StreamingResponse
|
| 11 |
from pydantic import BaseModel, Field
|
| 12 |
from slowapi import Limiter, _rate_limit_exceeded_handler
|
| 13 |
from slowapi.util import get_remote_address
|
| 14 |
from slowapi.errors import RateLimitExceeded
|
| 15 |
from typing import Annotated
|
|
|
|
| 16 |
|
| 17 |
from ai_prompts import TEMPLATES
|
| 18 |
+
from utils import (
|
| 19 |
+
call_llm,
|
| 20 |
+
call_llm_messages,
|
| 21 |
+
call_llm_stream,
|
| 22 |
+
get_cached_response,
|
| 23 |
+
set_cached_response,
|
| 24 |
+
make_cache_key,
|
| 25 |
+
)
|
| 26 |
from database import (
|
| 27 |
init_db,
|
| 28 |
GUEST_DAILY_LIMIT,
|
|
|
|
| 36 |
_BACKEND_DIR = Path(__file__).resolve().parent
|
| 37 |
load_dotenv(dotenv_path=_BACKEND_DIR / ".env")
|
| 38 |
|
| 39 |
+
# ββ Sentry (optional) βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
+
_SENTRY_DSN = os.getenv("SENTRY_DSN")
|
| 41 |
+
if _SENTRY_DSN:
|
| 42 |
+
import sentry_sdk
|
| 43 |
+
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
| 44 |
+
from sentry_sdk.integrations.starlette import StarletteIntegration
|
| 45 |
+
sentry_sdk.init(
|
| 46 |
+
dsn=_SENTRY_DSN,
|
| 47 |
+
integrations=[StarletteIntegration(), FastApiIntegration()],
|
| 48 |
+
traces_sample_rate=float(os.getenv("SENTRY_TRACES_SAMPLE_RATE", "0.2")),
|
| 49 |
+
send_default_pii=False,
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
_REQUIRED_ENV = ["PROVIDER"]
|
| 53 |
_PROVIDER_KEYS = {
|
| 54 |
"openai": "OPENAI_API_KEY",
|
|
|
|
| 72 |
|
| 73 |
_check_env()
|
| 74 |
|
| 75 |
+
logger = structlog.get_logger("pseudogen.app")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
|
| 78 |
@asynccontextmanager
|
| 79 |
async def lifespan(app: FastAPI):
|
| 80 |
init_db()
|
| 81 |
+
logger.info("app.started")
|
| 82 |
yield
|
| 83 |
+
logger.info("app.stopped")
|
| 84 |
|
| 85 |
|
| 86 |
+
# ββ Rate limiter (Redis-backed when REDIS_URL is set) βββββββββββββββββββββββββ
|
| 87 |
+
_redis_url = os.getenv("REDIS_URL")
|
| 88 |
+
limiter = Limiter(
|
| 89 |
+
key_func=get_remote_address,
|
| 90 |
+
**{"storage_uri": _redis_url} if _redis_url else {},
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
app = FastAPI(title="Pseudogen API", lifespan=lifespan, docs_url=None, redoc_url=None)
|
| 94 |
app.state.limiter = limiter
|
| 95 |
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
| 96 |
|
| 97 |
+
# CORS: credentials require explicit origins (can't mix * with allow_credentials=True)
|
| 98 |
+
_cors_origins_env = os.getenv("CORS_ORIGINS", "*").strip()
|
| 99 |
+
if _cors_origins_env == "*":
|
| 100 |
+
_allow_origins = ["*"]
|
| 101 |
+
_allow_credentials = False
|
| 102 |
+
else:
|
| 103 |
+
_allow_origins = [o.strip() for o in _cors_origins_env.split(",") if o.strip()]
|
| 104 |
+
_allow_credentials = True
|
| 105 |
+
|
| 106 |
app.add_middleware(
|
| 107 |
CORSMiddleware,
|
| 108 |
+
allow_origins=_allow_origins,
|
| 109 |
+
allow_credentials=_allow_credentials,
|
| 110 |
allow_methods=["*"],
|
| 111 |
allow_headers=["*"],
|
| 112 |
)
|
|
|
|
| 152 |
),
|
| 153 |
}
|
| 154 |
|
|
|
|
| 155 |
app.include_router(auth_router)
|
|
|
|
| 156 |
v1_router = APIRouter(prefix="/v1", tags=["v1"])
|
| 157 |
|
| 158 |
|
|
|
|
| 161 |
return {"service": "Pseudogen API", "version": "1"}
|
| 162 |
|
| 163 |
|
| 164 |
+
@app.get("/health")
|
| 165 |
+
async def health():
|
| 166 |
+
return {"status": "ok"}
|
| 167 |
+
|
| 168 |
+
|
| 169 |
@app.get("/usage")
|
| 170 |
async def usage(
|
| 171 |
user: dict | None = Depends(get_optional_user),
|
|
|
|
| 183 |
return {"used": 0, "limit": GUEST_DAILY_LIMIT, "remaining": GUEST_DAILY_LIMIT, "is_guest": True}
|
| 184 |
|
| 185 |
used = get_usage_today(identifier)
|
| 186 |
+
return {"used": used, "limit": limit, "remaining": max(0, limit - used), "is_guest": is_guest}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
|
| 188 |
|
| 189 |
@app.post("/summarize")
|
|
|
|
| 199 |
title = title.strip().split("\n")[0][:60]
|
| 200 |
return {"title": title}
|
| 201 |
except Exception:
|
| 202 |
+
logger.exception("summarize.failed")
|
| 203 |
raise HTTPException(status_code=502, detail="Summarization failed")
|
| 204 |
|
| 205 |
|
| 206 |
+
def _resolve_identity(user: dict | None, x_session_id: str | None):
|
| 207 |
+
if user:
|
| 208 |
+
return f"user:{user['id']}", USER_DAILY_LIMIT, False
|
| 209 |
+
if x_session_id:
|
| 210 |
+
return f"session:{x_session_id}", GUEST_DAILY_LIMIT, True
|
| 211 |
+
raise HTTPException(status_code=400, detail="A session ID or account is required.")
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def _build_messages(req: GenerateRequest) -> list | None:
|
| 215 |
+
if not req.context:
|
| 216 |
+
return None
|
| 217 |
+
system_msg = (
|
| 218 |
+
f"{_STYLE_SYSTEM.get(req.style, 'You generate pseudocode.')} "
|
| 219 |
+
f"Detail level: {req.detail}. "
|
| 220 |
+
"When asked to modify or improve, update the pseudocode accordingly."
|
| 221 |
+
)
|
| 222 |
+
context = req.context[-10:]
|
| 223 |
+
return [
|
| 224 |
+
{"role": "system", "content": system_msg},
|
| 225 |
+
*[{"role": m.role, "content": m.content} for m in context],
|
| 226 |
+
{"role": "user", "content": req.problem_description},
|
| 227 |
+
]
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
# ββ Main endpoint β SSE streaming βββββββββββββββββββββββββββββββββββββββββββββ
|
| 231 |
+
|
| 232 |
+
@app.post("/generate-pseudocode")
|
| 233 |
@limiter.limit("30/minute")
|
| 234 |
+
async def generate(
|
| 235 |
request: Request,
|
| 236 |
req: GenerateRequest,
|
| 237 |
user: dict | None = Depends(get_optional_user),
|
| 238 |
x_session_id: str | None = Header(default=None),
|
| 239 |
):
|
| 240 |
+
identifier, limit, is_guest = _resolve_identity(user, x_session_id)
|
| 241 |
+
used = get_usage_today(identifier)
|
| 242 |
+
if used >= limit:
|
| 243 |
+
raise HTTPException(
|
| 244 |
+
status_code=429,
|
| 245 |
+
detail=(
|
| 246 |
+
f"You've used all {limit} free prompts for today. Create a free account to get {USER_DAILY_LIMIT} per day."
|
| 247 |
+
if is_guest
|
| 248 |
+
else f"Daily limit of {limit} prompts reached. Resets at midnight UTC."
|
| 249 |
+
),
|
| 250 |
+
)
|
| 251 |
|
| 252 |
+
# Increment before streaming to prevent quota abuse via cancel
|
| 253 |
+
new_count = increment_usage_today(identifier)
|
| 254 |
+
remaining = max(0, limit - new_count)
|
| 255 |
+
messages = _build_messages(req)
|
| 256 |
|
| 257 |
+
def _sse():
|
| 258 |
+
try:
|
| 259 |
+
if messages:
|
| 260 |
+
token_stream = call_llm_stream(messages)
|
| 261 |
+
else:
|
| 262 |
+
template = TEMPLATES.get(req.style)
|
| 263 |
+
if template is None:
|
| 264 |
+
yield f"data: {json.dumps({'error': 'Unknown style'})}\n\n"
|
| 265 |
+
return
|
| 266 |
+
prompt = template.format(user_input=req.problem_description, detail=req.detail)
|
| 267 |
+
token_stream = call_llm_stream([{"role": "user", "content": prompt}])
|
| 268 |
|
| 269 |
+
for token in token_stream:
|
| 270 |
+
yield f"data: {json.dumps({'token': token})}\n\n"
|
| 271 |
|
| 272 |
+
yield f"data: {json.dumps({'usage': {'used': new_count, 'limit': limit, 'remaining': remaining, 'is_guest': is_guest}})}\n\n"
|
| 273 |
+
yield "data: [DONE]\n\n"
|
| 274 |
+
except Exception as exc:
|
| 275 |
+
logger.error("generate.stream.error", error=str(exc))
|
| 276 |
+
yield f"data: {json.dumps({'error': 'Generation failed. Please try again.'})}\n\n"
|
| 277 |
+
|
| 278 |
+
return StreamingResponse(_sse(), media_type="text/event-stream")
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
# ββ v1 endpoint β non-streaming with Redis cache ββββββββββββββββββββββββββββββ
|
| 282 |
+
|
| 283 |
+
@v1_router.post("/generate-pseudocode")
|
| 284 |
@limiter.limit("30/minute")
|
| 285 |
+
async def generate_v1(
|
| 286 |
request: Request,
|
| 287 |
req: GenerateRequest,
|
| 288 |
user: dict | None = Depends(get_optional_user),
|
| 289 |
x_session_id: str | None = Header(default=None),
|
| 290 |
):
|
| 291 |
+
identifier, limit, is_guest = _resolve_identity(user, x_session_id)
|
| 292 |
+
|
| 293 |
+
cache_key = make_cache_key(req.problem_description, req.style, req.detail)
|
| 294 |
+
cached = get_cached_response(cache_key)
|
| 295 |
+
if cached:
|
| 296 |
+
used = get_usage_today(identifier)
|
| 297 |
+
return {
|
| 298 |
+
"markdown": cached,
|
| 299 |
+
"used": used,
|
| 300 |
+
"limit": limit,
|
| 301 |
+
"remaining": max(0, limit - used),
|
| 302 |
+
"is_guest": is_guest,
|
| 303 |
+
"cached": True,
|
| 304 |
+
}
|
|
|
|
|
|
|
|
|
|
| 305 |
|
| 306 |
used = get_usage_today(identifier)
|
| 307 |
if used >= limit:
|
| 308 |
+
raise HTTPException(
|
| 309 |
+
status_code=429,
|
| 310 |
+
detail=(
|
| 311 |
+
f"You've used all {limit} free prompts for today. Create a free account to get {USER_DAILY_LIMIT} per day."
|
| 312 |
+
if is_guest
|
| 313 |
+
else f"Daily limit of {limit} prompts reached. Resets at midnight UTC."
|
| 314 |
+
),
|
| 315 |
+
)
|
|
|
|
|
|
|
| 316 |
|
| 317 |
try:
|
| 318 |
+
messages = _build_messages(req)
|
| 319 |
+
if messages:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
response_text = call_llm_messages(messages)
|
| 321 |
else:
|
| 322 |
template = TEMPLATES.get(req.style)
|
|
|
|
| 327 |
except HTTPException:
|
| 328 |
raise
|
| 329 |
except Exception:
|
| 330 |
+
logger.exception("generate_v1.failed")
|
| 331 |
raise HTTPException(status_code=502, detail="Failed to generate pseudocode. Please try again.")
|
| 332 |
|
| 333 |
+
set_cached_response(cache_key, response_text)
|
| 334 |
new_count = increment_usage_today(identifier)
|
| 335 |
remaining = max(0, limit - new_count)
|
| 336 |
|
|
|
|
| 340 |
"limit": limit,
|
| 341 |
"remaining": remaining,
|
| 342 |
"is_guest": is_guest,
|
| 343 |
+
"cached": False,
|
| 344 |
}
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
app.include_router(v1_router)
|
auth.py
CHANGED
|
@@ -1,7 +1,3 @@
|
|
| 1 |
-
# backend/auth.py
|
| 2 |
-
"""
|
| 3 |
-
JWT and password hashing for Pseudogen. get_current_user dependency for protected routes.
|
| 4 |
-
"""
|
| 5 |
import os
|
| 6 |
from datetime import datetime, timedelta, timezone
|
| 7 |
|
|
@@ -18,7 +14,7 @@ load_dotenv(dotenv_path=Path(__file__).resolve().parent / ".env")
|
|
| 18 |
|
| 19 |
SECRET_KEY = os.getenv("SECRET_KEY", "change-me-in-production-use-env")
|
| 20 |
ALGORITHM = "HS256"
|
| 21 |
-
ACCESS_TOKEN_EXPIRE_MINUTES =
|
| 22 |
|
| 23 |
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 24 |
security = HTTPBearer(auto_error=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
from datetime import datetime, timedelta, timezone
|
| 3 |
|
|
|
|
| 14 |
|
| 15 |
SECRET_KEY = os.getenv("SECRET_KEY", "change-me-in-production-use-env")
|
| 16 |
ALGORITHM = "HS256"
|
| 17 |
+
ACCESS_TOKEN_EXPIRE_MINUTES = 15
|
| 18 |
|
| 19 |
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 20 |
security = HTTPBearer(auto_error=False)
|
database.py
CHANGED
|
@@ -1,41 +1,113 @@
|
|
| 1 |
-
import
|
| 2 |
-
|
|
|
|
|
|
|
| 3 |
from pathlib import Path
|
| 4 |
|
| 5 |
_BACKEND_DIR = Path(__file__).resolve().parent
|
| 6 |
DB_PATH = _BACKEND_DIR / "pseudogen.db"
|
| 7 |
|
|
|
|
|
|
|
|
|
|
| 8 |
GUEST_DAILY_LIMIT = 5
|
| 9 |
USER_DAILY_LIMIT = 10
|
| 10 |
|
| 11 |
|
| 12 |
def get_connection():
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
def init_db():
|
| 19 |
conn = get_connection()
|
| 20 |
try:
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
conn.commit()
|
| 40 |
finally:
|
| 41 |
conn.close()
|
|
@@ -44,11 +116,11 @@ def init_db():
|
|
| 44 |
def get_user_by_email(email: str) -> dict | None:
|
| 45 |
conn = get_connection()
|
| 46 |
try:
|
| 47 |
-
|
| 48 |
-
|
|
|
|
| 49 |
(email.strip().lower(),),
|
| 50 |
-
)
|
| 51 |
-
return dict(row) if row else None
|
| 52 |
finally:
|
| 53 |
conn.close()
|
| 54 |
|
|
@@ -56,11 +128,11 @@ def get_user_by_email(email: str) -> dict | None:
|
|
| 56 |
def get_user_by_id(user_id: int) -> dict | None:
|
| 57 |
conn = get_connection()
|
| 58 |
try:
|
| 59 |
-
|
| 60 |
-
|
|
|
|
| 61 |
(user_id,),
|
| 62 |
-
)
|
| 63 |
-
return dict(row) if row else None
|
| 64 |
finally:
|
| 65 |
conn.close()
|
| 66 |
|
|
@@ -68,12 +140,22 @@ def get_user_by_id(user_id: int) -> dict | None:
|
|
| 68 |
def create_user(email: str, hashed_password: str, plan: str = "free") -> dict:
|
| 69 |
conn = get_connection()
|
| 70 |
try:
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
finally:
|
| 78 |
conn.close()
|
| 79 |
|
|
@@ -85,10 +167,11 @@ def _today_utc() -> str:
|
|
| 85 |
def get_usage_today(identifier: str) -> int:
|
| 86 |
conn = get_connection()
|
| 87 |
try:
|
| 88 |
-
row =
|
| 89 |
-
|
|
|
|
| 90 |
(identifier, _today_utc()),
|
| 91 |
-
)
|
| 92 |
return row["count"] if row else 0
|
| 93 |
finally:
|
| 94 |
conn.close()
|
|
@@ -98,19 +181,93 @@ def increment_usage_today(identifier: str) -> int:
|
|
| 98 |
today = _today_utc()
|
| 99 |
conn = get_connection()
|
| 100 |
try:
|
| 101 |
-
|
| 102 |
-
|
|
|
|
| 103 |
INSERT INTO daily_usage (identifier, date, count)
|
| 104 |
-
VALUES (
|
| 105 |
-
ON CONFLICT(identifier, date) DO UPDATE SET count = count + 1
|
| 106 |
""",
|
| 107 |
(identifier, today),
|
| 108 |
)
|
| 109 |
conn.commit()
|
| 110 |
-
row =
|
| 111 |
-
|
|
|
|
| 112 |
(identifier, today),
|
| 113 |
-
)
|
| 114 |
-
return row["count"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
finally:
|
| 116 |
conn.close()
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import hashlib
|
| 3 |
+
import secrets
|
| 4 |
+
from datetime import datetime, timezone, timedelta
|
| 5 |
from pathlib import Path
|
| 6 |
|
| 7 |
_BACKEND_DIR = Path(__file__).resolve().parent
|
| 8 |
DB_PATH = _BACKEND_DIR / "pseudogen.db"
|
| 9 |
|
| 10 |
+
_USE_PG = bool(os.getenv("DATABASE_URL"))
|
| 11 |
+
P = "%s" if _USE_PG else "?"
|
| 12 |
+
|
| 13 |
GUEST_DAILY_LIMIT = 5
|
| 14 |
USER_DAILY_LIMIT = 10
|
| 15 |
|
| 16 |
|
| 17 |
def get_connection():
|
| 18 |
+
if _USE_PG:
|
| 19 |
+
import psycopg2
|
| 20 |
+
url = os.getenv("DATABASE_URL", "")
|
| 21 |
+
if url.startswith("postgres://"):
|
| 22 |
+
url = url.replace("postgres://", "postgresql://", 1)
|
| 23 |
+
return psycopg2.connect(url)
|
| 24 |
+
else:
|
| 25 |
+
import sqlite3
|
| 26 |
+
conn = sqlite3.connect(DB_PATH)
|
| 27 |
+
conn.row_factory = sqlite3.Row
|
| 28 |
+
return conn
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _one(conn, sql, params=()):
|
| 32 |
+
if _USE_PG:
|
| 33 |
+
import psycopg2.extras
|
| 34 |
+
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
| 35 |
+
cur.execute(sql, params)
|
| 36 |
+
row = cur.fetchone()
|
| 37 |
+
return dict(row) if row else None
|
| 38 |
+
else:
|
| 39 |
+
row = conn.execute(sql, params).fetchone()
|
| 40 |
+
return dict(row) if row else None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _exec(conn, sql, params=()):
|
| 44 |
+
if _USE_PG:
|
| 45 |
+
cur = conn.cursor()
|
| 46 |
+
cur.execute(sql, params)
|
| 47 |
+
return cur
|
| 48 |
+
else:
|
| 49 |
+
return conn.execute(sql, params)
|
| 50 |
|
| 51 |
|
| 52 |
def init_db():
|
| 53 |
conn = get_connection()
|
| 54 |
try:
|
| 55 |
+
if _USE_PG:
|
| 56 |
+
_exec(conn, """
|
| 57 |
+
CREATE TABLE IF NOT EXISTS users (
|
| 58 |
+
id BIGSERIAL PRIMARY KEY,
|
| 59 |
+
email TEXT UNIQUE NOT NULL,
|
| 60 |
+
hashed_password TEXT NOT NULL,
|
| 61 |
+
plan TEXT NOT NULL DEFAULT 'free',
|
| 62 |
+
created_at TEXT NOT NULL DEFAULT NOW()::TEXT
|
| 63 |
+
)
|
| 64 |
+
""")
|
| 65 |
+
_exec(conn, """
|
| 66 |
+
CREATE TABLE IF NOT EXISTS daily_usage (
|
| 67 |
+
id BIGSERIAL PRIMARY KEY,
|
| 68 |
+
identifier TEXT NOT NULL,
|
| 69 |
+
date TEXT NOT NULL,
|
| 70 |
+
count INTEGER NOT NULL DEFAULT 0,
|
| 71 |
+
UNIQUE(identifier, date)
|
| 72 |
+
)
|
| 73 |
+
""")
|
| 74 |
+
_exec(conn, """
|
| 75 |
+
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
| 76 |
+
id BIGSERIAL PRIMARY KEY,
|
| 77 |
+
token_hash TEXT UNIQUE NOT NULL,
|
| 78 |
+
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
| 79 |
+
expires_at TEXT NOT NULL,
|
| 80 |
+
created_at TEXT NOT NULL DEFAULT NOW()::TEXT
|
| 81 |
+
)
|
| 82 |
+
""")
|
| 83 |
+
else:
|
| 84 |
+
_exec(conn, """
|
| 85 |
+
CREATE TABLE IF NOT EXISTS users (
|
| 86 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 87 |
+
email TEXT UNIQUE NOT NULL,
|
| 88 |
+
hashed_password TEXT NOT NULL,
|
| 89 |
+
plan TEXT NOT NULL DEFAULT 'free',
|
| 90 |
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
| 91 |
+
)
|
| 92 |
+
""")
|
| 93 |
+
_exec(conn, """
|
| 94 |
+
CREATE TABLE IF NOT EXISTS daily_usage (
|
| 95 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 96 |
+
identifier TEXT NOT NULL,
|
| 97 |
+
date TEXT NOT NULL,
|
| 98 |
+
count INTEGER NOT NULL DEFAULT 0,
|
| 99 |
+
UNIQUE(identifier, date)
|
| 100 |
+
)
|
| 101 |
+
""")
|
| 102 |
+
_exec(conn, """
|
| 103 |
+
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
| 104 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 105 |
+
token_hash TEXT UNIQUE NOT NULL,
|
| 106 |
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
| 107 |
+
expires_at TEXT NOT NULL,
|
| 108 |
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
| 109 |
+
)
|
| 110 |
+
""")
|
| 111 |
conn.commit()
|
| 112 |
finally:
|
| 113 |
conn.close()
|
|
|
|
| 116 |
def get_user_by_email(email: str) -> dict | None:
|
| 117 |
conn = get_connection()
|
| 118 |
try:
|
| 119 |
+
return _one(
|
| 120 |
+
conn,
|
| 121 |
+
f"SELECT id, email, hashed_password, plan, created_at FROM users WHERE email = {P}",
|
| 122 |
(email.strip().lower(),),
|
| 123 |
+
)
|
|
|
|
| 124 |
finally:
|
| 125 |
conn.close()
|
| 126 |
|
|
|
|
| 128 |
def get_user_by_id(user_id: int) -> dict | None:
|
| 129 |
conn = get_connection()
|
| 130 |
try:
|
| 131 |
+
return _one(
|
| 132 |
+
conn,
|
| 133 |
+
f"SELECT id, email, plan, created_at FROM users WHERE id = {P}",
|
| 134 |
(user_id,),
|
| 135 |
+
)
|
|
|
|
| 136 |
finally:
|
| 137 |
conn.close()
|
| 138 |
|
|
|
|
| 140 |
def create_user(email: str, hashed_password: str, plan: str = "free") -> dict:
|
| 141 |
conn = get_connection()
|
| 142 |
try:
|
| 143 |
+
if _USE_PG:
|
| 144 |
+
row = _one(
|
| 145 |
+
conn,
|
| 146 |
+
f"INSERT INTO users (email, hashed_password, plan) VALUES ({P}, {P}, {P}) RETURNING id, email, plan",
|
| 147 |
+
(email.strip().lower(), hashed_password, plan),
|
| 148 |
+
)
|
| 149 |
+
conn.commit()
|
| 150 |
+
return row
|
| 151 |
+
else:
|
| 152 |
+
cur = _exec(
|
| 153 |
+
conn,
|
| 154 |
+
f"INSERT INTO users (email, hashed_password, plan) VALUES ({P}, {P}, {P})",
|
| 155 |
+
(email.strip().lower(), hashed_password, plan),
|
| 156 |
+
)
|
| 157 |
+
conn.commit()
|
| 158 |
+
return {"id": cur.lastrowid, "email": email.strip().lower(), "plan": plan}
|
| 159 |
finally:
|
| 160 |
conn.close()
|
| 161 |
|
|
|
|
| 167 |
def get_usage_today(identifier: str) -> int:
|
| 168 |
conn = get_connection()
|
| 169 |
try:
|
| 170 |
+
row = _one(
|
| 171 |
+
conn,
|
| 172 |
+
f"SELECT count FROM daily_usage WHERE identifier = {P} AND date = {P}",
|
| 173 |
(identifier, _today_utc()),
|
| 174 |
+
)
|
| 175 |
return row["count"] if row else 0
|
| 176 |
finally:
|
| 177 |
conn.close()
|
|
|
|
| 181 |
today = _today_utc()
|
| 182 |
conn = get_connection()
|
| 183 |
try:
|
| 184 |
+
_exec(
|
| 185 |
+
conn,
|
| 186 |
+
f"""
|
| 187 |
INSERT INTO daily_usage (identifier, date, count)
|
| 188 |
+
VALUES ({P}, {P}, 1)
|
| 189 |
+
ON CONFLICT(identifier, date) DO UPDATE SET count = daily_usage.count + 1
|
| 190 |
""",
|
| 191 |
(identifier, today),
|
| 192 |
)
|
| 193 |
conn.commit()
|
| 194 |
+
row = _one(
|
| 195 |
+
conn,
|
| 196 |
+
f"SELECT count FROM daily_usage WHERE identifier = {P} AND date = {P}",
|
| 197 |
(identifier, today),
|
| 198 |
+
)
|
| 199 |
+
return row["count"] if row else 1
|
| 200 |
+
finally:
|
| 201 |
+
conn.close()
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
# ββ Refresh tokens ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 205 |
+
|
| 206 |
+
REFRESH_TOKEN_DAYS = 7
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def _hash_token(value: str) -> str:
|
| 210 |
+
return hashlib.sha256(value.encode()).hexdigest()
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def create_refresh_token(user_id: int) -> str:
|
| 214 |
+
"""Create a refresh token, persist its hash, return the raw value."""
|
| 215 |
+
value = secrets.token_urlsafe(32)
|
| 216 |
+
token_hash = _hash_token(value)
|
| 217 |
+
expires_at = (
|
| 218 |
+
datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_DAYS)
|
| 219 |
+
).isoformat()
|
| 220 |
+
conn = get_connection()
|
| 221 |
+
try:
|
| 222 |
+
_exec(
|
| 223 |
+
conn,
|
| 224 |
+
f"INSERT INTO refresh_tokens (token_hash, user_id, expires_at) VALUES ({P}, {P}, {P})",
|
| 225 |
+
(token_hash, user_id, expires_at),
|
| 226 |
+
)
|
| 227 |
+
conn.commit()
|
| 228 |
+
finally:
|
| 229 |
+
conn.close()
|
| 230 |
+
return value
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def validate_refresh_token(value: str) -> int | None:
|
| 234 |
+
"""Return user_id if valid and not expired, else None."""
|
| 235 |
+
token_hash = _hash_token(value)
|
| 236 |
+
conn = get_connection()
|
| 237 |
+
try:
|
| 238 |
+
row = _one(
|
| 239 |
+
conn,
|
| 240 |
+
f"SELECT user_id, expires_at FROM refresh_tokens WHERE token_hash = {P}",
|
| 241 |
+
(token_hash,),
|
| 242 |
+
)
|
| 243 |
+
if not row:
|
| 244 |
+
return None
|
| 245 |
+
expires = datetime.fromisoformat(row["expires_at"])
|
| 246 |
+
if expires.tzinfo is None:
|
| 247 |
+
expires = expires.replace(tzinfo=timezone.utc)
|
| 248 |
+
if datetime.now(timezone.utc) > expires:
|
| 249 |
+
_exec(conn, f"DELETE FROM refresh_tokens WHERE token_hash = {P}", (token_hash,))
|
| 250 |
+
conn.commit()
|
| 251 |
+
return None
|
| 252 |
+
return row["user_id"]
|
| 253 |
+
finally:
|
| 254 |
+
conn.close()
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def revoke_refresh_token(value: str) -> None:
|
| 258 |
+
token_hash = _hash_token(value)
|
| 259 |
+
conn = get_connection()
|
| 260 |
+
try:
|
| 261 |
+
_exec(conn, f"DELETE FROM refresh_tokens WHERE token_hash = {P}", (token_hash,))
|
| 262 |
+
conn.commit()
|
| 263 |
+
finally:
|
| 264 |
+
conn.close()
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def revoke_all_refresh_tokens(user_id: int) -> None:
|
| 268 |
+
conn = get_connection()
|
| 269 |
+
try:
|
| 270 |
+
_exec(conn, f"DELETE FROM refresh_tokens WHERE user_id = {P}", (user_id,))
|
| 271 |
+
conn.commit()
|
| 272 |
finally:
|
| 273 |
conn.close()
|
requirements.txt
CHANGED
|
@@ -19,6 +19,16 @@ bcrypt==4.0.1
|
|
| 19 |
PyJWT==2.10.1
|
| 20 |
email-validator==2.2.0
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
# Testing
|
| 23 |
pytest==8.3.4
|
| 24 |
pytest-asyncio==0.24.0
|
|
|
|
| 19 |
PyJWT==2.10.1
|
| 20 |
email-validator==2.2.0
|
| 21 |
|
| 22 |
+
# Database
|
| 23 |
+
psycopg2-binary==2.9.10
|
| 24 |
+
|
| 25 |
+
# Observability
|
| 26 |
+
structlog==24.4.0
|
| 27 |
+
sentry-sdk[fastapi]==2.19.2
|
| 28 |
+
|
| 29 |
+
# Caching / Redis rate-limit backend
|
| 30 |
+
redis==5.2.1
|
| 31 |
+
|
| 32 |
# Testing
|
| 33 |
pytest==8.3.4
|
| 34 |
pytest-asyncio==0.24.0
|
routers/auth.py
CHANGED
|
@@ -1,11 +1,41 @@
|
|
| 1 |
-
|
|
|
|
| 2 |
from pydantic import BaseModel, EmailStr
|
| 3 |
|
| 4 |
-
from database import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
from auth import hash_password, verify_password, create_access_token, get_current_user
|
| 6 |
|
| 7 |
router = APIRouter(prefix="/auth", tags=["auth"])
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
class RegisterRequest(BaseModel):
|
| 11 |
email: EmailStr
|
|
@@ -29,7 +59,7 @@ class UserResponse(BaseModel):
|
|
| 29 |
|
| 30 |
|
| 31 |
@router.post("/register", response_model=TokenResponse)
|
| 32 |
-
def register(req: RegisterRequest):
|
| 33 |
if len(req.password) < 8:
|
| 34 |
raise HTTPException(
|
| 35 |
status_code=status.HTTP_400_BAD_REQUEST,
|
|
@@ -42,20 +72,60 @@ def register(req: RegisterRequest):
|
|
| 42 |
)
|
| 43 |
hashed = hash_password(req.password)
|
| 44 |
user = create_user(req.email, hashed)
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
| 47 |
|
| 48 |
|
| 49 |
@router.post("/login", response_model=TokenResponse)
|
| 50 |
-
def login(req: LoginRequest):
|
| 51 |
user = get_user_by_email(req.email)
|
| 52 |
if not user or not verify_password(req.password, user["hashed_password"]):
|
| 53 |
raise HTTPException(
|
| 54 |
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 55 |
detail="Invalid email or password",
|
| 56 |
)
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
|
| 61 |
@router.get("/me", response_model=UserResponse)
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
| 3 |
from pydantic import BaseModel, EmailStr
|
| 4 |
|
| 5 |
+
from database import (
|
| 6 |
+
get_user_by_email,
|
| 7 |
+
create_user,
|
| 8 |
+
get_user_by_id,
|
| 9 |
+
create_refresh_token,
|
| 10 |
+
validate_refresh_token,
|
| 11 |
+
revoke_refresh_token,
|
| 12 |
+
revoke_all_refresh_tokens,
|
| 13 |
+
)
|
| 14 |
from auth import hash_password, verify_password, create_access_token, get_current_user
|
| 15 |
|
| 16 |
router = APIRouter(prefix="/auth", tags=["auth"])
|
| 17 |
|
| 18 |
+
_COOKIE_NAME = "pseudogen_rt"
|
| 19 |
+
_COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() == "true"
|
| 20 |
+
_COOKIE_SAMESITE = os.getenv("COOKIE_SAMESITE", "lax")
|
| 21 |
+
_COOKIE_MAX_AGE = 7 * 24 * 3600
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _set_refresh_cookie(response: Response, token_value: str) -> None:
|
| 25 |
+
response.set_cookie(
|
| 26 |
+
key=_COOKIE_NAME,
|
| 27 |
+
value=token_value,
|
| 28 |
+
httponly=True,
|
| 29 |
+
secure=_COOKIE_SECURE,
|
| 30 |
+
samesite=_COOKIE_SAMESITE,
|
| 31 |
+
max_age=_COOKIE_MAX_AGE,
|
| 32 |
+
path="/auth",
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _clear_refresh_cookie(response: Response) -> None:
|
| 37 |
+
response.delete_cookie(key=_COOKIE_NAME, path="/auth")
|
| 38 |
+
|
| 39 |
|
| 40 |
class RegisterRequest(BaseModel):
|
| 41 |
email: EmailStr
|
|
|
|
| 59 |
|
| 60 |
|
| 61 |
@router.post("/register", response_model=TokenResponse)
|
| 62 |
+
def register(req: RegisterRequest, response: Response):
|
| 63 |
if len(req.password) < 8:
|
| 64 |
raise HTTPException(
|
| 65 |
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
| 72 |
)
|
| 73 |
hashed = hash_password(req.password)
|
| 74 |
user = create_user(req.email, hashed)
|
| 75 |
+
access_token = create_access_token({"sub": str(user["id"])})
|
| 76 |
+
refresh_value = create_refresh_token(user["id"])
|
| 77 |
+
_set_refresh_cookie(response, refresh_value)
|
| 78 |
+
return TokenResponse(access_token=access_token)
|
| 79 |
|
| 80 |
|
| 81 |
@router.post("/login", response_model=TokenResponse)
|
| 82 |
+
def login(req: LoginRequest, response: Response):
|
| 83 |
user = get_user_by_email(req.email)
|
| 84 |
if not user or not verify_password(req.password, user["hashed_password"]):
|
| 85 |
raise HTTPException(
|
| 86 |
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 87 |
detail="Invalid email or password",
|
| 88 |
)
|
| 89 |
+
access_token = create_access_token({"sub": str(user["id"])})
|
| 90 |
+
refresh_value = create_refresh_token(user["id"])
|
| 91 |
+
_set_refresh_cookie(response, refresh_value)
|
| 92 |
+
return TokenResponse(access_token=access_token)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@router.post("/refresh", response_model=TokenResponse)
|
| 96 |
+
def refresh(request: Request, response: Response):
|
| 97 |
+
token_value = request.cookies.get(_COOKIE_NAME)
|
| 98 |
+
if not token_value:
|
| 99 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No refresh token")
|
| 100 |
+
user_id = validate_refresh_token(token_value)
|
| 101 |
+
if user_id is None:
|
| 102 |
+
_clear_refresh_cookie(response)
|
| 103 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token expired or invalid")
|
| 104 |
+
user = get_user_by_id(user_id)
|
| 105 |
+
if user is None:
|
| 106 |
+
_clear_refresh_cookie(response)
|
| 107 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
| 108 |
+
revoke_refresh_token(token_value)
|
| 109 |
+
new_access = create_access_token({"sub": str(user["id"])})
|
| 110 |
+
new_refresh = create_refresh_token(user["id"])
|
| 111 |
+
_set_refresh_cookie(response, new_refresh)
|
| 112 |
+
return TokenResponse(access_token=new_access)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@router.post("/logout")
|
| 116 |
+
def logout(request: Request, response: Response):
|
| 117 |
+
token_value = request.cookies.get(_COOKIE_NAME)
|
| 118 |
+
if token_value:
|
| 119 |
+
revoke_refresh_token(token_value)
|
| 120 |
+
_clear_refresh_cookie(response)
|
| 121 |
+
return {"detail": "Logged out"}
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@router.post("/logout-all")
|
| 125 |
+
def logout_all(response: Response, user: dict = Depends(get_current_user)):
|
| 126 |
+
revoke_all_refresh_tokens(user["id"])
|
| 127 |
+
_clear_refresh_cookie(response)
|
| 128 |
+
return {"detail": "All sessions revoked"}
|
| 129 |
|
| 130 |
|
| 131 |
@router.get("/me", response_model=UserResponse)
|
utils.py
CHANGED
|
@@ -1,8 +1,11 @@
|
|
| 1 |
import os
|
| 2 |
import time
|
|
|
|
|
|
|
|
|
|
| 3 |
import openai
|
| 4 |
import requests
|
| 5 |
-
import
|
| 6 |
from anthropic import Anthropic
|
| 7 |
from requests.exceptions import RequestException
|
| 8 |
from dotenv import load_dotenv
|
|
@@ -13,6 +16,74 @@ load_dotenv(dotenv_path=Path(__file__).resolve().parent / ".env")
|
|
| 13 |
openai.api_key = os.getenv("OPENAI_API_KEY")
|
| 14 |
_claude = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
def call_openai_with_retries(prompt: str, model: str = None, max_retries: int = 3, backoff: float = 1.0) -> str:
|
| 18 |
model = model or os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
|
@@ -30,7 +101,7 @@ def call_openai_with_retries(prompt: str, model: str = None, max_retries: int =
|
|
| 30 |
raise RuntimeError("Empty response from OpenAI")
|
| 31 |
except Exception as e:
|
| 32 |
last_err = e
|
| 33 |
-
|
| 34 |
if attempt < max_retries:
|
| 35 |
time.sleep(backoff * attempt)
|
| 36 |
raise RuntimeError(f"OpenAI failed after {max_retries} attempts: {last_err}")
|
|
@@ -54,7 +125,7 @@ def call_claude_with_retries(prompt: str, model: str = None, max_retries: int =
|
|
| 54 |
raise RuntimeError("Empty response from Claude")
|
| 55 |
except Exception as e:
|
| 56 |
last_err = e
|
| 57 |
-
|
| 58 |
if attempt < max_retries:
|
| 59 |
time.sleep(backoff * attempt)
|
| 60 |
raise RuntimeError(f"Claude failed after {max_retries} attempts: {last_err}")
|
|
@@ -66,37 +137,25 @@ def call_groq_with_retries(prompt: str, model: str = None, max_retries: int = 3,
|
|
| 66 |
raise RuntimeError("Missing GROQ_API_KEY")
|
| 67 |
model = model or os.getenv("GROQ_MODEL", "llama3-8b-8192")
|
| 68 |
ssl_verify = os.getenv("GROQ_SSL_VERIFY", "true").lower() != "false"
|
| 69 |
-
headers = {
|
| 70 |
-
|
| 71 |
-
"Content-Type": "application/json",
|
| 72 |
-
}
|
| 73 |
-
payload = {
|
| 74 |
-
"model": model,
|
| 75 |
-
"messages": [{"role": "user", "content": prompt}],
|
| 76 |
-
"temperature": 0.2,
|
| 77 |
-
"max_tokens": 1000,
|
| 78 |
-
}
|
| 79 |
last_err = None
|
| 80 |
for attempt in range(1, max_retries + 1):
|
| 81 |
try:
|
| 82 |
resp = requests.post(
|
| 83 |
"https://api.groq.com/openai/v1/chat/completions",
|
| 84 |
-
headers=headers,
|
| 85 |
-
json=payload,
|
| 86 |
-
timeout=30,
|
| 87 |
-
verify=ssl_verify,
|
| 88 |
)
|
| 89 |
if resp.status_code == 200:
|
| 90 |
-
|
| 91 |
-
content = data.get("choices", [{}])[0].get("message", {}).get("content")
|
| 92 |
if content:
|
| 93 |
return content.strip()
|
| 94 |
raise RuntimeError("Empty response from Groq")
|
| 95 |
-
|
| 96 |
resp.raise_for_status()
|
| 97 |
except (RequestException, Exception) as e:
|
| 98 |
last_err = e
|
| 99 |
-
|
| 100 |
if attempt < max_retries:
|
| 101 |
time.sleep(backoff * attempt)
|
| 102 |
raise RuntimeError(f"Groq failed after {max_retries} attempts: {last_err}")
|
|
@@ -104,7 +163,7 @@ def call_groq_with_retries(prompt: str, model: str = None, max_retries: int = 3,
|
|
| 104 |
|
| 105 |
def call_llm(prompt: str) -> str:
|
| 106 |
provider = os.getenv("PROVIDER", "openai").lower()
|
| 107 |
-
if provider
|
| 108 |
return call_claude_with_retries(prompt)
|
| 109 |
elif provider == "openai":
|
| 110 |
return call_openai_with_retries(prompt)
|
|
@@ -113,6 +172,8 @@ def call_llm(prompt: str) -> str:
|
|
| 113 |
raise RuntimeError(f"Unsupported PROVIDER: {provider}")
|
| 114 |
|
| 115 |
|
|
|
|
|
|
|
| 116 |
def call_groq_with_messages(messages: list, model: str = None, max_retries: int = 3, backoff: float = 1.0) -> str:
|
| 117 |
api_key = os.getenv("GROQ_API_KEY")
|
| 118 |
if not api_key:
|
|
@@ -133,11 +194,10 @@ def call_groq_with_messages(messages: list, model: str = None, max_retries: int
|
|
| 133 |
if content:
|
| 134 |
return content.strip()
|
| 135 |
raise RuntimeError("Empty response from Groq")
|
| 136 |
-
logging.error(f"Groq API error ({resp.status_code}): {resp.text}")
|
| 137 |
resp.raise_for_status()
|
| 138 |
except (RequestException, Exception) as e:
|
| 139 |
last_err = e
|
| 140 |
-
|
| 141 |
if attempt < max_retries:
|
| 142 |
time.sleep(backoff * attempt)
|
| 143 |
raise RuntimeError(f"Groq failed after {max_retries} attempts: {last_err}")
|
|
@@ -156,7 +216,7 @@ def call_openai_with_messages(messages: list, model: str = None, max_retries: in
|
|
| 156 |
raise RuntimeError("Empty response from OpenAI")
|
| 157 |
except Exception as e:
|
| 158 |
last_err = e
|
| 159 |
-
|
| 160 |
if attempt < max_retries:
|
| 161 |
time.sleep(backoff * attempt)
|
| 162 |
raise RuntimeError(f"OpenAI failed after {max_retries} attempts: {last_err}")
|
|
@@ -180,7 +240,7 @@ def call_claude_with_messages(messages: list, model: str = None, max_retries: in
|
|
| 180 |
raise RuntimeError("Empty response from Claude")
|
| 181 |
except Exception as e:
|
| 182 |
last_err = e
|
| 183 |
-
|
| 184 |
if attempt < max_retries:
|
| 185 |
time.sleep(backoff * attempt)
|
| 186 |
raise RuntimeError(f"Claude failed after {max_retries} attempts: {last_err}")
|
|
@@ -195,3 +255,72 @@ def call_llm_messages(messages: list) -> str:
|
|
| 195 |
elif provider == "groq":
|
| 196 |
return call_groq_with_messages(messages)
|
| 197 |
raise RuntimeError(f"Unsupported PROVIDER: {provider}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
import time
|
| 3 |
+
import json
|
| 4 |
+
import hashlib
|
| 5 |
+
import logging
|
| 6 |
import openai
|
| 7 |
import requests
|
| 8 |
+
import structlog
|
| 9 |
from anthropic import Anthropic
|
| 10 |
from requests.exceptions import RequestException
|
| 11 |
from dotenv import load_dotenv
|
|
|
|
| 16 |
openai.api_key = os.getenv("OPENAI_API_KEY")
|
| 17 |
_claude = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
|
| 18 |
|
| 19 |
+
# ββ Structlog setup βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 20 |
+
|
| 21 |
+
def configure_logging() -> None:
|
| 22 |
+
log_level = getattr(logging, os.getenv("LOG_LEVEL", "INFO").upper(), logging.INFO)
|
| 23 |
+
use_json = os.getenv("LOG_JSON", "false").lower() == "true"
|
| 24 |
+
|
| 25 |
+
processors = [
|
| 26 |
+
structlog.stdlib.filter_by_level,
|
| 27 |
+
structlog.stdlib.add_logger_name,
|
| 28 |
+
structlog.stdlib.add_log_level,
|
| 29 |
+
structlog.processors.TimeStamper(fmt="iso"),
|
| 30 |
+
structlog.stdlib.PositionalArgumentsFormatter(),
|
| 31 |
+
structlog.processors.StackInfoRenderer(),
|
| 32 |
+
structlog.processors.format_exc_info,
|
| 33 |
+
structlog.processors.JSONRenderer() if use_json else structlog.dev.ConsoleRenderer(),
|
| 34 |
+
]
|
| 35 |
+
structlog.configure(
|
| 36 |
+
processors=processors,
|
| 37 |
+
wrapper_class=structlog.stdlib.BoundLogger,
|
| 38 |
+
context_class=dict,
|
| 39 |
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
| 40 |
+
cache_logger_on_first_use=True,
|
| 41 |
+
)
|
| 42 |
+
logging.basicConfig(level=log_level, format="%(message)s")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
configure_logging()
|
| 46 |
+
logger = structlog.get_logger("pseudogen")
|
| 47 |
+
|
| 48 |
+
# ββ Redis (optional) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 49 |
+
|
| 50 |
+
_redis = None
|
| 51 |
+
_redis_url = os.getenv("REDIS_URL")
|
| 52 |
+
if _redis_url:
|
| 53 |
+
try:
|
| 54 |
+
import redis as _redis_lib
|
| 55 |
+
_redis = _redis_lib.from_url(_redis_url, decode_responses=True, socket_connect_timeout=2)
|
| 56 |
+
_redis.ping()
|
| 57 |
+
logger.info("redis.connected", url=_redis_url.split("@")[-1])
|
| 58 |
+
except Exception as e:
|
| 59 |
+
logger.warning("redis.unavailable", error=str(e))
|
| 60 |
+
_redis = None
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def get_cached_response(key: str) -> str | None:
|
| 64 |
+
if not _redis:
|
| 65 |
+
return None
|
| 66 |
+
try:
|
| 67 |
+
return _redis.get(f"pgcache:{key}")
|
| 68 |
+
except Exception:
|
| 69 |
+
return None
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def set_cached_response(key: str, value: str, ttl: int = 3600) -> None:
|
| 73 |
+
if not _redis:
|
| 74 |
+
return
|
| 75 |
+
try:
|
| 76 |
+
_redis.setex(f"pgcache:{key}", ttl, value)
|
| 77 |
+
except Exception:
|
| 78 |
+
pass
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def make_cache_key(problem: str, style: str, detail: str) -> str:
|
| 82 |
+
payload = f"{style}:{detail}:{problem.strip().lower()}"
|
| 83 |
+
return hashlib.sha256(payload.encode()).hexdigest()
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# ββ Single-turn LLM (non-streaming) ββββββββββββββββββββββββββββββββββββββββββ
|
| 87 |
|
| 88 |
def call_openai_with_retries(prompt: str, model: str = None, max_retries: int = 3, backoff: float = 1.0) -> str:
|
| 89 |
model = model or os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
|
|
|
| 101 |
raise RuntimeError("Empty response from OpenAI")
|
| 102 |
except Exception as e:
|
| 103 |
last_err = e
|
| 104 |
+
logger.warning("openai.retry", attempt=attempt, error=str(e))
|
| 105 |
if attempt < max_retries:
|
| 106 |
time.sleep(backoff * attempt)
|
| 107 |
raise RuntimeError(f"OpenAI failed after {max_retries} attempts: {last_err}")
|
|
|
|
| 125 |
raise RuntimeError("Empty response from Claude")
|
| 126 |
except Exception as e:
|
| 127 |
last_err = e
|
| 128 |
+
logger.warning("claude.retry", attempt=attempt, error=str(e))
|
| 129 |
if attempt < max_retries:
|
| 130 |
time.sleep(backoff * attempt)
|
| 131 |
raise RuntimeError(f"Claude failed after {max_retries} attempts: {last_err}")
|
|
|
|
| 137 |
raise RuntimeError("Missing GROQ_API_KEY")
|
| 138 |
model = model or os.getenv("GROQ_MODEL", "llama3-8b-8192")
|
| 139 |
ssl_verify = os.getenv("GROQ_SSL_VERIFY", "true").lower() != "false"
|
| 140 |
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
| 141 |
+
payload = {"model": model, "messages": [{"role": "user", "content": prompt}], "temperature": 0.2, "max_tokens": 1000}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
last_err = None
|
| 143 |
for attempt in range(1, max_retries + 1):
|
| 144 |
try:
|
| 145 |
resp = requests.post(
|
| 146 |
"https://api.groq.com/openai/v1/chat/completions",
|
| 147 |
+
headers=headers, json=payload, timeout=30, verify=ssl_verify,
|
|
|
|
|
|
|
|
|
|
| 148 |
)
|
| 149 |
if resp.status_code == 200:
|
| 150 |
+
content = resp.json().get("choices", [{}])[0].get("message", {}).get("content")
|
|
|
|
| 151 |
if content:
|
| 152 |
return content.strip()
|
| 153 |
raise RuntimeError("Empty response from Groq")
|
| 154 |
+
logger.error("groq.error", status=resp.status_code)
|
| 155 |
resp.raise_for_status()
|
| 156 |
except (RequestException, Exception) as e:
|
| 157 |
last_err = e
|
| 158 |
+
logger.warning("groq.retry", attempt=attempt, error=str(e))
|
| 159 |
if attempt < max_retries:
|
| 160 |
time.sleep(backoff * attempt)
|
| 161 |
raise RuntimeError(f"Groq failed after {max_retries} attempts: {last_err}")
|
|
|
|
| 163 |
|
| 164 |
def call_llm(prompt: str) -> str:
|
| 165 |
provider = os.getenv("PROVIDER", "openai").lower()
|
| 166 |
+
if provider in ("claude", "anthropic"):
|
| 167 |
return call_claude_with_retries(prompt)
|
| 168 |
elif provider == "openai":
|
| 169 |
return call_openai_with_retries(prompt)
|
|
|
|
| 172 |
raise RuntimeError(f"Unsupported PROVIDER: {provider}")
|
| 173 |
|
| 174 |
|
| 175 |
+
# ββ Multi-turn LLM (non-streaming) βββββββββββββββββββββββββββββββββββββββββββ
|
| 176 |
+
|
| 177 |
def call_groq_with_messages(messages: list, model: str = None, max_retries: int = 3, backoff: float = 1.0) -> str:
|
| 178 |
api_key = os.getenv("GROQ_API_KEY")
|
| 179 |
if not api_key:
|
|
|
|
| 194 |
if content:
|
| 195 |
return content.strip()
|
| 196 |
raise RuntimeError("Empty response from Groq")
|
|
|
|
| 197 |
resp.raise_for_status()
|
| 198 |
except (RequestException, Exception) as e:
|
| 199 |
last_err = e
|
| 200 |
+
logger.warning("groq.retry", attempt=attempt, error=str(e))
|
| 201 |
if attempt < max_retries:
|
| 202 |
time.sleep(backoff * attempt)
|
| 203 |
raise RuntimeError(f"Groq failed after {max_retries} attempts: {last_err}")
|
|
|
|
| 216 |
raise RuntimeError("Empty response from OpenAI")
|
| 217 |
except Exception as e:
|
| 218 |
last_err = e
|
| 219 |
+
logger.warning("openai.retry", attempt=attempt, error=str(e))
|
| 220 |
if attempt < max_retries:
|
| 221 |
time.sleep(backoff * attempt)
|
| 222 |
raise RuntimeError(f"OpenAI failed after {max_retries} attempts: {last_err}")
|
|
|
|
| 240 |
raise RuntimeError("Empty response from Claude")
|
| 241 |
except Exception as e:
|
| 242 |
last_err = e
|
| 243 |
+
logger.warning("claude.retry", attempt=attempt, error=str(e))
|
| 244 |
if attempt < max_retries:
|
| 245 |
time.sleep(backoff * attempt)
|
| 246 |
raise RuntimeError(f"Claude failed after {max_retries} attempts: {last_err}")
|
|
|
|
| 255 |
elif provider == "groq":
|
| 256 |
return call_groq_with_messages(messages)
|
| 257 |
raise RuntimeError(f"Unsupported PROVIDER: {provider}")
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
# ββ Streaming LLM βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 261 |
+
|
| 262 |
+
def call_groq_stream(messages: list, model: str = None):
|
| 263 |
+
"""Sync generator β yields text tokens from Groq SSE stream."""
|
| 264 |
+
api_key = os.getenv("GROQ_API_KEY")
|
| 265 |
+
if not api_key:
|
| 266 |
+
raise RuntimeError("Missing GROQ_API_KEY")
|
| 267 |
+
model = model or os.getenv("GROQ_MODEL", "llama3-8b-8192")
|
| 268 |
+
ssl_verify = os.getenv("GROQ_SSL_VERIFY", "true").lower() != "false"
|
| 269 |
+
resp = requests.post(
|
| 270 |
+
"https://api.groq.com/openai/v1/chat/completions",
|
| 271 |
+
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
| 272 |
+
json={"model": model, "messages": messages, "temperature": 0.2, "max_tokens": 1000, "stream": True},
|
| 273 |
+
timeout=60,
|
| 274 |
+
verify=ssl_verify,
|
| 275 |
+
stream=True,
|
| 276 |
+
)
|
| 277 |
+
resp.raise_for_status()
|
| 278 |
+
for line in resp.iter_lines():
|
| 279 |
+
if not line or line == b"data: [DONE]":
|
| 280 |
+
continue
|
| 281 |
+
if line.startswith(b"data: "):
|
| 282 |
+
try:
|
| 283 |
+
data = json.loads(line[6:])
|
| 284 |
+
delta = data["choices"][0]["delta"].get("content", "")
|
| 285 |
+
if delta:
|
| 286 |
+
yield delta
|
| 287 |
+
except (json.JSONDecodeError, KeyError, IndexError):
|
| 288 |
+
pass
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def call_openai_stream(messages: list, model: str = None):
|
| 292 |
+
"""Sync generator β yields text tokens from OpenAI streaming."""
|
| 293 |
+
model = model or os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
| 294 |
+
resp = openai.ChatCompletion.create(
|
| 295 |
+
model=model, messages=messages, temperature=0.2, max_tokens=1200, stream=True,
|
| 296 |
+
)
|
| 297 |
+
for chunk in resp:
|
| 298 |
+
delta = chunk["choices"][0]["delta"].get("content", "")
|
| 299 |
+
if delta:
|
| 300 |
+
yield delta
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def call_claude_stream(messages: list, model: str = None):
|
| 304 |
+
"""Sync generator β yields text tokens from Claude streaming."""
|
| 305 |
+
model = model or os.getenv("CLAUDE_MODEL", "claude-3-5-haiku-20241022")
|
| 306 |
+
system_parts = [m["content"] for m in messages if m.get("role") == "system"]
|
| 307 |
+
non_system = [m for m in messages if m.get("role") != "system"]
|
| 308 |
+
kwargs = {"model": model, "max_tokens": 1000, "messages": non_system}
|
| 309 |
+
if system_parts:
|
| 310 |
+
kwargs["system"] = " ".join(system_parts)
|
| 311 |
+
with _claude.messages.stream(**kwargs) as stream:
|
| 312 |
+
for text in stream.text_stream:
|
| 313 |
+
yield text
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def call_llm_stream(messages: list):
|
| 317 |
+
"""Sync generator β dispatches to the configured provider's streaming function."""
|
| 318 |
+
provider = os.getenv("PROVIDER", "openai").lower()
|
| 319 |
+
if provider in ("claude", "anthropic"):
|
| 320 |
+
yield from call_claude_stream(messages)
|
| 321 |
+
elif provider == "openai":
|
| 322 |
+
yield from call_openai_stream(messages)
|
| 323 |
+
elif provider == "groq":
|
| 324 |
+
yield from call_groq_stream(messages)
|
| 325 |
+
else:
|
| 326 |
+
raise RuntimeError(f"Unsupported PROVIDER: {provider}")
|