Implement Usage Statistics, Bug: No Chat ID On First Message

This commit is contained in:
Phil Szalay 2025-02-25 11:01:53 +01:00
parent 2b2faa1433
commit b227bc7a97
5 changed files with 181 additions and 41 deletions

View File

@ -6,7 +6,7 @@ export PYTHONPATH="/Users/philszalay/Documents/code/beyond-the-loop/backend:$PYT
PORT="${PORT:-8080}"
# Start the LiteLLM container in the background
cd /Users/philszalay/Documents/code/beyond-the-loop && docker-compose up -d litellm
cd /Users/philszalay/Documents/code/beyond-the-loop && docker-compose -f docker-compose-prod.yaml up -d litellm
# Start the uvicorn server
cd /Users/philszalay/Documents/code/beyond-the-loop/backend && uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips '*' --reload

View File

@ -1,6 +1,6 @@
from pydantic import BaseModel, ConfigDict
from typing import Optional
from sqlalchemy import String, Column, BigInteger, Integer, Text, ForeignKey
from sqlalchemy import String, Column, BigInteger, Integer, Text, ForeignKey, Float
import uuid
import time
@ -23,6 +23,7 @@ class Completion(Base):
model = Column(Text)
credits_used = Column(Integer)
created_at = Column(BigInteger)
time_saved_in_seconds = Column(Float)
class CompletionModel(BaseModel):
id: str
@ -31,12 +32,13 @@ class CompletionModel(BaseModel):
model: str
credits_used: int
created_at: int # timestamp in epoch
time_saved_in_seconds: float
model_config = ConfigDict(from_attributes=True)
class CompletionTable:
def insert_new_completion(self, user_id: str, chat_id: str, model: str, credits_used: int) -> Optional[CompletionModel]:
def insert_new_completion(self, user_id: str, chat_id: str, model: str, credits_used: int, time_saved_in_seconds: float) -> Optional[CompletionModel]:
id = str(uuid.uuid4())
completion = CompletionModel(
**{
@ -45,7 +47,8 @@ class CompletionTable:
"chat_id": chat_id,
"created_at": int(time.time()),
"model": model,
"credits_used": credits_used
"credits_used": credits_used,
"time_saved_in_seconds": time_saved_in_seconds
}
)
try:
@ -63,5 +66,21 @@ class CompletionTable:
print(f"Error creating completion: {e}")
return None
def calculate_saved_time_in_seconds(last_message, response_message):
print(last_message + " ----- " + response_message)
writing_speed_per_word = 600 / 500 # 500 words in 600 seconds = 1.2 sec per word
reading_speed_per_word = 400 / 500 # 500 words in 400 seconds = 0.8 sec per word
# Now prompt is a string (the last message), not a list of messages
num_words_prompt = len(last_message.split())
num_words_output = len(response_message.split())
prompt_time = num_words_prompt * writing_speed_per_word
writing_time = num_words_output * writing_speed_per_word
reading_time = num_words_output * reading_speed_per_word
total_time = writing_time - (prompt_time + reading_time)
return total_time
Completions = CompletionTable()

View File

