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)

Files changed (6) hide show
  1. app.py +152 -73
  2. auth.py +1 -5
  3. database.py +205 -48
  4. requirements.txt +10 -0
  5. routers/auth.py +78 -8
  6. 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 call_llm, call_llm_messages
 
 
 
 
 
 
 
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
- logging.basicConfig(
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
- limiter = Limiter(key_func=get_remote_address)
68
- app = FastAPI(title="Pseudogen API", lifespan=lifespan)
 
 
 
 
 
 
69
  app.state.limiter = limiter
70
  app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
71
 
72
- _cors_origins = os.getenv("CORS_ORIGINS", "*").strip()
73
- allow_origins = (
74
- [o.strip() for o in _cors_origins.split(",") if o.strip()]
75
- if _cors_origins != "*"
76
- else ["*"]
77
- )
 
 
 
78
  app.add_middleware(
79
  CORSMiddleware,
80
- allow_origins=allow_origins,
81
- allow_credentials=True,
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("Summarize failed")
177
  raise HTTPException(status_code=502, detail="Summarization failed")
178
 
179
 
180
- @v1_router.post("/generate-pseudocode")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  @limiter.limit("30/minute")
182
- async def generate_v1(
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
- return await _generate(req, user, x_session_id)
 
 
 
 
 
 
 
 
 
 
189
 
 
 
 
 
190
 
191
- app.include_router(v1_router)
 
 
 
 
 
 
 
 
 
 
192
 
 
 
193
 
194
- @app.post("/generate-pseudocode")
 
 
 
 
 
 
 
 
 
 
 
195
  @limiter.limit("30/minute")
196
- async def generate(
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
- return await _generate(req, user, x_session_id)
203
-
204
-
205
- async def _generate(req: GenerateRequest, user: dict | None, x_session_id: str | None):
206
- if user:
207
- identifier = f"user:{user['id']}"
208
- limit = USER_DAILY_LIMIT
209
- is_guest = False
210
- elif x_session_id:
211
- identifier = f"session:{x_session_id}"
212
- limit = GUEST_DAILY_LIMIT
213
- is_guest = True
214
- else:
215
- raise HTTPException(
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
- if is_guest:
223
- raise HTTPException(
224
- status_code=429,
225
- detail=f"You've used all {limit} free prompts for today. Create a free account to get {USER_DAILY_LIMIT} per day.",
226
- )
227
- else:
228
- raise HTTPException(
229
- status_code=429,
230
- detail=f"Daily limit of {limit} prompts reached. Resets at midnight UTC.",
231
- )
232
 
233
  try:
234
- if req.context:
235
- system_msg = (
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("LLM call failed")
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 = 60 * 24 * 7 # 7 days
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 sqlite3
2
- from datetime import datetime, timezone
 
 
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
- conn = sqlite3.connect(DB_PATH)
14
- conn.row_factory = sqlite3.Row
15
- return conn
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
 
18
  def init_db():
19
  conn = get_connection()
20
  try:
21
- conn.execute("""
22
- CREATE TABLE IF NOT EXISTS users (
23
- id INTEGER PRIMARY KEY AUTOINCREMENT,
24
- email TEXT UNIQUE NOT NULL,
25
- hashed_password TEXT NOT NULL,
26
- plan TEXT NOT NULL DEFAULT 'free',
27
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
28
- )
29
- """)
30
- conn.execute("""
31
- CREATE TABLE IF NOT EXISTS daily_usage (
32
- id INTEGER PRIMARY KEY AUTOINCREMENT,
33
- identifier TEXT NOT NULL,
34
- date TEXT NOT NULL,
35
- count INTEGER NOT NULL DEFAULT 0,
36
- UNIQUE(identifier, date)
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
- row = conn.execute(
48
- "SELECT id, email, hashed_password, plan, created_at FROM users WHERE email = ?",
 
49
  (email.strip().lower(),),
50
- ).fetchone()
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
- row = conn.execute(
60
- "SELECT id, email, plan, created_at FROM users WHERE id = ?",
 
61
  (user_id,),
62
- ).fetchone()
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
- cursor = conn.execute(
72
- "INSERT INTO users (email, hashed_password, plan) VALUES (?, ?, ?)",
73
- (email.strip().lower(), hashed_password, plan),
74
- )
75
- conn.commit()
76
- return {"id": cursor.lastrowid, "email": email.strip().lower(), "plan": plan}
 
 
 
 
 
 
 
 
 
 
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 = conn.execute(
89
- "SELECT count FROM daily_usage WHERE identifier = ? AND date = ?",
 
90
  (identifier, _today_utc()),
91
- ).fetchone()
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
- conn.execute(
102
- """
 
103
  INSERT INTO daily_usage (identifier, date, count)
104
- VALUES (?, ?, 1)
105
- ON CONFLICT(identifier, date) DO UPDATE SET count = count + 1
106
  """,
107
  (identifier, today),
108
  )
109
  conn.commit()
110
- row = conn.execute(
111
- "SELECT count FROM daily_usage WHERE identifier = ? AND date = ?",
 
112
  (identifier, today),
113
- ).fetchone()
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
- from fastapi import APIRouter, Depends, HTTPException, status
 
2
  from pydantic import BaseModel, EmailStr
3
 
4
- from database import get_user_by_email, create_user
 
 
 
 
 
 
 
 
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
- token = create_access_token({"sub": str(user["id"])})
46
- return TokenResponse(access_token=token)
 
 
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
- token = create_access_token({"sub": str(user["id"])})
58
- return TokenResponse(access_token=token)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 logging
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
- logging.warning(f"OpenAI attempt {attempt} failed: {e}")
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
- logging.warning(f"Claude attempt {attempt} failed: {e}")
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
- "Authorization": f"Bearer {api_key}",
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
- data = resp.json()
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
- logging.error(f"Groq API error ({resp.status_code}): {resp.text}")
96
  resp.raise_for_status()
97
  except (RequestException, Exception) as e:
98
  last_err = e
99
- logging.warning(f"Groq attempt {attempt} failed: {e}")
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 == "claude" or provider == "anthropic":
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
- logging.warning(f"Groq attempt {attempt} failed: {e}")
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
- logging.warning(f"OpenAI attempt {attempt} failed: {e}")
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
- logging.warning(f"Claude attempt {attempt} failed: {e}")
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}")