Complete phases D–G: quota enforcement, token invalidation, admin writes, backfill
Backend: - token_version in JWT (bump_token_version, get_token_version on User model); jwt_required checks tv claim → 401 on mismatch; login routes embed version - Quota pre-flight in all 3 LLM public methods (QuotaExceededError bubbles up) - AI runner catches QuotaExceededError → sets status paused_quota + emits WS event - Admin routes: POST /users (create), POST /users/<id>/reset-password, POST /pricing, GET /focus-groups with aggregated cost; PUT /users/<id> now bumps token_version on disable or role change - backfill_usage.py: idempotent estimated-event generator for historical data, tiktoken for GPT models, char/3.8 for Gemini, --dry-run flag Frontend: - 402 interceptor dispatches quota_exceeded CustomEvent - adminApi: createUser, resetPassword, createPricing, listFocusGroups - UsersTab: New User dialog + Reset Password in edit dialog - PricingTab: New Price dialog (model, provider, input/output/cached prices) - FocusGroupsTab: focus groups table sorted by total cost - Admin.tsx: 4th tab (Focus Groups) - FocusGroupSession: admin-only cost badge + dismissable quota exceeded banner Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
015e6cc5cc
commit
915c81b8f1
16 changed files with 941 additions and 22 deletions
|
|
@ -31,14 +31,15 @@ class QuartJWTError(Exception):
|
|||
pass
|
||||
|
||||
|
||||
def create_access_token(identity: str, expires_delta: Optional[timedelta] = None) -> str:
|
||||
def create_access_token(identity: str, expires_delta: Optional[timedelta] = None, token_version: int = 0) -> str:
|
||||
"""
|
||||
Create a JWT access token.
|
||||
|
||||
|
||||
Args:
|
||||
identity: User identifier (usually user ID)
|
||||
expires_delta: Optional expiration time override
|
||||
|
||||
token_version: Token version for invalidation support
|
||||
|
||||
Returns:
|
||||
JWT token string
|
||||
"""
|
||||
|
|
@ -46,14 +47,15 @@ def create_access_token(identity: str, expires_delta: Optional[timedelta] = None
|
|||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
else:
|
||||
expire = datetime.now(timezone.utc) + JWT_ACCESS_TOKEN_EXPIRES
|
||||
|
||||
|
||||
payload = {
|
||||
'sub': identity, # Subject (user ID)
|
||||
'exp': expire,
|
||||
'iat': datetime.now(timezone.utc),
|
||||
'type': 'access'
|
||||
'type': 'access',
|
||||
'tv': token_version,
|
||||
}
|
||||
|
||||
|
||||
return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
|
||||
|
||||
|
||||
|
|
@ -148,6 +150,20 @@ def jwt_required(optional: bool = False):
|
|||
# Store user ID in request context
|
||||
g.current_user_id = user_id
|
||||
|
||||
# Token-version check — invalidates old tokens after password reset or disable
|
||||
try:
|
||||
tv_in_token = payload.get("tv", 0)
|
||||
from app.models.user import User as _User
|
||||
current_tv = await _User.get_token_version(user_id)
|
||||
if tv_in_token < current_tv:
|
||||
return Response(
|
||||
json.dumps({"error": "Token invalidated"}),
|
||||
status=401,
|
||||
mimetype="application/json",
|
||||
)
|
||||
except Exception:
|
||||
pass # Non-fatal — a DB failure must not block auth
|
||||
|
||||
# Propagate user_id into the LLM usage ContextVar for this request.
|
||||
# Each Quart request runs in its own asyncio Task, so setting the ContextVar
|
||||
# here is request-scoped. Child tasks (create_task) and thread submissions
|
||||
|
|
|
|||
|
|
@ -73,6 +73,22 @@ class User:
|
|||
{"$set": fields}
|
||||
)
|
||||
return result.matched_count > 0
|
||||
|
||||
@staticmethod
|
||||
async def bump_token_version(user_id) -> int:
|
||||
db = await get_db()
|
||||
result = await db.users.find_one_and_update(
|
||||
{"_id": ObjectId(user_id)},
|
||||
{"$inc": {"token_version": 1}},
|
||||
return_document=True
|
||||
)
|
||||
return result.get("token_version", 1) if result else 1
|
||||
|
||||
@staticmethod
|
||||
async def get_token_version(user_id) -> int:
|
||||
db = await get_db()
|
||||
doc = await db.users.find_one({"_id": ObjectId(user_id)}, {"token_version": 1})
|
||||
return doc.get("token_version", 0) if doc else 0
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -139,6 +139,10 @@ async def update_user(user_id):
|
|||
if not updated:
|
||||
return jsonify({'error': 'User not found'}), 404
|
||||
|
||||
# Bump token_version so existing JWTs are immediately invalidated
|
||||
if fields.get('is_active') is False or 'role' in fields:
|
||||
await User.bump_token_version(user_id)
|
||||
|
||||
logger.info(f"Admin updated user {user_id}: {list(fields.keys())}")
|
||||
user = await User.find_by_id(user_id)
|
||||
return jsonify(_safe_user(user)), 200
|
||||
|
|
@ -314,3 +318,150 @@ async def list_pricing():
|
|||
}).sort([('model', 1), ('effective_from', -1)])
|
||||
rows = await cursor.to_list(length=100)
|
||||
return jsonify({'pricing': make_serializable(rows)}), 200
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Users — extended
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@admin_bp.route('/users', methods=['POST'])
|
||||
@jwt_required()
|
||||
@admin_required
|
||||
async def create_user():
|
||||
"""POST /api/admin/users — create a local (non-SSO) user."""
|
||||
import bcrypt as _bcrypt
|
||||
data = await request.get_json(silent=True) or {}
|
||||
username = (data.get('username') or '').strip()
|
||||
email = (data.get('email') or '').strip()
|
||||
password = (data.get('password') or '').strip()
|
||||
role = data.get('role', 'user')
|
||||
|
||||
if not username or not email or not password:
|
||||
return jsonify({'error': 'username, email, password required'}), 400
|
||||
if role not in ('user', 'admin'):
|
||||
return jsonify({'error': 'Invalid role. Must be user or admin'}), 400
|
||||
|
||||
db = await get_db()
|
||||
if await db.users.find_one({'$or': [{'username': username}, {'email': email}]}):
|
||||
return jsonify({'error': 'Username or email already exists'}), 409
|
||||
|
||||
pw_hash = _bcrypt.hashpw(password.encode(), _bcrypt.gensalt()).decode()
|
||||
now = datetime.now(timezone.utc)
|
||||
doc = {
|
||||
'username': username,
|
||||
'email': email,
|
||||
'password_hash': pw_hash,
|
||||
'role': role,
|
||||
'is_active': True,
|
||||
'override_quota': False,
|
||||
'token_version': 0,
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
}
|
||||
result = await db.users.insert_one(doc)
|
||||
doc['_id'] = result.inserted_id
|
||||
logger.info(f"Admin created user {username} ({email})")
|
||||
return jsonify(_safe_user(make_serializable(doc))), 201
|
||||
|
||||
|
||||
@admin_bp.route('/users/<user_id>/reset-password', methods=['POST'])
|
||||
@jwt_required()
|
||||
@admin_required
|
||||
async def reset_password(user_id):
|
||||
"""POST /api/admin/users/<id>/reset-password"""
|
||||
import bcrypt as _bcrypt
|
||||
data = await request.get_json(silent=True) or {}
|
||||
new_password = (data.get('password') or '').strip()
|
||||
if not new_password or len(new_password) < 8:
|
||||
return jsonify({'error': 'Password must be at least 8 characters'}), 400
|
||||
|
||||
pw_hash = _bcrypt.hashpw(new_password.encode(), _bcrypt.gensalt()).decode()
|
||||
db = await get_db()
|
||||
try:
|
||||
result = await db.users.update_one(
|
||||
{'_id': ObjectId(user_id)},
|
||||
{'$set': {'password_hash': pw_hash}}
|
||||
)
|
||||
except Exception:
|
||||
return jsonify({'error': 'Invalid user ID'}), 400
|
||||
|
||||
if result.matched_count == 0:
|
||||
return jsonify({'error': 'User not found'}), 404
|
||||
|
||||
await User.bump_token_version(user_id)
|
||||
logger.info(f"Admin reset password for user {user_id}")
|
||||
return jsonify({'ok': True}), 200
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Pricing — extended
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@admin_bp.route('/pricing', methods=['POST'])
|
||||
@jwt_required()
|
||||
@admin_required
|
||||
async def create_pricing():
|
||||
"""POST /api/admin/pricing — insert a new pricing row."""
|
||||
data = await request.get_json(silent=True) or {}
|
||||
model = (data.get('model') or '').strip()
|
||||
provider = (data.get('provider') or '').strip()
|
||||
tiers = data.get('tiers', [])
|
||||
if not model or not provider or not tiers:
|
||||
return jsonify({'error': 'model, provider, tiers required'}), 400
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
expire_current = bool(data.get('expire_current', False))
|
||||
db = await get_db()
|
||||
if expire_current:
|
||||
await db.model_pricing.update_many(
|
||||
{'model': model, 'effective_until': None},
|
||||
{'$set': {'effective_until': now}},
|
||||
)
|
||||
doc = {
|
||||
'model': model,
|
||||
'provider': provider,
|
||||
'currency': 'USD',
|
||||
'tiers': tiers,
|
||||
'effective_from': now,
|
||||
'effective_until': None,
|
||||
'notes': data.get('notes', ''),
|
||||
}
|
||||
result = await db.model_pricing.insert_one(doc)
|
||||
doc['_id'] = result.inserted_id
|
||||
logger.info(f"Admin created pricing row for model {model}")
|
||||
return jsonify(make_serializable(doc)), 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Focus Groups (admin view)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@admin_bp.route('/focus-groups', methods=['GET'])
|
||||
@jwt_required()
|
||||
@admin_required
|
||||
async def list_focus_groups():
|
||||
"""GET /api/admin/focus-groups?skip=&limit= — list all focus groups with cost totals."""
|
||||
skip = max(0, int(request.args.get('skip', 0)))
|
||||
limit = min(200, max(1, int(request.args.get('limit', 50))))
|
||||
db = await get_db()
|
||||
cursor = db.focus_groups.find(
|
||||
{},
|
||||
{'name': 1, 'date': 1, 'status': 1, 'llm_model': 1, 'quota': 1},
|
||||
).sort('date', -1).skip(skip).limit(limit)
|
||||
fgs = await cursor.to_list(length=limit)
|
||||
|
||||
result = []
|
||||
for fg in fgs:
|
||||
fg_id = str(fg['_id'])
|
||||
pipeline = [
|
||||
{'$match': {'focus_group_id': fg_id}},
|
||||
{'$group': {'_id': None, 'total': {'$sum': '$cost_usd.total'}, 'calls': {'$sum': 1}}},
|
||||
]
|
||||
agg = await db.usage_events.aggregate(pipeline).to_list(1)
|
||||
fg['cost_total'] = agg[0]['total'] if agg else 0
|
||||
fg['call_count'] = agg[0]['calls'] if agg else 0
|
||||
result.append(fg)
|
||||
|
||||
result.sort(key=lambda x: x['cost_total'], reverse=True)
|
||||
total = await db.focus_groups.count_documents({})
|
||||
return jsonify({'focus_groups': make_serializable(result), 'total': total}), 200
|
||||
|
|
|
|||
|
|
@ -60,8 +60,9 @@ async def login():
|
|||
if not User.check_password(user_data['password_hash'], password):
|
||||
return jsonify({"message": "Invalid username or password"}), 401
|
||||
|
||||
# Generate access token
|
||||
access_token = create_access_token(identity=str(user_data['_id']))
|
||||
# Generate access token (embed token_version for invalidation support)
|
||||
tv = user_data.get("token_version", 0)
|
||||
access_token = create_access_token(identity=str(user_data['_id']), token_version=tv)
|
||||
|
||||
return jsonify({
|
||||
"message": "Login successful",
|
||||
|
|
@ -168,8 +169,9 @@ async def microsoft_login():
|
|||
print(f"Error creating Microsoft user: {e}")
|
||||
return jsonify({"message": "Failed to create user account"}), 500
|
||||
|
||||
# Generate our backend JWT access token
|
||||
access_token = create_access_token(identity=str(existing_user['_id']))
|
||||
# Generate our backend JWT access token (embed token_version for invalidation support)
|
||||
_tv = existing_user.get("token_version", 0)
|
||||
access_token = create_access_token(identity=str(existing_user['_id']), token_version=_tv)
|
||||
|
||||
# Return response in same format as local login
|
||||
return jsonify({
|
||||
|
|
|
|||
|
|
@ -258,6 +258,33 @@ class AutonomousConversationController:
|
|||
}
|
||||
|
||||
except Exception as e:
|
||||
# Handle quota exhaustion — pause gracefully instead of erroring
|
||||
try:
|
||||
from app.models.quota import QuotaExceededError
|
||||
if isinstance(e, QuotaExceededError):
|
||||
self.logger.warning(
|
||||
f"Quota exceeded for focus group {self.focus_group_id}: {e}"
|
||||
)
|
||||
self.is_running = False
|
||||
self.conversation_state = "paused_quota"
|
||||
await FocusGroup.update(self.focus_group_id, {"status": "paused_quota"})
|
||||
try:
|
||||
from app.websocket_manager_async import websocket_manager
|
||||
await websocket_manager.emit_to_focus_group(
|
||||
self.focus_group_id,
|
||||
"quota_exceeded",
|
||||
{
|
||||
"scope": e.scope,
|
||||
"limit_usd": e.limit_usd,
|
||||
"used_usd": e.used_usd,
|
||||
"focus_group_id": self.focus_group_id,
|
||||
},
|
||||
)
|
||||
except Exception as _we:
|
||||
self.logger.warning(f"Could not emit quota_exceeded WS event: {_we}")
|
||||
return {"error": "quota_exceeded", "scope": e.scope}
|
||||
except ImportError:
|
||||
pass
|
||||
self.logger.error(f"Error in conversation loop: {str(e)}")
|
||||
self.conversation_state = "error"
|
||||
await self.stop_conversation("error")
|
||||
|
|
|
|||
|
|
@ -294,6 +294,18 @@ class LLMService:
|
|||
max_retries = 3
|
||||
last_error = None
|
||||
|
||||
# Quota pre-flight — raises QuotaExceededError if over limit
|
||||
try:
|
||||
from app.models.quota import check_quota, QuotaExceededError as _QuotaExceededError
|
||||
from app.services.llm_usage_context import current_context as _ctx
|
||||
_c = _ctx()
|
||||
await check_quota(_c.user_id, _c.focus_group_id)
|
||||
except Exception as _qe:
|
||||
from app.models.quota import QuotaExceededError as _QEE
|
||||
if isinstance(_qe, _QEE):
|
||||
raise
|
||||
pass # Non-fatal: DB failures must not block LLM calls
|
||||
|
||||
actual_model = LLMService._resolve_model(model_name)
|
||||
provider = LLMService._get_model_provider(model_name)
|
||||
_start_time = time.monotonic()
|
||||
|
|
@ -609,6 +621,18 @@ class LLMService:
|
|||
max_retries = 3
|
||||
last_error = None
|
||||
|
||||
# Quota pre-flight — raises QuotaExceededError if over limit
|
||||
try:
|
||||
from app.models.quota import check_quota, QuotaExceededError as _QuotaExceededError
|
||||
from app.services.llm_usage_context import current_context as _ctx
|
||||
_c = _ctx()
|
||||
await check_quota(_c.user_id, _c.focus_group_id)
|
||||
except Exception as _qe:
|
||||
from app.models.quota import QuotaExceededError as _QEE
|
||||
if isinstance(_qe, _QEE):
|
||||
raise
|
||||
pass # Non-fatal: DB failures must not block LLM calls
|
||||
|
||||
actual_model = LLMService._resolve_model(model_name)
|
||||
provider = LLMService._get_model_provider(model_name)
|
||||
|
||||
|
|
@ -805,11 +829,23 @@ class LLMService:
|
|||
LLMServiceError: If there's an issue with generation
|
||||
"""
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Quota pre-flight — raises QuotaExceededError if over limit
|
||||
try:
|
||||
from app.models.quota import check_quota, QuotaExceededError as _QuotaExceededError
|
||||
from app.services.llm_usage_context import current_context as _ctx
|
||||
_c = _ctx()
|
||||
await check_quota(_c.user_id, _c.focus_group_id)
|
||||
except Exception as _qe:
|
||||
from app.models.quota import QuotaExceededError as _QEE
|
||||
if isinstance(_qe, _QEE):
|
||||
raise
|
||||
pass # Non-fatal: DB failures must not block LLM calls
|
||||
|
||||
# Separate text and image content from the conversation context
|
||||
text_context_parts = []
|
||||
image_parts = []
|
||||
|
||||
|
||||
print(f"🎯 Processing {len(conversation_context)} context items for LLM")
|
||||
|
||||
for item in conversation_context:
|
||||
|
|
|
|||
251
backend/scripts/backfill_usage.py
Normal file
251
backend/scripts/backfill_usage.py
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Backfill usage_events from existing focus-group messages and personas.
|
||||
|
||||
Creates estimated usage_event docs (is_estimated=True) so the admin dashboard
|
||||
can show historical cost data for sessions that pre-date the usage tracking system.
|
||||
|
||||
Idempotent: skips documents that already have an estimated event in the collection.
|
||||
|
||||
Usage:
|
||||
cd backend
|
||||
python scripts/backfill_usage.py [--dry-run]
|
||||
|
||||
Environment:
|
||||
MONGO_URI — connection string (falls back to localhost:27017 without auth)
|
||||
DB_NAME — database name (default: semblance_db)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pymongo import MongoClient
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Token estimation helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _estimate_tokens(text: str, model: str) -> dict:
|
||||
"""Estimate prompt/completion tokens for a piece of text."""
|
||||
if not text:
|
||||
return {"prompt": 0, "completion": 0}
|
||||
|
||||
# Try tiktoken for OpenAI models, fall back to char-based estimate
|
||||
if model and ("gpt" in model.lower() or "openai" in model.lower()):
|
||||
try:
|
||||
import tiktoken
|
||||
enc = tiktoken.encoding_for_model("gpt-4")
|
||||
n = len(enc.encode(text))
|
||||
return {"prompt": n, "completion": 0}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Gemini / unknown: ~3.8 chars per token
|
||||
n = max(1, int(len(text) / 3.8))
|
||||
return {"prompt": n, "completion": 0}
|
||||
|
||||
|
||||
def _estimate_cost(prompt_tokens: int, completion_tokens: int, model: str) -> float:
|
||||
"""Very rough cost estimate in USD (used only for backfill estimates)."""
|
||||
# Approximate per-million-token prices for common models
|
||||
rate_per_m = {
|
||||
"gemini": (0.35, 1.05), # input, output USD/1M tokens
|
||||
"gpt-4": (30.00, 60.00),
|
||||
"gpt-3": (0.50, 1.50),
|
||||
}
|
||||
key = "gemini"
|
||||
if model:
|
||||
m = model.lower()
|
||||
if "gpt-4" in m or "gpt-5" in m:
|
||||
key = "gpt-4"
|
||||
elif "gpt-3" in m:
|
||||
key = "gpt-3"
|
||||
|
||||
input_rate, output_rate = rate_per_m[key]
|
||||
cost = (prompt_tokens / 1_000_000) * input_rate + (completion_tokens / 1_000_000) * output_rate
|
||||
return round(cost, 8)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# DB connection (sync PyMongo)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def connect():
|
||||
mongo_uri = os.environ.get("MONGO_URI", "mongodb://localhost:27017")
|
||||
db_name = os.environ.get("DB_NAME", "semblance_db")
|
||||
try:
|
||||
client = MongoClient(mongo_uri, serverSelectionTimeoutMS=5000)
|
||||
client.admin.command("ping")
|
||||
print(f"Connected to MongoDB: {db_name}")
|
||||
return client[db_name]
|
||||
except Exception as e:
|
||||
print(f"ERROR: Could not connect to MongoDB: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Backfill focus-group messages
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def backfill_messages(db, dry_run: bool) -> int:
|
||||
"""Walk all focus groups and create estimated usage events for messages."""
|
||||
created = 0
|
||||
focus_groups = list(db.focus_groups.find({}))
|
||||
print(f"\n[messages] Found {len(focus_groups)} focus groups to process")
|
||||
|
||||
for fg in focus_groups:
|
||||
fg_id = str(fg["_id"])
|
||||
fg_model = fg.get("llm_model") or "gemini-3.1-pro-preview"
|
||||
messages = fg.get("messages", [])
|
||||
|
||||
for msg in messages:
|
||||
msg_id = str(msg.get("id") or msg.get("_id") or "")
|
||||
if not msg_id:
|
||||
continue
|
||||
|
||||
# Idempotent: skip if an estimated event already exists for this message
|
||||
existing = db.usage_events.find_one({
|
||||
"source_message_id": msg_id,
|
||||
"is_estimated": True,
|
||||
})
|
||||
if existing:
|
||||
continue
|
||||
|
||||
text = msg.get("content") or ""
|
||||
tokens = _estimate_tokens(text, fg_model)
|
||||
# For responses we add a rough output token estimate
|
||||
tokens["completion"] = max(1, int(len(text) / 5.0))
|
||||
cost = _estimate_cost(tokens["prompt"], tokens["completion"], fg_model)
|
||||
|
||||
ts = msg.get("timestamp")
|
||||
if isinstance(ts, str):
|
||||
try:
|
||||
ts = datetime.fromisoformat(ts)
|
||||
except Exception:
|
||||
ts = None
|
||||
ts = ts or fg.get("date") or datetime.now(timezone.utc)
|
||||
|
||||
event = {
|
||||
"ts": ts,
|
||||
"provider": "gemini" if "gemini" in fg_model.lower() else "openai",
|
||||
"model": fg_model,
|
||||
"feature": "autonomous_conversation",
|
||||
"user_id": str(fg.get("user_id") or ""),
|
||||
"focus_group_id": fg_id,
|
||||
"persona_id": str(msg.get("personaId") or msg.get("persona_id") or ""),
|
||||
"prompt_tokens": tokens["prompt"],
|
||||
"completion_tokens": tokens["completion"],
|
||||
"cached_tokens": 0,
|
||||
"reasoning_tokens": 0,
|
||||
"cost_usd": {
|
||||
"input": round(cost * 0.4, 8),
|
||||
"output": round(cost * 0.6, 8),
|
||||
"total": cost,
|
||||
},
|
||||
"duration_ms": 0,
|
||||
"retry_count": 0,
|
||||
"status": "estimated",
|
||||
"is_estimated": True,
|
||||
"source_message_id": msg_id,
|
||||
}
|
||||
|
||||
if not dry_run:
|
||||
db.usage_events.insert_one(event)
|
||||
created += 1
|
||||
|
||||
print(f"[messages] {'Would create' if dry_run else 'Created'} {created} estimated usage events")
|
||||
return created
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Backfill persona generation
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def backfill_personas(db, dry_run: bool) -> int:
|
||||
"""Walk all personas and create an estimated usage event for narrative generation."""
|
||||
created = 0
|
||||
personas = list(db.personas.find({}))
|
||||
print(f"\n[personas] Found {len(personas)} personas to process")
|
||||
|
||||
for persona in personas:
|
||||
persona_id = str(persona["_id"])
|
||||
narrative = persona.get("narrative") or ""
|
||||
if not narrative:
|
||||
continue # No narrative to estimate from — skip
|
||||
|
||||
# Idempotent check
|
||||
existing = db.usage_events.find_one({
|
||||
"persona_id": persona_id,
|
||||
"feature": "persona_generate",
|
||||
"is_estimated": True,
|
||||
})
|
||||
if existing:
|
||||
continue
|
||||
|
||||
model = "gemini-3.1-pro-preview" # default; personas are usually generated via default model
|
||||
tokens = _estimate_tokens(narrative, model)
|
||||
tokens["completion"] = max(1, int(len(narrative) / 4.0))
|
||||
cost = _estimate_cost(tokens["prompt"], tokens["completion"], model)
|
||||
|
||||
ts = persona.get("created_at") or persona.get("updatedAt") or datetime.now(timezone.utc)
|
||||
if isinstance(ts, str):
|
||||
try:
|
||||
ts = datetime.fromisoformat(ts)
|
||||
except Exception:
|
||||
ts = datetime.now(timezone.utc)
|
||||
|
||||
event = {
|
||||
"ts": ts,
|
||||
"provider": "gemini",
|
||||
"model": model,
|
||||
"feature": "persona_generate",
|
||||
"user_id": str(persona.get("user_id") or ""),
|
||||
"focus_group_id": str(persona.get("focus_group_id") or ""),
|
||||
"persona_id": persona_id,
|
||||
"prompt_tokens": tokens["prompt"],
|
||||
"completion_tokens": tokens["completion"],
|
||||
"cached_tokens": 0,
|
||||
"reasoning_tokens": 0,
|
||||
"cost_usd": {
|
||||
"input": round(cost * 0.4, 8),
|
||||
"output": round(cost * 0.6, 8),
|
||||
"total": cost,
|
||||
},
|
||||
"duration_ms": 0,
|
||||
"retry_count": 0,
|
||||
"status": "estimated",
|
||||
"is_estimated": True,
|
||||
}
|
||||
|
||||
if not dry_run:
|
||||
db.usage_events.insert_one(event)
|
||||
created += 1
|
||||
|
||||
print(f"[personas] {'Would create' if dry_run else 'Created'} {created} estimated usage events")
|
||||
return created
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Main
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Backfill usage_events from existing data")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Preview what would be created without writing")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.dry_run:
|
||||
print("=== DRY RUN — no data will be written ===\n")
|
||||
|
||||
db = connect()
|
||||
|
||||
total = 0
|
||||
total += backfill_messages(db, args.dry_run)
|
||||
total += backfill_personas(db, args.dry_run)
|
||||
|
||||
print(f"\n{'[DRY RUN] ' if args.dry_run else ''}Backfill complete — {total} events total")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
58
src/components/admin/FocusGroupsTab.tsx
Normal file
58
src/components/admin/FocusGroupsTab.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { useAdminFocusGroups } from '@/hooks/useAdminFocusGroups';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export default function FocusGroupsTab() {
|
||||
const { data, isLoading } = useAdminFocusGroups();
|
||||
const fgs = data?.focus_groups ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Total Cost</TableHead>
|
||||
<TableHead className="text-right">Calls</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{fgs.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-slate-500 py-8">No focus groups</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{fgs.map((fg: any) => (
|
||||
<TableRow key={fg._id}>
|
||||
<TableCell className="font-medium">{fg.name}</TableCell>
|
||||
<TableCell className="text-xs text-slate-500">
|
||||
{fg.date ? new Date(fg.date).toLocaleDateString() : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{fg.llm_model ?? '—'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={fg.status === 'completed' ? 'secondary' : fg.status === 'paused_quota' ? 'destructive' : 'outline'}
|
||||
className="text-xs"
|
||||
>
|
||||
{fg.status ?? 'active'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs">${(fg.cost_total ?? 0).toFixed(4)}</TableCell>
|
||||
<TableCell className="text-right text-xs">{fg.call_count ?? 0}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,13 @@
|
|||
import { useAdminPricing } from '@/hooks/useAdminPricing';
|
||||
import { useState } from 'react';
|
||||
import { useAdminPricing, useCreatePricing } from '@/hooks/useAdminPricing';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Loader2, Plus } from 'lucide-react';
|
||||
|
||||
interface PricingTier {
|
||||
threshold_input_tokens: number;
|
||||
|
|
@ -23,11 +29,51 @@ interface PricingRow {
|
|||
|
||||
export default function PricingTab() {
|
||||
const { data, isLoading } = useAdminPricing();
|
||||
const createPricing = useCreatePricing();
|
||||
const rows: PricingRow[] = data?.pricing ?? [];
|
||||
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [model, setModel] = useState('');
|
||||
const [provider, setProvider] = useState('gemini');
|
||||
const [inputPerMtok, setInputPerMtok] = useState('');
|
||||
const [outputPerMtok, setOutputPerMtok] = useState('');
|
||||
const [cachedInputPerMtok, setCachedInputPerMtok] = useState('');
|
||||
const [expireCurrent, setExpireCurrent] = useState(true);
|
||||
|
||||
const handleCreate = () => {
|
||||
const payload: any = {
|
||||
model,
|
||||
provider,
|
||||
tiers: [{
|
||||
threshold_input_tokens: 0,
|
||||
input_per_mtok: parseFloat(inputPerMtok),
|
||||
output_per_mtok: parseFloat(outputPerMtok),
|
||||
cached_input_per_mtok: cachedInputPerMtok ? parseFloat(cachedInputPerMtok) : null,
|
||||
}],
|
||||
expire_current: expireCurrent,
|
||||
};
|
||||
createPricing.mutate(payload, {
|
||||
onSuccess: () => {
|
||||
setShowDialog(false);
|
||||
setModel('');
|
||||
setProvider('gemini');
|
||||
setInputPerMtok('');
|
||||
setOutputPerMtok('');
|
||||
setCachedInputPerMtok('');
|
||||
setExpireCurrent(true);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-slate-500">Active pricing rows used for cost calculations. New rows can be added via API.</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-slate-500">Active pricing rows used for cost calculations.</p>
|
||||
<Button size="sm" onClick={() => setShowDialog(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Price
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
|
|
@ -91,6 +137,92 @@ export default function PricingTab() {
|
|||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Price Dialog */}
|
||||
<Dialog open={showDialog} onOpenChange={open => !open && setShowDialog(false)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Pricing Row</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1">
|
||||
<Label>Model</Label>
|
||||
<Input
|
||||
placeholder="e.g. gemini-2.5-pro"
|
||||
value={model}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Provider</Label>
|
||||
<Select value={provider} onValueChange={setProvider}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="gemini">Gemini</SelectItem>
|
||||
<SelectItem value="openai">OpenAI</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Input price ($/M tokens)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="e.g. 1.25"
|
||||
value={inputPerMtok}
|
||||
onChange={e => setInputPerMtok(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Output price ($/M tokens)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="e.g. 5.00"
|
||||
value={outputPerMtok}
|
||||
onChange={e => setOutputPerMtok(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Cached input price ($/M tokens, optional)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="e.g. 0.31"
|
||||
value={cachedInputPerMtok}
|
||||
onChange={e => setCachedInputPerMtok(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="expire_current"
|
||||
type="checkbox"
|
||||
checked={expireCurrent}
|
||||
onChange={e => setExpireCurrent(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-slate-300"
|
||||
/>
|
||||
<Label htmlFor="expire_current" className="cursor-pointer">
|
||||
Expire current active row for this model
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowDialog(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!model || !inputPerMtok || !outputPerMtok || createPricing.isPending}
|
||||
>
|
||||
{createPricing.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from 'react';
|
||||
import { useAdminUsers, useUpdateUser, useDisableUser, useEnableUser } from '@/hooks/useAdminUsers';
|
||||
import { useAdminUsers, useUpdateUser, useDisableUser, useEnableUser, useCreateUser, useResetPassword } from '@/hooks/useAdminUsers';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -7,7 +7,7 @@ import { Input } from '@/components/ui/input';
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2, Search, UserCog, ShieldCheck, Ban, CheckCircle } from 'lucide-react';
|
||||
import { Loader2, Search, UserCog, Ban, CheckCircle, UserPlus } from 'lucide-react';
|
||||
|
||||
interface User {
|
||||
_id: string;
|
||||
|
|
@ -27,11 +27,21 @@ export default function UsersTab() {
|
|||
const [editRole, setEditRole] = useState('user');
|
||||
const [editQuota, setEditQuota] = useState('');
|
||||
const [editOverride, setEditOverride] = useState(false);
|
||||
const [resetPassword, setResetPassword] = useState('');
|
||||
|
||||
// Create user dialog state
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [newUsername, setNewUsername] = useState('');
|
||||
const [newEmail, setNewEmail] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [newRole, setNewRole] = useState('user');
|
||||
|
||||
const { data, isLoading } = useAdminUsers({ q: search, role: roleFilter || undefined });
|
||||
const updateUser = useUpdateUser();
|
||||
const disableUser = useDisableUser();
|
||||
const enableUser = useEnableUser();
|
||||
const createUser = useCreateUser();
|
||||
const resetPasswordMutation = useResetPassword();
|
||||
|
||||
const users: User[] = data?.users || [];
|
||||
|
||||
|
|
@ -40,6 +50,29 @@ export default function UsersTab() {
|
|||
setEditRole(u.role);
|
||||
setEditQuota(u.quota?.monthly_usd?.toString() ?? '');
|
||||
setEditOverride(u.override_quota ?? false);
|
||||
setResetPassword('');
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
createUser.mutate(
|
||||
{ username: newUsername, email: newEmail, password: newPassword, role: newRole },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setShowCreateDialog(false);
|
||||
setNewUsername('');
|
||||
setNewEmail('');
|
||||
setNewPassword('');
|
||||
setNewRole('user');
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleResetPassword = () => {
|
||||
if (!editUser || !resetPassword) return;
|
||||
resetPasswordMutation.mutate({ id: editUser._id, password: resetPassword }, {
|
||||
onSuccess: () => setResetPassword(''),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
|
|
@ -78,6 +111,10 @@ export default function UsersTab() {
|
|||
<SelectItem value="user">User</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => setShowCreateDialog(true)}>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
New User
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
|
|
@ -196,6 +233,25 @@ export default function UsersTab() {
|
|||
Override quota (bypass spending limit)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="border-t pt-4 space-y-1">
|
||||
<Label>Reset Password</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="New password..."
|
||||
value={resetPassword}
|
||||
onChange={e => setResetPassword(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleResetPassword}
|
||||
disabled={!resetPassword || resetPasswordMutation.isPending}
|
||||
>
|
||||
{resetPasswordMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditUser(null)}>Cancel</Button>
|
||||
|
|
@ -206,6 +262,65 @@ export default function UsersTab() {
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Create User Dialog */}
|
||||
<Dialog open={showCreateDialog} onOpenChange={open => !open && setShowCreateDialog(false)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New User</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1">
|
||||
<Label>Username</Label>
|
||||
<Input
|
||||
placeholder="username"
|
||||
value={newUsername}
|
||||
onChange={e => setNewUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="user@example.com"
|
||||
value={newEmail}
|
||||
onChange={e => setNewEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Password</Label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Password..."
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Role</Label>
|
||||
<Select value={newRole} onValueChange={setNewRole}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!newUsername || !newEmail || !newPassword || createUser.isPending}
|
||||
>
|
||||
{createUser.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
10
src/hooks/useAdminFocusGroups.ts
Normal file
10
src/hooks/useAdminFocusGroups.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { adminApi } from '@/lib/api';
|
||||
|
||||
export function useAdminFocusGroups(params?: { skip?: number; limit?: number }) {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'focus-groups', params],
|
||||
queryFn: () => adminApi.listFocusGroups(params).then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { adminApi } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function useAdminPricing() {
|
||||
return useQuery({
|
||||
|
|
@ -8,3 +9,15 @@ export function useAdminPricing() {
|
|||
staleTime: 300_000, // 5 min — pricing rarely changes
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePricing() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: any) => adminApi.createPricing(data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'pricing'] });
|
||||
toast.success('Pricing row created');
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || 'Failed to create pricing'),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,3 +59,27 @@ export function useEnableUser() {
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateUser() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { username: string; email: string; password: string; role?: string }) =>
|
||||
adminApi.createUser(data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||
toast.success('User created');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.response?.data?.error || 'Failed to create user');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useResetPassword() {
|
||||
return useMutation({
|
||||
mutationFn: ({ id, password }: { id: string; password: string }) =>
|
||||
adminApi.resetPassword(id, password),
|
||||
onSuccess: () => toast.success('Password reset — existing sessions invalidated'),
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || 'Failed to reset password'),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,14 @@ export const dispatchAuthError = (details?: AuthErrorDetail) => {
|
|||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
// Handle 402 (Quota Exceeded)
|
||||
if (error.response && error.response.status === 402) {
|
||||
const data = error.response.data;
|
||||
window.dispatchEvent(new CustomEvent('quota_exceeded', {
|
||||
detail: { scope: data.scope, limit_usd: data.limit_usd, used_usd: data.used_usd }
|
||||
}));
|
||||
}
|
||||
|
||||
// Handle 401 (Unauthorized) - dispatch event instead of redirect
|
||||
if (error.response && error.response.status === 401) {
|
||||
// Check if this is a persona-related request - these are handled separately
|
||||
|
|
@ -728,6 +736,19 @@ export const adminApi = {
|
|||
// Pricing
|
||||
listPricing: () =>
|
||||
api.get('/admin/pricing'),
|
||||
|
||||
createPricing: (data: any) => api.post('/admin/pricing', data),
|
||||
|
||||
// Users — create + reset password
|
||||
createUser: (data: { username: string; email: string; password: string; role?: string }) =>
|
||||
api.post('/admin/users', data),
|
||||
|
||||
resetPassword: (id: string, password: string) =>
|
||||
api.post(`/admin/users/${id}/reset-password`, { password }),
|
||||
|
||||
// Focus Groups (admin view)
|
||||
listFocusGroups: (params?: { skip?: number; limit?: number }) =>
|
||||
api.get('/admin/focus-groups', { params }),
|
||||
};
|
||||
|
||||
export const usageApi = {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
|||
import UsersTab from '@/components/admin/UsersTab';
|
||||
import UsageTab from '@/components/admin/UsageTab';
|
||||
import PricingTab from '@/components/admin/PricingTab';
|
||||
import FocusGroupsTab from '@/components/admin/FocusGroupsTab';
|
||||
|
||||
export default function Admin() {
|
||||
return (
|
||||
|
|
@ -17,6 +18,7 @@ export default function Admin() {
|
|||
<TabsTrigger value="users">Users</TabsTrigger>
|
||||
<TabsTrigger value="usage">Usage</TabsTrigger>
|
||||
<TabsTrigger value="pricing">Pricing</TabsTrigger>
|
||||
<TabsTrigger value="focus-groups">Focus Groups</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="users">
|
||||
|
|
@ -30,6 +32,10 @@ export default function Admin() {
|
|||
<TabsContent value="pricing">
|
||||
<PricingTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="focus-groups">
|
||||
<FocusGroupsTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ import NotesPanel from '@/components/focus-group-session/NotesPanel';
|
|||
import QuickNoteModal from '@/components/focus-group-session/QuickNoteModal';
|
||||
import { FocusGroup, Message, Theme, Note, QuoteData, ModeEvent } from '@/components/focus-group-session/types';
|
||||
import { Persona } from '@/types/persona';
|
||||
import api, { focusGroupsApi, personasApi, focusGroupAiApi } from '@/lib/api';
|
||||
import api, { focusGroupsApi, personasApi, focusGroupAiApi, adminApi } from '@/lib/api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCancellableGeneration } from '@/hooks/useCancellableGeneration';
|
||||
import { getSocket } from '@/services/websocketServiceNew';
|
||||
import ProgressModal from '@/components/ui/ProgressModal';
|
||||
|
|
@ -47,7 +48,7 @@ import {
|
|||
const FocusGroupSession = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { token } = useAuth();
|
||||
const { token, user } = useAuth();
|
||||
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [modeEvents, setModeEvents] = useState<ModeEvent[]>([]);
|
||||
|
|
@ -106,6 +107,19 @@ const FocusGroupSession = () => {
|
|||
isLoading?: boolean;
|
||||
}>({ isOpen: false });
|
||||
|
||||
// Quota exceeded banner state
|
||||
const [quotaExceeded, setQuotaExceeded] = useState<{ scope: string; limit_usd: number; used_usd: number } | null>(null);
|
||||
|
||||
// Admin-only: fetch focus group cost summary
|
||||
const { data: fgCostData } = useQuery({
|
||||
queryKey: ['admin', 'fg-cost', id],
|
||||
queryFn: () => adminApi.usageSummary({ focus_group_id: id, group_by: 'focus_group' }).then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
enabled: !!id && user?.role === 'admin',
|
||||
});
|
||||
const fgCostTotal = fgCostData?.totals?.total_cost ?? 0;
|
||||
const fgTokensTotal = (fgCostData?.totals?.prompt_tokens ?? 0) + (fgCostData?.totals?.completion_tokens ?? 0);
|
||||
|
||||
// Track the last known AI status from API to avoid false positive changes
|
||||
const lastAiStatusRef = useRef<boolean>(false);
|
||||
|
||||
|
|
@ -140,6 +154,16 @@ const FocusGroupSession = () => {
|
|||
return token || '';
|
||||
}, [token]);
|
||||
|
||||
// Listen for quota_exceeded events dispatched by the API interceptor
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
setQuotaExceeded(detail);
|
||||
};
|
||||
window.addEventListener('quota_exceeded', handler);
|
||||
return () => window.removeEventListener('quota_exceeded', handler);
|
||||
}, []);
|
||||
|
||||
// Initialize singleton socket (GPT-5 fix: avoid useMemo issues)
|
||||
useEffect(() => {
|
||||
if (useWebSocketEnabled) {
|
||||
|
|
@ -1855,13 +1879,20 @@ const FocusGroupSession = () => {
|
|||
<div className="flex items-center mt-1">
|
||||
<Bot className="h-3 w-3 text-slate-500 mr-1" />
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{focusGroup.llm_model === 'gpt-4.1' ? 'GPT-4.1' :
|
||||
{focusGroup.llm_model === 'gpt-4.1' ? 'GPT-4.1' :
|
||||
focusGroup.llm_model === 'gpt-5.2' ? 'GPT-5.2' : 'Gemini 3 Pro'}
|
||||
</Badge>
|
||||
</div>
|
||||
{user?.role === 'admin' && fgCostTotal > 0 && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
${fgCostTotal.toFixed(4)} • {(fgTokensTotal / 1000).toFixed(1)}k tok
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center space-x-4 mt-4 sm:mt-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -1896,6 +1927,16 @@ const FocusGroupSession = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quota exceeded banner */}
|
||||
{quotaExceeded && (
|
||||
<div className="mx-0 mt-2 mb-2 p-3 bg-red-50 border border-red-200 rounded-md flex items-center justify-between">
|
||||
<span className="text-sm text-red-700">
|
||||
Quota exceeded ({quotaExceeded.scope}): ${quotaExceeded.used_usd.toFixed(4)} of ${quotaExceeded.limit_usd.toFixed(2)} used.
|
||||
</span>
|
||||
<button className="text-xs text-red-500 hover:text-red-700" onClick={() => setQuotaExceeded(null)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress Modal for Key Themes Generation */}
|
||||
<ProgressModal
|
||||
isOpen={isThemeProgressModalOpen}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue