semblance-dev/backend/app/routes/usage.py
Vadym Samoilenko bc4138f332 Final pieces: decorators on LLM routes, usage self-service, billing page, WS events
Backend:
- @active_required + @with_user_context applied to all LLM-invoking routes
  in personas.py, focus_group_ai.py, ai_personas.py
- backend/app/routes/usage.py: GET /api/usage/me (MTD summary by feature),
  GET /api/usage/focus-groups/<id> (owner or admin)
- Registered usage_bp in app/__init__.py
- llm_service._record_usage now emits usage_update WS event to focus group room

Frontend:
- useMyUsage + useFocusGroupUsage hooks
- MyUsage.tsx: personal billing dashboard (cost cards + per-feature table)
- /billing route (ProtectedRoute) + Billing nav link
- FocusGroupSession: quota_warning amber banner with Progress bar,
  quota_exceeded + quota_warning WS events wired via websocketServiceNew

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:43:13 +01:00

105 lines
3.9 KiB
Python

"""User self-service usage endpoints."""
import logging
from datetime import datetime, timezone
from quart import Blueprint, jsonify, request
from app.auth.quart_jwt import jwt_required, get_jwt_identity
from app.utils import active_required
from app.models.usage_event import UsageEvent
from app.db import get_db
logger = logging.getLogger(__name__)
usage_bp = Blueprint('usage', __name__)
def _month_start() -> datetime:
now = datetime.now(timezone.utc)
return now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
@usage_bp.route('/me', methods=['GET'])
@jwt_required()
async def my_usage():
"""GET /api/usage/me — current user's own MTD cost summary."""
user_id = get_jwt_identity()
try:
period_start = _month_start()
db = await get_db()
pipeline = [
{'$match': {'user_id': user_id, 'ts': {'$gte': period_start}}},
{'$group': {
'_id': None,
'total_cost': {'$sum': '$cost_usd.total'},
'prompt_tokens': {'$sum': '$prompt_tokens'},
'completion_tokens': {'$sum': '$completion_tokens'},
'calls': {'$sum': 1},
}},
]
agg = await db.usage_events.aggregate(pipeline).to_list(1)
totals = agg[0] if agg else {'total_cost': 0, 'prompt_tokens': 0, 'completion_tokens': 0, 'calls': 0}
totals.pop('_id', None)
# By feature
feat_pipeline = [
{'$match': {'user_id': user_id, 'ts': {'$gte': period_start}}},
{'$group': {
'_id': '$feature',
'total_cost': {'$sum': '$cost_usd.total'},
'calls': {'$sum': 1},
}},
{'$sort': {'total_cost': -1}},
]
by_feature = await db.usage_events.aggregate(feat_pipeline).to_list(20)
from app.utils import make_serializable
return jsonify({
'totals': make_serializable(totals),
'by_feature': make_serializable(by_feature),
'period_start': period_start.isoformat(),
}), 200
except Exception as e:
logger.error(f"my_usage error: {e}", exc_info=True)
return jsonify({'error': str(e)}), 500
@usage_bp.route('/focus-groups/<fg_id>', methods=['GET'])
@jwt_required()
async def focus_group_usage(fg_id: str):
"""GET /api/usage/focus-groups/<id> — usage for a specific focus group (owner or admin)."""
user_id = get_jwt_identity()
try:
# Auth check — owner or admin
db = await get_db()
from bson import ObjectId
try:
fg = await db.focus_groups.find_one({'_id': ObjectId(fg_id)})
except Exception:
return jsonify({'error': 'Invalid id'}), 400
if not fg:
return jsonify({'error': 'Not found'}), 404
from app.models.user import User
user = await User.find_by_id(user_id)
is_admin = user and user.get('role') == 'admin'
is_owner = fg.get('created_by') == user_id or str(fg.get('user_id', '')) == user_id
if not is_admin and not is_owner:
return jsonify({'error': 'Forbidden'}), 403
pipeline = [
{'$match': {'focus_group_id': fg_id}},
{'$group': {
'_id': None,
'total_cost': {'$sum': '$cost_usd.total'},
'prompt_tokens': {'$sum': '$prompt_tokens'},
'completion_tokens': {'$sum': '$completion_tokens'},
'calls': {'$sum': 1},
}},
]
agg = await db.usage_events.aggregate(pipeline).to_list(1)
totals = agg[0] if agg else {'total_cost': 0, 'prompt_tokens': 0, 'completion_tokens': 0, 'calls': 0}
totals.pop('_id', None)
from app.utils import make_serializable
return jsonify({'totals': make_serializable(totals), 'focus_group_id': fg_id}), 200
except Exception as e:
logger.error(f"focus_group_usage error: {e}", exc_info=True)
return jsonify({'error': str(e)}), 500