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>
105 lines
3.9 KiB
Python
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
|