- Model renames: gpt-5.2 → gpt-5.4-2026-03-05, gemini-3-pro-preview → gemini-3.1-pro-preview; retire gpt-4.1 via alias fallback - New: llm_usage_context.py (ContextVar-based attribution), model_pricing.py (tiered pricing + 60s cache), usage_event.py (append-only telemetry), quota.py (user/FG quota enforcement with 80% warning) - Wire _record_usage into all 3 LLM methods; set_llm_context at every service entry point - Fix admin_required decorator (was sync, never awaited User.find_by_id); add active_required and with_user_context decorators - Inject user_id into ContextVar from JWT on every authenticated request - Add DB indexes for usage_events, model_pricing, users collections - Seed script for model pricing (gpt-5.4 single-tier, gemini-3.1 two-tier 200k threshold) - Fix parse_json_response NameError (logger undefined at module level) - 70 passing tests: conftest.py with sys.modules stubs, test_usage_infrastructure.py (52 tests), rewrite stale test_llm_service.py (18 tests) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
75 lines
2.7 KiB
Python
Executable file
75 lines
2.7 KiB
Python
Executable file
# Utils package for Synthetic Society backend
|
|
from functools import wraps
|
|
from datetime import datetime
|
|
from bson import ObjectId
|
|
from quart import jsonify
|
|
from app.auth.quart_jwt import get_jwt_identity
|
|
from app.models.user import User
|
|
|
|
|
|
def make_serializable(obj):
|
|
"""Recursively convert MongoDB documents to JSON-serializable types."""
|
|
if isinstance(obj, dict):
|
|
return {k: make_serializable(v) for k, v in obj.items()}
|
|
elif isinstance(obj, list):
|
|
return [make_serializable(item) for item in obj]
|
|
elif isinstance(obj, ObjectId):
|
|
return str(obj)
|
|
elif isinstance(obj, datetime):
|
|
return obj.isoformat()
|
|
else:
|
|
return obj
|
|
|
|
|
|
def admin_required(f):
|
|
"""Route decorator requiring admin role. Must be stacked BELOW @jwt_required()."""
|
|
@wraps(f)
|
|
async def decorated(*args, **kwargs):
|
|
user_id = get_jwt_identity()
|
|
if not user_id:
|
|
return jsonify({"message": "Authentication required"}), 401
|
|
user_data = await User.find_by_id(user_id)
|
|
if not user_data:
|
|
return jsonify({"message": "User not found"}), 404
|
|
if user_data.get("role") != "admin":
|
|
return jsonify({"message": "Admin privileges required"}), 403
|
|
if user_data.get("is_active") is False:
|
|
return jsonify({"message": "Account disabled"}), 403
|
|
return await f(*args, **kwargs)
|
|
return decorated
|
|
|
|
|
|
def active_required(f):
|
|
"""Route decorator that rejects requests from disabled users.
|
|
|
|
Guards LLM-invoking routes so that revoking a user's access takes effect
|
|
immediately rather than waiting for their JWT to expire (24 h window).
|
|
Must be stacked BELOW @jwt_required().
|
|
"""
|
|
@wraps(f)
|
|
async def decorated(*args, **kwargs):
|
|
user_id = get_jwt_identity()
|
|
if user_id:
|
|
user_data = await User.find_by_id(user_id)
|
|
if user_data and user_data.get("is_active") is False:
|
|
return jsonify({"message": "Account disabled"}), 403
|
|
return await f(*args, **kwargs)
|
|
return decorated
|
|
|
|
|
|
def with_user_context(f):
|
|
"""Route decorator that injects the JWT user_id into the LLM usage ContextVar.
|
|
|
|
Must be stacked BELOW @jwt_required() so the token is already validated.
|
|
The context propagates to asyncio tasks and run_coroutine_threadsafe calls,
|
|
so autonomous AI runner conversations pick up the user attribution automatically.
|
|
"""
|
|
@wraps(f)
|
|
async def decorated(*args, **kwargs):
|
|
from app.services.llm_usage_context import llm_context
|
|
user_id = get_jwt_identity()
|
|
if user_id:
|
|
with llm_context(user_id=user_id):
|
|
return await f(*args, **kwargs)
|
|
return await f(*args, **kwargs)
|
|
return decorated
|