diff --git a/backend/beyond_the_loop/dev.sh b/backend/beyond_the_loop/dev.sh index 08d15e068..380586d4b 100755 --- a/backend/beyond_the_loop/dev.sh +++ b/backend/beyond_the_loop/dev.sh @@ -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 diff --git a/backend/beyond_the_loop/models/completions.py b/backend/beyond_the_loop/models/completions.py index 0c56fd50d..462323104 100644 --- a/backend/beyond_the_loop/models/completions.py +++ b/backend/beyond_the_loop/models/completions.py @@ -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() diff --git a/backend/beyond_the_loop/routers/analytics.py b/backend/beyond_the_loop/routers/analytics.py index 6526c37cf..acfd95ac4 100644 --- a/backend/beyond_the_loop/routers/analytics.py +++ b/backend/beyond_the_loop/routers/analytics.py @@ -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}") diff --git a/backend/beyond_the_loop/routers/openai.py b/backend/beyond_the_loop/routers/openai.py index 8981ecde9..e55be4666 100644 --- a/backend/beyond_the_loop/routers/openai.py +++ b/backend/beyond_the_loop/routers/openai.py @@ -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( diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 59ca57a51..4adafd6cd 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -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: