semblance-dev/backend/app/utils/__init__.py
Vadym Samoilenko 3e9ccafad2 Add LLM usage tracking infrastructure (Phases A-C)
- 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>
2026-04-24 18:08:27 +01:00

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