feat: token pipeline and device fingerprinting
Browse files- cost_tracker.py +12 -1
- main.py +10 -2
- quota_system_v2.py +52 -8
cost_tracker.py
CHANGED
|
@@ -52,8 +52,13 @@ def get_log_file_path():
|
|
| 52 |
LOG_FILE = get_log_file_path()
|
| 53 |
LOG_DIR = os.path.dirname(LOG_FILE)
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
def log_api_usage(usage_metadata, source="unknown"):
|
| 56 |
-
"""Log API usage to proper location"""
|
| 57 |
if not usage_metadata:
|
| 58 |
return
|
| 59 |
|
|
@@ -73,6 +78,12 @@ def log_api_usage(usage_metadata, source="unknown"):
|
|
| 73 |
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
| 74 |
f.write(json.dumps(entry) + "\n")
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
# Also print to stdout for immediate visibility in console logs
|
| 77 |
print(f"💰 [USAGE] {source}: In={entry['input_tokens']}, Out={entry['output_tokens']}")
|
| 78 |
|
|
|
|
| 52 |
LOG_FILE = get_log_file_path()
|
| 53 |
LOG_DIR = os.path.dirname(LOG_FILE)
|
| 54 |
|
| 55 |
+
import contextvars
|
| 56 |
+
|
| 57 |
+
# Stores the total tokens used in the current solve request
|
| 58 |
+
current_request_tokens = contextvars.ContextVar('current_request_tokens', default=0)
|
| 59 |
+
|
| 60 |
def log_api_usage(usage_metadata, source="unknown"):
|
| 61 |
+
"""Log API usage to proper location and add to current ContextVar"""
|
| 62 |
if not usage_metadata:
|
| 63 |
return
|
| 64 |
|
|
|
|
| 78 |
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
| 79 |
f.write(json.dumps(entry) + "\n")
|
| 80 |
|
| 81 |
+
# Update the ContextVar for the current request
|
| 82 |
+
try:
|
| 83 |
+
current_request_tokens.set(current_request_tokens.get() + entry["total_tokens"])
|
| 84 |
+
except Exception:
|
| 85 |
+
pass
|
| 86 |
+
|
| 87 |
# Also print to stdout for immediate visibility in console logs
|
| 88 |
print(f"💰 [USAGE] {source}: In={entry['input_tokens']}, Out={entry['output_tokens']}")
|
| 89 |
|
main.py
CHANGED
|
@@ -246,9 +246,10 @@ async def solve_stream_v2(
|
|
| 246 |
uid = decoded_token.get('uid')
|
| 247 |
final_student_name = student_name or user or "תלמיד"
|
| 248 |
print(f"🚀 🟢 [V2] Received request from UID: {uid} ({final_student_name}). Grade: {grade}")
|
|
|
|
| 249 |
|
| 250 |
# V2 Quota Check (Firestore)
|
| 251 |
-
is_allowed, msg, current_usage, limit = quota_manager_v2.check_limit(uid)
|
| 252 |
if not is_allowed:
|
| 253 |
response_content = build_standard_response(
|
| 254 |
final_answer=f"הגעת למכסה היומית ({limit} שאלות)",
|
|
@@ -289,6 +290,8 @@ async def solve_stream_v2(
|
|
| 289 |
print("🚀 [TRACE-V2] Initiating streaming orchestrator.solve_problem with multiple images...")
|
| 290 |
|
| 291 |
async def event_generator():
|
|
|
|
|
|
|
| 292 |
try:
|
| 293 |
# orchestrator handles the rest using student_name for pedagogical stuff, but quota is already handled.
|
| 294 |
async for event in orchestrator.solve_problem(
|
|
@@ -313,6 +316,11 @@ async def solve_stream_v2(
|
|
| 313 |
"event": "error",
|
| 314 |
"data": json.dumps({"error": str(e)})
|
| 315 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
|
| 317 |
return EventSourceResponse(event_generator())
|
| 318 |
|
|
@@ -380,7 +388,7 @@ async def upgrade_success(req: UpgradeRequest):
|
|
| 380 |
user_ref.set({
|
| 381 |
"tier": "parent_premium",
|
| 382 |
"parent_email": req.parent_email,
|
| 383 |
-
"
|
| 384 |
}, merge=True)
|
| 385 |
|
| 386 |
logger.info(f"🎉 UPGRADED USER {req.uid} to parent_premium")
|
|
|
|
| 246 |
uid = decoded_token.get('uid')
|
| 247 |
final_student_name = student_name or user or "תלמיד"
|
| 248 |
print(f"🚀 🟢 [V2] Received request from UID: {uid} ({final_student_name}). Grade: {grade}")
|
| 249 |
+
device_id = request.headers.get('Device-ID')
|
| 250 |
|
| 251 |
# V2 Quota Check (Firestore)
|
| 252 |
+
is_allowed, msg, current_usage, limit = quota_manager_v2.check_limit(uid, device_id=device_id)
|
| 253 |
if not is_allowed:
|
| 254 |
response_content = build_standard_response(
|
| 255 |
final_answer=f"הגעת למכסה היומית ({limit} שאלות)",
|
|
|
|
| 290 |
print("🚀 [TRACE-V2] Initiating streaming orchestrator.solve_problem with multiple images...")
|
| 291 |
|
| 292 |
async def event_generator():
|
| 293 |
+
import cost_tracker
|
| 294 |
+
cost_tracker.current_request_tokens.set(0)
|
| 295 |
try:
|
| 296 |
# orchestrator handles the rest using student_name for pedagogical stuff, but quota is already handled.
|
| 297 |
async for event in orchestrator.solve_problem(
|
|
|
|
| 316 |
"event": "error",
|
| 317 |
"data": json.dumps({"error": str(e)})
|
| 318 |
}
|
| 319 |
+
finally:
|
| 320 |
+
total_tokens = cost_tracker.current_request_tokens.get()
|
| 321 |
+
if total_tokens > 0:
|
| 322 |
+
quota_manager_v2.increment_usage(uid, increment_questions=0, tokens_used=total_tokens)
|
| 323 |
+
print(f"🪙 [V2-QUOTA] Deducted {total_tokens} tokens for UID: {uid}")
|
| 324 |
|
| 325 |
return EventSourceResponse(event_generator())
|
| 326 |
|
|
|
|
| 388 |
user_ref.set({
|
| 389 |
"tier": "parent_premium",
|
| 390 |
"parent_email": req.parent_email,
|
| 391 |
+
"monthly_token_budget": 2800000
|
| 392 |
}, merge=True)
|
| 393 |
|
| 394 |
logger.info(f"🎉 UPGRADED USER {req.uid} to parent_premium")
|
quota_system_v2.py
CHANGED
|
@@ -22,8 +22,11 @@ class QuotaManagerV2:
|
|
| 22 |
|
| 23 |
def get_today_key(self):
|
| 24 |
return datetime.date.today().isoformat()
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
-
def check_limit(self, uid: str):
|
| 27 |
"""
|
| 28 |
Returns:
|
| 29 |
(allowed: bool, message: str, current_usage: int, limit: int)
|
|
@@ -54,16 +57,35 @@ class QuotaManagerV2:
|
|
| 54 |
if data.get("role") == "admin" or data.get("status") == "admin":
|
| 55 |
return True, "Unlimited (Admin)", 0, -1
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
limit = data.get("quota_limit", self.default_limit)
|
|
|
|
| 58 |
|
| 59 |
# Special limits
|
| 60 |
if limit < 0:
|
| 61 |
return True, "Unlimited", 0, -1
|
| 62 |
|
| 63 |
today = self.get_today_key()
|
|
|
|
|
|
|
| 64 |
last_usage_date = data.get("last_usage_date", "")
|
|
|
|
| 65 |
|
| 66 |
-
#
|
| 67 |
if last_usage_date != today:
|
| 68 |
current_count = 0
|
| 69 |
else:
|
|
@@ -71,6 +93,16 @@ class QuotaManagerV2:
|
|
| 71 |
|
| 72 |
if current_count >= limit:
|
| 73 |
return False, f"Daily limit reached ({limit})", current_count, limit
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
return True, "Allowed", current_count, limit
|
| 76 |
|
|
@@ -79,7 +111,7 @@ class QuotaManagerV2:
|
|
| 79 |
# Fail closed or open? Let's fail open temporarily so we don't block users if DB hiccups
|
| 80 |
return True, "Error - Fallback Allow", 0, self.default_limit
|
| 81 |
|
| 82 |
-
def increment_usage(self, uid: str):
|
| 83 |
if not uid:
|
| 84 |
return
|
| 85 |
|
|
@@ -88,6 +120,7 @@ class QuotaManagerV2:
|
|
| 88 |
return
|
| 89 |
|
| 90 |
today = self.get_today_key()
|
|
|
|
| 91 |
doc_ref = db.collection(self.collection_name).document(uid)
|
| 92 |
|
| 93 |
try:
|
|
@@ -95,32 +128,43 @@ class QuotaManagerV2:
|
|
| 95 |
def update_in_transaction(transaction, doc_ref):
|
| 96 |
snapshot = doc_ref.get(transaction=transaction)
|
| 97 |
if not snapshot.exists:
|
| 98 |
-
# Create document if it doesn't exist (though migration should handle this)
|
| 99 |
transaction.set(doc_ref, {
|
| 100 |
"quota_limit": self.default_limit,
|
| 101 |
-
"used_today":
|
|
|
|
| 102 |
"last_usage_date": today,
|
|
|
|
| 103 |
"last_seen": firestore.SERVER_TIMESTAMP
|
| 104 |
})
|
| 105 |
return
|
| 106 |
|
| 107 |
data = snapshot.to_dict()
|
| 108 |
last_date = data.get("last_usage_date", "")
|
|
|
|
| 109 |
|
|
|
|
| 110 |
if last_date != today:
|
| 111 |
-
new_usage =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
else:
|
| 113 |
-
|
| 114 |
|
| 115 |
transaction.update(doc_ref, {
|
| 116 |
"used_today": new_usage,
|
|
|
|
| 117 |
"last_usage_date": today,
|
|
|
|
| 118 |
"last_seen": firestore.SERVER_TIMESTAMP
|
| 119 |
})
|
| 120 |
|
| 121 |
transaction = db.transaction()
|
| 122 |
update_in_transaction(transaction, doc_ref)
|
| 123 |
-
logger.info(f"📊 [QUOTA_V2] Incremented usage for {uid}. Date: {today}")
|
| 124 |
|
| 125 |
except Exception as e:
|
| 126 |
logger.error(f"❌ [QUOTA_V2] Failed to increment usage for {uid}: {e}")
|
|
|
|
| 22 |
|
| 23 |
def get_today_key(self):
|
| 24 |
return datetime.date.today().isoformat()
|
| 25 |
+
|
| 26 |
+
def get_this_month_key(self):
|
| 27 |
+
return datetime.date.today().strftime("%Y-%m")
|
| 28 |
|
| 29 |
+
def check_limit(self, uid: str, device_id: str = None):
|
| 30 |
"""
|
| 31 |
Returns:
|
| 32 |
(allowed: bool, message: str, current_usage: int, limit: int)
|
|
|
|
| 57 |
if data.get("role") == "admin" or data.get("status") == "admin":
|
| 58 |
return True, "Unlimited (Admin)", 0, -1
|
| 59 |
|
| 60 |
+
# --- Device ID Enforcement ---
|
| 61 |
+
if device_id:
|
| 62 |
+
tier = data.get("tier", "student_basic")
|
| 63 |
+
allowed_devices = data.get("allowed_devices", [])
|
| 64 |
+
|
| 65 |
+
max_devices = 2 if tier == "parent_premium" else 1
|
| 66 |
+
|
| 67 |
+
if device_id not in allowed_devices:
|
| 68 |
+
if len(allowed_devices) >= max_devices:
|
| 69 |
+
return False, f"Device Limit Exceeded ({max_devices})", 0, 0
|
| 70 |
+
else:
|
| 71 |
+
# Add device atomically
|
| 72 |
+
doc_ref.update({"allowed_devices": firestore.ArrayUnion([device_id])})
|
| 73 |
+
# -----------------------------
|
| 74 |
+
|
| 75 |
limit = data.get("quota_limit", self.default_limit)
|
| 76 |
+
monthly_token_budget = data.get("monthly_token_budget")
|
| 77 |
|
| 78 |
# Special limits
|
| 79 |
if limit < 0:
|
| 80 |
return True, "Unlimited", 0, -1
|
| 81 |
|
| 82 |
today = self.get_today_key()
|
| 83 |
+
this_month = self.get_this_month_key()
|
| 84 |
+
|
| 85 |
last_usage_date = data.get("last_usage_date", "")
|
| 86 |
+
last_usage_month = data.get("last_usage_month", "")
|
| 87 |
|
| 88 |
+
# Daily Question Quota
|
| 89 |
if last_usage_date != today:
|
| 90 |
current_count = 0
|
| 91 |
else:
|
|
|
|
| 93 |
|
| 94 |
if current_count >= limit:
|
| 95 |
return False, f"Daily limit reached ({limit})", current_count, limit
|
| 96 |
+
|
| 97 |
+
# Monthly Token Quota (if defined)
|
| 98 |
+
if monthly_token_budget is not None:
|
| 99 |
+
if last_usage_month != this_month:
|
| 100 |
+
used_tokens = 0
|
| 101 |
+
else:
|
| 102 |
+
used_tokens = data.get("used_tokens_this_month", 0)
|
| 103 |
+
|
| 104 |
+
if used_tokens >= monthly_token_budget:
|
| 105 |
+
return False, f"Monthly token budget reached", current_count, limit
|
| 106 |
|
| 107 |
return True, "Allowed", current_count, limit
|
| 108 |
|
|
|
|
| 111 |
# Fail closed or open? Let's fail open temporarily so we don't block users if DB hiccups
|
| 112 |
return True, "Error - Fallback Allow", 0, self.default_limit
|
| 113 |
|
| 114 |
+
def increment_usage(self, uid: str, increment_questions: int = 1, tokens_used: int = 0):
|
| 115 |
if not uid:
|
| 116 |
return
|
| 117 |
|
|
|
|
| 120 |
return
|
| 121 |
|
| 122 |
today = self.get_today_key()
|
| 123 |
+
this_month = self.get_this_month_key()
|
| 124 |
doc_ref = db.collection(self.collection_name).document(uid)
|
| 125 |
|
| 126 |
try:
|
|
|
|
| 128 |
def update_in_transaction(transaction, doc_ref):
|
| 129 |
snapshot = doc_ref.get(transaction=transaction)
|
| 130 |
if not snapshot.exists:
|
|
|
|
| 131 |
transaction.set(doc_ref, {
|
| 132 |
"quota_limit": self.default_limit,
|
| 133 |
+
"used_today": increment_questions,
|
| 134 |
+
"used_tokens_this_month": tokens_used,
|
| 135 |
"last_usage_date": today,
|
| 136 |
+
"last_usage_month": this_month,
|
| 137 |
"last_seen": firestore.SERVER_TIMESTAMP
|
| 138 |
})
|
| 139 |
return
|
| 140 |
|
| 141 |
data = snapshot.to_dict()
|
| 142 |
last_date = data.get("last_usage_date", "")
|
| 143 |
+
last_month = data.get("last_usage_month", "")
|
| 144 |
|
| 145 |
+
# Daily logic
|
| 146 |
if last_date != today:
|
| 147 |
+
new_usage = increment_questions
|
| 148 |
+
else:
|
| 149 |
+
new_usage = data.get("used_today", 0) + increment_questions
|
| 150 |
+
|
| 151 |
+
# Monthly Token logic
|
| 152 |
+
if last_month != this_month:
|
| 153 |
+
new_tokens = tokens_used
|
| 154 |
else:
|
| 155 |
+
new_tokens = data.get("used_tokens_this_month", 0) + tokens_used
|
| 156 |
|
| 157 |
transaction.update(doc_ref, {
|
| 158 |
"used_today": new_usage,
|
| 159 |
+
"used_tokens_this_month": new_tokens,
|
| 160 |
"last_usage_date": today,
|
| 161 |
+
"last_usage_month": this_month,
|
| 162 |
"last_seen": firestore.SERVER_TIMESTAMP
|
| 163 |
})
|
| 164 |
|
| 165 |
transaction = db.transaction()
|
| 166 |
update_in_transaction(transaction, doc_ref)
|
| 167 |
+
logger.info(f"📊 [QUOTA_V2] Incremented usage for {uid}. Date: {today}, Tokens: {tokens_used}")
|
| 168 |
|
| 169 |
except Exception as e:
|
| 170 |
logger.error(f"❌ [QUOTA_V2] Failed to increment usage for {uid}: {e}")
|