@ -383,3 +383,88 @@ async def get_total_chats(
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD.")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error fetching chat stats: {e}")
@router.get("/stats/saved-time-in-seconds")
async def get_saved_time_in_seconds(
start_date: str = Query(None, description="Start date in YYYY-MM-DD format (optional)"),
end_date: str = Query(None, description="End date in YYYY-MM-DD format (optional)"),
user=Depends(get_verified_user)
):
"""
Returns total saved time in seconds for the last 12 months or within a specified time frame,
filtered by the user's completions.
"""
try:
current_date = datetime.now()
one_year_ago = current_date.replace(day=1) - timedelta(days=365)
# Parse start_date
if start_date:
start_date_dt = datetime.strptime(start_date, "%Y-%m-%d")
else:
start_date_dt = one_year_ago # Default to one year ago
# Parse end_date
if end_date:
end_date_dt = datetime.strptime(end_date, "%Y-%m-%d")
else:
end_date_dt = current_date # Default to current date
if start_date_dt > end_date_dt:
raise HTTPException(status_code=400, detail="Start date must be before end date.")
# Ensure end_date includes the entire day
end_date_dt = end_date_dt.replace(hour=23, minute=59, second=59)
with get_db() as db:
query = db.query(
func.strftime('%Y-%m', func.datetime(Completion.created_at, 'unixepoch')).label("month"),
func.sum(Completion.time_saved_in_seconds).label("total_saved_time")
).filter(
func.datetime(Completion.created_at, 'unixepoch') >= start_date_dt.strftime('%Y-%m-%d 00:00:00'),
func.datetime(Completion.created_at, 'unixepoch') <= end_date_dt.strftime('%Y-%m-%d %H:%M:%S')
)
query = query.filter(Completion.user_id == user.id)
# Execute the query and fetch results
results = query.group_by("month").order_by("month").all()
# Convert results to a dictionary
monthly_saved_time = {row[0]: int(row[1]) if row[1] is not None else 0 for row in results}
# Generate all months within the specified range
months = []
current_month = start_date_dt.replace(day=1)
end_month = end_date_dt.replace(day=1)
while current_month <= end_month:
months.append(current_month.strftime('%Y-%m'))
# Move to the first day of next month
if current_month.month == 12:
current_month = current_month.replace(year=current_month.year + 1, month=1)
else:
current_month = current_month.replace(month=current_month.month + 1)
saved_time_data = {month: monthly_saved_time.get(month, 0) for month in months}
# Calculate percentage changes month-over-month
percentage_changes = {}
previous_value = None
for month, value in saved_time_data.items():
if previous_value is not None:
change = ((value - previous_value) / previous_value) * 100 if previous_value != 0 else None
percentage_changes[month] = round(change, 2) if change is not None else "N/A"
else:
percentage_changes[month] = "N/A"
previous_value = value
return {
"monthly_saved_time_in_seconds": saved_time_data,
"percentage_changes": percentage_changes
}
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD.")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error fetching saved time stats: {e}")

View File

@ -22,6 +22,7 @@ from beyond_the_loop.models.companies import Companies
from beyond_the_loop.models.companies import EIGHTY_PERCENT_CREDIT_LIMIT
from beyond_the_loop.models.completions import Completions
from beyond_the_loop.services.email_service import EmailService
from beyond_the_loop.models.completions import calculate_saved_time_in_seconds
from open_webui.config import (
CACHE_DIR,
@ -564,47 +565,50 @@ async def generate_chat_completion(
model_id = form_data.get("model")
model_info = Models.get_model_by_id(model_id)
# Subtract credits from the company's balance (if possible)
model_message_credit_cost = ModelMessageCreditCosts.get_cost_by_model(model_id)
chat_id = metadata.get("chat_id", "no_chat_id")
has_chat_id = "chat_id" in metadata and metadata["chat_id"] is not None
# Get current credit balance once
current_balance = Companies.get_credit_balance(user.company_id)
# Check if company has sufficient credits
if current_balance < model_message_credit_cost:
email_service = EmailService()
email_service.send_budget_mail_100(to_email=user.email, recipient_name=user.name)
# Initialize the credit cost variable
model_message_credit_cost = 0
raise HTTPException(
status_code=402, # 402 Payment Required
detail=f"Insufficient credits. This operation requires {model_message_credit_cost} credits.",
)
if has_chat_id:
model_message_credit_cost = ModelMessageCreditCosts.get_cost_by_model(model_id)
# Check 80% threshold
if current_balance - model_message_credit_cost < EIGHTY_PERCENT_CREDIT_LIMIT: # If balance is less than 125% of required (which means we're below 80%)
email_service = EmailService()
should_send_budget_email_80 = True # Default to sending email
# Get current credit balance
current_balance = Companies.get_credit_balance(user.company_id)
if Companies.get_auto_recharge(user.company_id):
try:
# Trigger auto-recharge using the charge_customer endpoint
await charge_customer(user)
# Note: The webhook will handle adding the credits when payment succeeds
should_send_budget_email_80 = False # Don't send email if auto-recharge succeeded
except HTTPException as e:
print(f"Auto-recharge failed: {str(e)}")
except Exception as e:
print(f"Unexpected error during auto-recharge: {str(e)}")
# Check if company has sufficient credits
if current_balance < model_message_credit_cost:
email_service = EmailService()
email_service.send_budget_mail_100(to_email=user.email, recipient_name=user.name)
if should_send_budget_email_80:
email_service.send_budget_mail_80(to_email=user.email, recipient_name=user.name)
raise HTTPException(
status_code=402, # 402 Payment Required
detail=f"Insufficient credits. This operation requires {model_message_credit_cost} credits.",
)
# Subtract credits from balance
Companies.subtract_credit_balance(user.company_id, model_message_credit_cost)
# Check 80% threshold
if current_balance - model_message_credit_cost < EIGHTY_PERCENT_CREDIT_LIMIT: # If balance is less than 125% of required (which means we're below 80%)
email_service = EmailService()
# Add completion to completion table
Completions.insert_new_completion(user.id, metadata["chat_id"], model_id, model_message_credit_cost)
should_send_budget_email_80 = True # Default to sending email
if Companies.get_auto_recharge(user.company_id):
try:
# Trigger auto-recharge using the charge_customer endpoint
await charge_customer(user)
# Note: The webhook will handle adding the credits when payment succeeds
should_send_budget_email_80 = False # Don't send email if auto-recharge succeeded
except HTTPException as e:
print(f"Auto-recharge failed: {str(e)}")
except Exception as e:
print(f"Unexpected error during auto-recharge: {str(e)}")
if should_send_budget_email_80:
email_service.send_budget_mail_80(to_email=user.email, recipient_name=user.name)
# Subtract credits from balance
Companies.subtract_credit_balance(user.company_id, model_message_credit_cost)
# Check model info and override the payload
if model_info:
@ -689,6 +693,11 @@ async def generate_chat_completion(
streaming = False
response = None
# Parse payload once for both streaming and non-streaming cases
payload_dict = json.loads(payload)
last_user_message = next((msg['content'] for msg in reversed(payload_dict['messages'])
if msg['role'] == 'user'), '')
try:
session = aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
@ -725,8 +734,28 @@ async def generate_chat_completion(
# Check if response is SSE
if "text/event-stream" in r.headers.get("Content-Type", ""):
streaming = True
async def insert_completion_if_streaming_is_done():
full_response = ""
async for chunk in r.content:
chunk_str = chunk.decode()
if chunk_str.startswith('data: '):
try:
data = json.loads(chunk_str[6:])
delta = data.get('choices', [{}])[0].get('delta', {}).get('content', '')
if delta: # Only use it if there's actual content
full_response += delta
elif data.get('choices', [{}])[0].get('finish_reason'):
# End of stream
# Add completion to completion table if it's a chat message from the user
if has_chat_id:
Completions.insert_new_completion(user.id, metadata["chat_id"], model_id, model_message_credit_cost, calculate_saved_time_in_seconds(last_user_message, full_response))
except json.JSONDecodeError:
print(f"\n{chunk_str}")
yield chunk
return StreamingResponse(
r.content,
insert_completion_if_streaming_is_done(),
status_code=r.status,
headers=dict(r.headers),
background=BackgroundTask(
@ -741,6 +770,13 @@ async def generate_chat_completion(
response = await r.text()
r.raise_for_status()
if has_chat_id:
# Add completion to completion table
response_content = response.get('choices', [{}])[0].get('message', {}).get('content', '')
Completions.insert_new_completion(user.id, metadata["chat_id"], model_id, model_message_credit_cost, calculate_saved_time_in_seconds(last_user_message, response_content))
return response
except Exception as e:
log.exception(e)
@ -826,7 +862,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
res = await r.json()
print(res)
if "error" in res:
detail = f"External: {res['error']['message'] if 'message' in res['error'] else res['error']}"
detail = f"External Error: {res['error']['message'] if 'message' in res['error'] else res['error']}"
except Exception:
detail = f"External: {e}"
raise HTTPException(

View File

@ -839,7 +839,7 @@ async def chat_completion(
user=Depends(get_verified_user),
):
if not request.app.state.MODELS:
await get_all_models(request)
await get_all_models(request, user)
tasks = form_data.pop("background_tasks", None)
try: