m / app.py
GamerC0der's picture
Update app.py
00e5b1b verified
from flask import Flask, jsonify, request, redirect, session, Response
import json
import requests
import os
import hashlib
import time
import psycopg2
from psycopg2.extras import RealDictCursor
from datetime import datetime, timedelta
import threading
import atexit
import uuid
import json
import re
from collections import defaultdict
app = Flask(__name__)
app.secret_key = 'your-secret-key-here'
DISCORD_CLIENT_ID = '1426015278499627058'
DISCORD_CLIENT_SECRET = 'YfTZg5JZA-y3GU1wDyoO3IPilwz9jXmL'
DATABASE_URL = 'postgres://avnadmin:AVNS_o0LDhRmORJQ7lh7ULQS@goapi-hacktv.b.aivencloud.com:11426/defaultdb?sslmode=require'
ADMIN_PASSWORD = "GoodGuys!Funn1"
# Global variables for batching credit deductions
pending_deductions = defaultdict(int) # username -> total_credits_to_deduct
credit_cache = {} # username -> last_known_credits_balance
deduction_lock = threading.Lock()
batch_timer = None
MODEL_MULTIPLIERS = {
'gpt-5-nano': 1.0,
'gpt-5-chat': 1.5,
'gpt-4.1': 1.0,
'claude-4-sonnet': 3.0,
'claude-4-opus': 5.0,
'claude-3.5-sonnet': 3.0,
'claude-3.7-sonnet': 3.0,
'claude-3.5-haiku': 2.0
}
def init_db():
conn = psycopg2.connect(DATABASE_URL)
c = conn.cursor()
c.execute('''CREATE TABLE IF NOT EXISTS users
(username TEXT PRIMARY KEY,
plan TEXT DEFAULT 'free',
credits INTEGER DEFAULT 500000,
last_reset TEXT)''')
conn.commit()
conn.close()
def get_user(username):
conn = psycopg2.connect(DATABASE_URL)
c = conn.cursor(cursor_factory=RealDictCursor)
c.execute('SELECT plan, credits, last_reset FROM users WHERE username = %s', (username,))
result = c.fetchone()
conn.close()
if result:
return dict(result)
return None
def create_or_update_user(username, plan='free'):
conn = psycopg2.connect(DATABASE_URL)
c = conn.cursor()
now = datetime.now().isoformat()
c.execute('''INSERT INTO users (username, plan, credits, last_reset)
VALUES (%s, %s, 500000, %s)
ON CONFLICT (username)
DO UPDATE SET plan = EXCLUDED.plan, credits = EXCLUDED.credits, last_reset = EXCLUDED.last_reset''',
(username, plan, now))
conn.commit()
conn.close()
def reset_credits_if_needed(username):
user = get_user(username)
if not user:
return
last_reset = user['last_reset']
if last_reset:
last_reset_date = datetime.fromisoformat(last_reset).date()
today = datetime.now().date()
if last_reset_date < today:
conn = psycopg2.connect(DATABASE_URL)
c = conn.cursor()
now = datetime.now().isoformat()
c.execute('UPDATE users SET credits = 500000, last_reset = %s WHERE username = %s',
(now, username))
conn.commit()
conn.close()
def deduct_credits(username, amount):
conn = psycopg2.connect(DATABASE_URL)
c = conn.cursor()
c.execute('UPDATE users SET credits = credits - %s WHERE username = %s AND credits >= %s',
(amount, username, amount))
success = c.rowcount > 0
conn.commit()
conn.close()
return success
def get_user_credits(username):
user = get_user(username)
if user:
reset_credits_if_needed(username)
user = get_user(username)
return user['credits'] if user else 0
return 0
def get_cached_credits(username):
"""Get cached credits balance for a user (estimated: cached_balance - pending_deductions)"""
with deduction_lock:
cached_balance = credit_cache.get(username, 0)
pending_for_user = pending_deductions.get(username, 0)
return cached_balance - pending_for_user
def update_credit_cache(username, new_balance):
"""Update the cached credit balance for a user"""
with deduction_lock:
credit_cache[username] = new_balance
print(f"Updated credit cache for {username}: {new_balance} credits")
def clear_cache_entry(username):
"""Clear cache entry for a user (called when they run out of credits)"""
with deduction_lock:
credit_cache.pop(username, None)
print(f"Cleared credit cache for {username}")
def add_pending_deduction(username, amount):
"""Add a credit deduction to the pending queue"""
with deduction_lock:
pending_deductions[username] += amount
def process_pending_deductions():
"""Process all pending credit deductions in a single batch (runs in background thread)"""
try:
with deduction_lock:
if not pending_deductions:
return
# Create a copy of pending deductions to process
deductions_to_process = dict(pending_deductions)
pending_deductions.clear()
# Process deductions in database
if deductions_to_process:
conn = psycopg2.connect(DATABASE_URL)
c = conn.cursor()
for username, amount in deductions_to_process.items():
c.execute('UPDATE users SET credits = credits - %s WHERE username = %s AND credits >= %s',
(amount, username, amount))
# Get updated balances for all affected users
usernames = list(deductions_to_process.keys())
if usernames:
placeholders = ','.join(['%s'] * len(usernames))
c.execute(f'SELECT username, credits FROM users WHERE username IN ({placeholders})', usernames)
for row in c.fetchall():
username, new_balance = row
update_credit_cache(username, new_balance)
conn.commit()
conn.close()
print(f"Processed {len(deductions_to_process)} credit deductions")
except Exception as e:
print(f"Error processing credit deductions: {e}")
# Put deductions back in queue if there was an error
with deduction_lock:
for username, amount in deductions_to_process.items():
pending_deductions[username] += amount
def start_batch_timer():
"""Start or restart the 3-minute batch timer"""
global batch_timer
if batch_timer:
batch_timer.cancel()
batch_timer = threading.Timer(180.0, lambda: threading.Thread(target=process_pending_deductions, daemon=True).start()) # 3 minutes = 180 seconds
batch_timer.daemon = True
batch_timer.start()
def verify_admin_password(password):
"""Verify admin password"""
return password == ADMIN_PASSWORD
def export_database():
"""Export current database to JSON"""
try:
conn = psycopg2.connect(DATABASE_URL)
c = conn.cursor(cursor_factory=RealDictCursor)
# Get all users
c.execute('SELECT * FROM users')
users = c.fetchall()
# Convert to list of dicts for JSON serialization
users_data = [dict(user) for user in users]
conn.close()
return {"users": users_data, "exported_at": datetime.now().isoformat()}
except Exception as e:
return {"error": f"Failed to export database: {str(e)}"}
def import_database(data):
"""Import database from JSON data"""
try:
if "users" not in data:
return {"error": "Invalid data format: missing 'users' key"}
conn = psycopg2.connect(DATABASE_URL)
c = conn.cursor()
# Clear existing data
c.execute('DELETE FROM users')
# Insert new data
for user_data in data["users"]:
c.execute('''INSERT INTO users (username, plan, credits, last_reset)
VALUES (%s, %s, %s, %s)''',
(user_data['username'], user_data.get('plan', 'free'),
user_data.get('credits', 500000), user_data.get('last_reset')))
conn.commit()
conn.close()
# Update credit cache for all imported users
for user_data in data["users"]:
update_credit_cache(user_data['username'], user_data.get('credits', 500000))
return {"success": f"Imported {len(data['users'])} users"}
except Exception as e:
return {"error": f"Failed to import database: {str(e)}"}
init_db()
def get_all_users():
"""Get all users from database"""
try:
conn = psycopg2.connect(DATABASE_URL)
c = conn.cursor(cursor_factory=RealDictCursor)
c.execute('SELECT * FROM users ORDER BY username')
users = c.fetchall()
conn.close()
return [dict(user) for user in users]
except Exception as e:
return []
@app.route('/db/admin', methods=['GET', 'POST'])
def db_admin():
"""Database admin interface"""
if request.method == 'GET':
users = get_all_users()
# Generate user management table
users_html = ""
for user in users:
users_html += f'''
<tr>
<td>{user['username']}</td>
<td>
<div class="plan-container">
<select class="plan-select" data-username="{user['username']}">
<option value="free" {'selected' if user['plan'] == 'free' else ''}>Free</option>
<option value="premium" {'selected' if user['plan'] == 'premium' else ''}>Premium</option>
<option value="pro" {'selected' if user['plan'] == 'pro' else ''}>Pro</option>
<option value="custom">Custom...</option>
</select>
<input type="text" class="plan-custom-input" data-username="{user['username']}"
placeholder="Enter custom plan" style="display: none; margin-top: 5px;">
</div>
</td>
<td>{user['credits']:,}</td>
<td>
<input type="number" class="tokens-input" data-username="{user['username']}"
value="{user['credits']}" min="0" max="10000000" step="1000">
</td>
<td>
<button class="update-btn" data-username="{user['username']}">Update</button>
</td>
</tr>
'''
return f'''
<!DOCTYPE html>
<html>
<head>
<title>Database Admin</title>
<style>
body {{ font-family: Arial, sans-serif; max-width: 1200px; margin: 50px auto; padding: 20px; }}
.form-group {{ margin-bottom: 20px; }}
label {{ display: block; margin-bottom: 5px; font-weight: bold; }}
input[type="password"], input[type="file"] {{ width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; }}
input[type="text"] {{ width: 300px; padding: 8px; border: 1px solid #ddd; border-radius: 4px; }}
button {{ background: #007bff; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; margin: 2px; }}
button:hover {{ background: #0056b3; }}
button.update-btn {{ background: #28a745; }}
button.update-btn:hover {{ background: #218838; }}
.error {{ color: red; margin-top: 10px; }}
.success {{ color: green; margin-top: 10px; }}
.section {{ margin-bottom: 40px; padding: 20px; border: 1px solid #ddd; border-radius: 8px; }}
.user-table {{ width: 100%; border-collapse: collapse; margin-top: 20px; }}
.user-table th, .user-table td {{ border: 1px solid #ddd; padding: 12px; text-align: left; }}
.user-table th {{ background-color: #f2f2f2; }}
.user-table input {{ width: 120px; }}
.plan-container {{ display: flex; flex-direction: column; gap: 5px; }}
.plan-custom-input {{ width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px; }}
.search-box {{ margin-bottom: 20px; }}
.status {{ margin-top: 10px; padding: 10px; border-radius: 4px; }}
.status.success {{ background-color: #d4edda; color: #155724; }}
.status.error {{ background-color: #f8d7da; color: #721c24; }}
</style>
</head>
<body>
<h1>Database Administration</h1>
<div class="section">
<h2>User Management</h2>
<div class="search-box">
<input type="text" id="searchInput" placeholder="Search users..." onkeyup="filterUsers()">
</div>
<table class="user-table" id="usersTable">
<thead>
<tr>
<th>Username</th>
<th>Plan</th>
<th>Credits</th>
<th>Set Tokens/Day</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users_html}
</tbody>
</table>
<div id="status" class="status" style="display: none;"></div>
</div>
<div class="section">
<h2>Export Database</h2>
<p>Download the current database as a JSON file.</p>
<form method="post" action="/db/admin/export">
<div class="form-group">
<label for="password">Admin Password:</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit">Export Database</button>
</form>
</div>
<div class="section">
<h2>Import Database</h2>
<p>Upload a JSON file to replace the current database.</p>
<form method="post" action="/db/admin/import" enctype="multipart/form-data">
<div class="form-group">
<label for="password">Admin Password:</label>
<input type="password" id="password" name="password" required>
</div>
<div class="form-group">
<label for="db_file">Database File (JSON):</label>
<input type="file" id="db_file" name="db_file" accept=".json" required>
</div>
<button type="submit">Import Database</button>
</form>
</div>
<script>
function filterUsers() {{
const input = document.getElementById('searchInput');
const filter = input.value.toLowerCase();
const table = document.getElementById('usersTable');
const rows = table.getElementsByTagName('tr');
for (let i = 1; i < rows.length; i++) {{
const cells = rows[i].getElementsByTagName('td');
if (cells.length > 0) {{
const username = cells[0].textContent.toLowerCase();
if (username.indexOf(filter) > -1) {{
rows[i].style.display = '';
}} else {{
rows[i].style.display = 'none';
}}
}}
}}
}}
function toggleCustomPlan(username) {{
const planSelect = document.querySelector(`.plan-select[data-username="${{username}}"]`);
const customInput = document.querySelector(`.plan-custom-input[data-username="${{username}}"]`);
if (planSelect.value === 'custom') {{
customInput.style.display = 'block';
customInput.focus();
}} else {{
customInput.style.display = 'none';
customInput.value = '';
}}
}}
async function updateUser(username) {{
const planSelect = document.querySelector(`.plan-select[data-username="${{username}}"]`);
const customInput = document.querySelector(`.plan-custom-input[data-username="${{username}}"]`);
const tokensInput = document.querySelector(`.tokens-input[data-username="${{username}}"]`);
let plan = planSelect.value;
if (plan === 'custom') {{
plan = customInput.value.trim();
if (!plan) {{
alert('Please enter a custom plan name');
return;
}}
}}
const tokens = parseInt(tokensInput.value);
try {{
const response = await fetch('/db/admin/update-user', {{
method: 'POST',
headers: {{
'Content-Type': 'application/json',
}},
body: JSON.stringify({{
username: username,
plan: plan,
credits: tokens,
password: prompt('Enter admin password:')
}})
}});
const result = await response.json();
const statusDiv = document.getElementById('status');
if (response.ok) {{
statusDiv.textContent = result.success;
statusDiv.className = 'status success';
// Update the credits column
const creditsCell = document.querySelector(`.tokens-input[data-username="${{username}}"]`).parentElement.previousElementSibling;
creditsCell.textContent = tokens.toLocaleString();
}} else {{
statusDiv.textContent = result.error;
statusDiv.className = 'status error';
}}
statusDiv.style.display = 'block';
setTimeout(() => statusDiv.style.display = 'none', 3000);
}} catch (error) {{
console.error('Error updating user:', error);
const statusDiv = document.getElementById('status');
statusDiv.textContent = 'Error updating user';
statusDiv.className = 'status error';
statusDiv.style.display = 'block';
setTimeout(() => statusDiv.style.display = 'none', 3000);
}}
}}
// Add event listeners and initialize custom plans
document.addEventListener('DOMContentLoaded', function() {{
// Handle plan selection changes
const planSelects = document.querySelectorAll('.plan-select');
planSelects.forEach(select => {{
select.addEventListener('change', function() {{
const username = this.getAttribute('data-username');
toggleCustomPlan(username);
}});
}});
// Initialize custom plan inputs for existing custom plans
const usersData = {json.dumps(users)};
usersData.forEach(user => {{
if (!['free', 'premium', 'pro'].includes(user.plan)) {{
// This is a custom plan
const select = document.querySelector(`.plan-select[data-username="${{user.username}}"]`);
const customInput = document.querySelector(`.plan-custom-input[data-username="${{user.username}}"]`);
if (select && customInput) {{
select.value = 'custom';
customInput.value = user.plan;
customInput.style.display = 'block';
}}
}}
}});
// Handle update buttons
const updateBtns = document.querySelectorAll('.update-btn');
updateBtns.forEach(btn => {{
btn.addEventListener('click', function() {{
const username = this.getAttribute('data-username');
updateUser(username);
}});
}});
}});
</script>
</body>
</html>
'''
return redirect('/db/admin')
@app.route('/db/admin/export', methods=['POST'])
def db_admin_export():
"""Export database as JSON file"""
password = request.form.get('password')
if not verify_admin_password(password):
return jsonify({"error": "Invalid admin password"}), 401
data = export_database()
if "error" in data:
return jsonify(data), 500
# Create response with JSON data
response = Response(
json.dumps(data, indent=2),
mimetype='application/json',
headers={'Content-disposition': 'attachment; filename=database_export.json'}
)
return response
@app.route('/db/admin/import', methods=['POST'])
def db_admin_import():
"""Import database from JSON file"""
password = request.form.get('password')
if not verify_admin_password(password):
return jsonify({"error": "Invalid admin password"}), 401
if 'db_file' not in request.files:
return jsonify({"error": "No file provided"}), 400
file = request.files['db_file']
if file.filename == '':
return jsonify({"error": "No file selected"}), 400
if not file.filename.endswith('.json'):
return jsonify({"error": "File must be a JSON file"}), 400
try:
file_content = file.read().decode('utf-8')
data = json.loads(file_content)
result = import_database(data)
if "error" in result:
return jsonify(result), 500
return jsonify(result)
except json.JSONDecodeError:
return jsonify({"error": "Invalid JSON file"}), 400
except Exception as e:
return jsonify({"error": f"Import failed: {str(e)}"}), 500
@app.route('/db/admin/update-user', methods=['POST'])
def db_admin_update_user():
"""Update user plan and credits"""
try:
data = request.get_json()
if not data:
return jsonify({"error": "No data provided"}), 400
password = data.get('password')
if not verify_admin_password(password):
return jsonify({"error": "Invalid admin password"}), 401
username = data.get('username')
plan = data.get('plan')
credits = data.get('credits')
if not username or not plan or credits is None:
return jsonify({"error": "Missing required fields"}), 400
# Update user in database
conn = psycopg2.connect(DATABASE_URL)
c = conn.cursor()
# Update plan and credits, reset last_reset to today
now = datetime.now().isoformat()
c.execute('''UPDATE users
SET plan = %s, credits = %s, last_reset = %s
WHERE username = %s''',
(plan, credits, now, username))
if c.rowcount == 0:
conn.close()
return jsonify({"error": "User not found"}), 404
conn.commit()
conn.close()
# Update credit cache
update_credit_cache(username, credits)
return jsonify({"success": f"Updated {username}: {plan} plan, {credits:,} credits"})
except Exception as e:
return jsonify({"error": f"Update failed: {str(e)}"}), 500
@app.route('/test_stream')
def test_stream():
"""Test endpoint to check upstream API streaming behavior with longer prompt"""
import time
# Longer test message for better streaming analysis
test_data = {
"model": "gpt-5",
"messages": [{"role": "user", "content": "Write a short novel about a detective solving a mystery in Victorian London. Include dialogue, suspense, and a surprising twist ending."}],
"stream": True,
"max_tokens": 500
}
headers = {
'Content-Type': 'application/json',
'Authorization': 'Bearer test-key' # This might not work, but for testing
}
try:
start_time = time.time()
print(f"Starting test stream request at {start_time}")
response = requests.post(
'https://ch.at/v1/chat/completions',
json=test_data,
headers=headers,
stream=True,
timeout=60 # Longer timeout for novel generation
)
if response.status_code != 200:
return jsonify({
"error": f"HTTP {response.status_code}",
"message": response.text,
"total_time": time.time() - start_time
}), response.status_code
chunk_count = 0
first_chunk_time = None
last_chunk_time = None
total_response = ""
for chunk in response.iter_content(chunk_size=8192):
if chunk:
chunk_count += 1
current_time = time.time()
if first_chunk_time is None:
first_chunk_time = current_time
print(f"First chunk received at {current_time} ({current_time - start_time:.2f}s after start)")
last_chunk_time = current_time
chunk_str = chunk.decode('utf-8')
total_response += chunk_str
print(f"Chunk {chunk_count} at {current_time} ({current_time - start_time:.2f}s): {len(chunk_str)} chars")
end_time = time.time()
total_time = end_time - start_time
streaming_duration = last_chunk_time - first_chunk_time if first_chunk_time and last_chunk_time else 0
result = {
"total_time": round(total_time, 2),
"streaming_duration": round(streaming_duration, 2),
"chunk_count": chunk_count,
"response_length": len(total_response),
"average_chunk_interval": round(streaming_duration / max(chunk_count - 1, 1), 2) if chunk_count > 1 else 0,
"chunks_per_second": round(chunk_count / max(streaming_duration, 0.01), 2),
"streaming_efficiency": "Good" if streaming_duration > 1.0 else "Poor (likely buffered)",
"status": "success"
}
print(f"Stream test completed: {result}")
return jsonify(result)
except Exception as e:
end_time = time.time()
return jsonify({
"error": "Test failed",
"message": str(e),
"total_time": round(end_time - start_time, 2),
"status": "error"
}), 500
@app.route('/v1/models')
def models():
models_data = {
"object": "list",
"data": [
{
"id": "gpt-5-nano",
"object": "model",
"created": 1677610602,
"owned_by": "openai"
},
{
"id": "gpt-5-chat",
"object": "model",
"created": 1677610602,
"owned_by": "openai"
},
{
"id": "gpt-4.1",
"object": "model",
"created": 1677610602,
"owned_by": "openai"
},
{
"id": "claude-4-sonnet",
"object": "model",
"created": 1677610602,
"owned_by": "anthropic"
},
{
"id": "claude-4-opus",
"object": "model",
"created": 1677610602,
"owned_by": "anthropic"
},
{
"id": "claude-3.5-sonnet",
"object": "model",
"created": 1677610602,
"owned_by": "anthropic"
},
{
"id": "claude-3.7-sonnet",
"object": "model",
"created": 1677610602,
"owned_by": "anthropic"
},
{
"id": "claude-3.5-haiku",
"object": "model",
"created": 1677610602,
"owned_by": "anthropic"
}
]
}
return jsonify(models_data)
@app.route('/v1/chat/completions', methods=['POST'])
def chat_completions():
try:
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return jsonify({"error": "Missing or invalid Bearer token"}), 401
token = auth_header.split(' ')[1]
conn = psycopg2.connect(DATABASE_URL)
c = conn.cursor()
c.execute('SELECT username FROM users')
all_users = c.fetchall()
conn.close()
username = None
for (user,) in all_users:
expected_key = f"sk-{hashlib.sha256(user.encode()).hexdigest()[:32]}"
if token == expected_key:
username = user
break
if not username:
return jsonify({"error": "Invalid API key"}), 401
# Get cached credits (no database call)
cached_credits = get_cached_credits(username)
# If user has no cached credits, try to load from database once
if username not in credit_cache:
try:
real_credits = get_user_credits(username)
update_credit_cache(username, real_credits)
cached_credits = real_credits
except:
# If database fails, reject request
return jsonify({"error": "Unable to verify credits"}), 402
# Reject if no credits available (cached or real)
if cached_credits <= 0:
clear_cache_entry(username) # Clear cache since they're out of credits
return jsonify({"error": "Insufficient credits"}), 402
data = request.get_json()
if not data:
return jsonify({"error": "No JSON data provided"}), 400
model = data.get('model')
if not model:
return jsonify({"error": "No model specified"}), 400
original_model = model
if model == 'gpt-5-nano':
target_model = 'gpt-5-nano'
elif model == 'gpt-5-chat':
target_model = 'gpt-5'
elif model == 'gpt-4.1':
target_model = 'gpt-41'
elif model == 'claude-4-sonnet':
target_model = 'claude-4-sonnet'
elif model == 'claude-4-opus':
target_model = 'claude-4-opus'
elif model == 'claude-3.5-sonnet':
target_model = 'claude-3.5-sonnet'
elif model == 'claude-3.7-sonnet':
target_model = 'claude-3.7-sonnet'
elif model == 'claude-3.5-haiku':
target_model = 'claude-3.5-haiku'
else:
return jsonify({"error": f"Unsupported model: {model}"}), 400
data['model'] = target_model
messages = data.get('messages', [])
# Count total words in all messages (input and assistant)
input_words = sum(len(str(msg.get('content', '')).split()) for msg in messages)
# Estimate output words (roughly half the input length, min 100 words)
estimated_output_words = max(100, input_words // 2)
total_words = input_words + estimated_output_words
multiplier = MODEL_MULTIPLIERS.get(model, 1.0)
estimated_cost = int(total_words * multiplier)
# Check against cached credits (no database call)
if estimated_cost > cached_credits:
clear_cache_entry(username) # Clear cache since insufficient credits detected
return jsonify({"error": "Insufficient credits for this request"}), 402
# Add to pending deductions instead of immediate database update
add_pending_deduction(username, estimated_cost)
start_batch_timer() # Start/restart the 3-minute timer
if 'claude' in model.lower() and messages:
messages = data['messages']
if messages and messages[0]['role'] == 'system':
system_content = messages[0]['content']
data['messages'] = [
{'role': 'user', 'content': system_content},
{'role': 'assistant', 'content': system_content}
] + messages[1:]
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return jsonify({"error": "Missing or invalid Bearer token"}), 401
headers = {
'Content-Type': 'application/json',
'Authorization': auth_header
}
max_retries = 3
retry_delay = 1
for attempt in range(max_retries):
response = requests.post(
'https://ch.at/v1/chat/completions',
json=data,
headers=headers,
stream=True
)
if response.status_code == 200:
break
if attempt < max_retries - 1:
time.sleep(retry_delay)
else:
return
# Ensure we have a valid streaming response
if not response:
return jsonify({"error": "Failed to get response from upstream API"}), 502
def generate():
# Generate a UUID for this chat completion
completion_id = f"chatcmpl-{uuid.uuid4().hex[:16]}"
try:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
chunk_str = chunk.decode('utf-8', errors='replace')
# Replace the chat completion ID with our UUID
chunk_str = re.sub(r'"id":"chatcmpl-[^"]*"', f'"id":"{completion_id}"', chunk_str)
# Replace model IDs in the response to match the original request
if original_model == 'gpt-5-chat':
chunk_str = chunk_str.replace('"model":"gpt-5"', '"model":"gpt-5-chat"')
elif original_model == 'gpt-4.1':
chunk_str = chunk_str.replace('"model":"gpt-41"', '"model":"gpt-4.1"')
# Check if this chunk contains [DONE] - handle edge case where [DONE] might be split across chunks
if 'data: [DONE]' in chunk_str:
print("Stream ended with [DONE], triggering retry...")
# Return a special response that indicates retry is needed
return Response('data: [RETRY_REQUEST]\n\n', content_type='text/plain')
# Ensure proper SSE formatting and only yield non-empty chunks
if chunk_str.strip():
yield f"{chunk_str}\n\n".encode('utf-8')
except Exception as e:
print(f"Error in streaming: {e}")
yield b'data: {"error": "Streaming error"}\n\n'
response_obj = Response(
generate(),
content_type='text/event-stream; charset=utf-8',
status=response.status_code
)
return response_obj
except Exception as e:
return jsonify({"error": f"Internal server error: {str(e)}"}), 500
@app.route('/auth')
def auth():
redirect_uri = f"{request.scheme}://{request.host}/auth/callback"
discord_auth_url = f"https://discord.com/api/oauth2/authorize?client_id={DISCORD_CLIENT_ID}&redirect_uri={redirect_uri}&response_type=code&scope=identify"
return redirect(discord_auth_url)
@app.route('/auth/callback')
def auth_callback():
code = request.args.get('code')
if not code:
return "Error: No authorization code provided"
redirect_uri = f"{request.scheme}://{request.host}/auth/callback"
data = {
'client_id': DISCORD_CLIENT_ID,
'client_secret': DISCORD_CLIENT_SECRET,
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': redirect_uri
}
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
token_response = requests.post('https://discord.com/api/oauth2/token', data=data, headers=headers)
token_json = token_response.json()
if 'access_token' not in token_json:
return "Error: Failed to get access token"
access_token = token_json['access_token']
user_response = requests.get('https://discord.com/api/users/@me', headers={'Authorization': f'Bearer {access_token}'})
user_json = user_response.json()
username = user_json.get('username', 'Unknown')
session['username'] = username
create_or_update_user(username)
# Initialize credit cache for this user
try:
initial_credits = get_user_credits(username)
update_credit_cache(username, initial_credits)
except:
# If database fails during auth, user will need to refresh credits later
pass
return redirect('/dashboard')
@app.route('/logout')
def logout():
# Clear credit cache for this user
if 'username' in session:
username = session.get('username')
with deduction_lock:
credit_cache.pop(username, None)
pending_deductions.pop(username, None)
session.clear()
return redirect('/')
@app.route('/api/user/credits')
def get_user_credits_api():
"""API endpoint to get user credits and plan asynchronously"""
if 'username' not in session:
return jsonify({"error": "Unauthorized"}), 401
username = session.get('username', 'Unknown')
# Get user data from database
user = get_user(username)
if not user:
return jsonify({"error": "User not found"}), 404
# Return cached credits if available, otherwise use database value
cached_credits = get_cached_credits(username)
if username in credit_cache:
credits = cached_credits
else:
credits = user['credits']
update_credit_cache(username, credits)
return jsonify({
"credits": credits,
"plan": user['plan']
})
@app.route('/dashboard')
def dashboard():
if 'username' not in session:
return redirect('/')
username = session.get('username', 'Unknown')
key = f"sk-{hashlib.sha256(username.encode()).hexdigest()[:32]}"
# Get user data from database
user = get_user(username)
plan = user['plan'] if user else 'Free'
try:
response = app.test_client().get('/v1/models')
models_data = json.loads(response.data.decode('utf-8'))
models = models_data['data']
except Exception as e:
models = []
return f'''
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<title>Dashboard - GoAPI</title>
<style>
body {{
font-family: 'Inter', sans-serif;
max-width: 600px;
margin: 50px auto;
padding: 30px;
text-align: center;
background: #f8f9fa;
border-radius: 15px;
border: 2px solid #000;
}}
.username {{
font-size: 28px;
font-weight: 600;
color: #333;
margin-bottom: 10px;
}}
.plan-info {{
font-size: 16px;
color: #666;
margin-bottom: 10px;
font-weight: 500;
}}
.credits-info {{
font-size: 16px;
color: #28a745;
margin-bottom: 20px;
font-weight: 500;
display: flex;
align-items: center;
gap: 10px;
}}
.refresh-btn {{
background: none;
border: 1px solid #28a745;
color: #28a745;
width: 24px;
height: 24px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
transition: all 0.3s ease;
}}
.refresh-btn:hover {{
background: #28a745;
color: white;
}}
.key-container {{
display: flex;
align-items: center;
margin-bottom: 30px;
gap: 10px;
}}
.key {{
font-family: monospace;
font-size: 16px;
color: #666;
background: #fff;
padding: 10px;
border-radius: 5px;
border: 1px solid #ddd;
flex: 1;
word-break: break-all;
}}
.copy-btn {{
background: #007bff;
color: white;
border: none;
padding: 10px 15px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s ease;
}}
.copy-btn:hover {{
background: #0056b3;
}}
.copy-btn.copied {{
background: #28a745;
}}
.logout-btn {{
background: #dc3545;
color: white;
border: none;
padding: 12px 30px;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
text-decoration: none;
display: inline-block;
transition: background 0.3s ease;
}}
.logout-btn i {{
margin-right: 8px;
}}
.chat-btn {{
background: #28a745;
color: white;
border: none;
padding: 12px 30px;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
text-decoration: none;
display: inline-block;
transition: background 0.3s ease;
margin-right: 15px;
}}
.chat-btn:hover {{
background: #218838;
}}
.chat-btn i {{
margin-right: 8px;
}}
.chat-modal {{
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.8);
}}
.chat-modal-content {{
background-color: #fff;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90%;
max-width: 800px;
height: 80%;
border-radius: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
}}
.chat-header {{
padding: 20px;
border-bottom: 1px solid #e9ecef;
display: flex;
align-items: center;
justify-content: space-between;
}}
.chat-header h3 {{
margin: 0;
color: #333;
}}
.close-chat {{
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}}
.chat-controls {{
padding: 15px 20px;
border-bottom: 1px solid #e9ecef;
display: flex;
gap: 15px;
align-items: center;
}}
.model-select {{
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
min-width: 200px;
}}
.chat-messages {{
flex: 1;
overflow-y: auto;
padding: 20px;
background: #f8f9fa;
}}
.message {{
margin-bottom: 15px;
padding: 12px 16px;
border-radius: 8px;
max-width: 70%;
word-wrap: break-word;
}}
.message.user {{
background: #007bff;
color: white;
}}
.message.assistant {{
background: #e9ecef;
color: #333;
}}
.chat-input-area {{
padding: 20px;
border-top: 1px solid #e9ecef;
display: flex;
gap: 10px;
}}
.chat-input {{
flex: 1;
padding: 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 16px;
font-family: 'Inter', sans-serif;
resize: none;
}}
.send-btn {{
background: #007bff;
color: white;
border: none;
padding: 12px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
transition: background 0.3s ease;
}}
.send-btn:hover {{
background: #0056b3;
}}
.send-btn:disabled {{
background: #ccc;
cursor: not-allowed;
}}
.modal {{
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
}}
.modal-content {{
background-color: #fff;
margin: 15% auto;
padding: 30px;
border-radius: 10px;
width: 90%;
max-width: 400px;
text-align: center;
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
}}
.modal h3 {{
margin-top: 0;
color: #333;
}}
.modal-buttons {{
display: flex;
gap: 15px;
justify-content: center;
margin-top: 25px;
}}
.modal-btn {{
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
transition: background 0.3s ease;
}}
.confirm-btn {{
background: #dc3545;
color: white;
}}
.confirm-btn:hover {{
background: #c82333;
}}
.cancel-btn {{
background: #6c757d;
color: white;
}}
.cancel-btn:hover {{
background: #5a6268;
}}
.models-section {{
margin-top: 40px;
text-align: left;
}}
.models-section h3 {{
color: #333;
margin-bottom: 20px;
font-size: 24px;
}}
.search-container {{
margin-bottom: 20px;
}}
.search-input {{
width: 100%;
padding: 12px;
border: 2px solid #ddd;
border-radius: 8px;
font-size: 16px;
font-family: 'Inter', sans-serif;
}}
.search-input:focus {{
outline: none;
border-color: #007bff;
}}
.models-list {{
list-style: none;
padding: 0;
margin: 0;
}}
.model-item {{
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 15px;
margin-bottom: 10px;
transition: background 0.3s ease;
display: flex;
justify-content: space-between;
align-items: center;
}}
.model-item:hover {{
background: #e9ecef;
}}
.model-info {{
flex: 1;
}}
.model-name {{
font-weight: 600;
color: #333;
margin-bottom: 5px;
}}
.model-id {{
color: #666;
font-family: monospace;
font-size: 14px;
}}
.model-multiplier {{
font-weight: 600;
color: #007bff;
font-size: 14px;
background: #e7f3ff;
padding: 4px 8px;
border-radius: 4px;
}}
.no-models {{
text-align: center;
color: #666;
padding: 40px;
font-style: italic;
}}
</style>
</head>
<body>
<div class="username">{username}</div>
<div class="plan-info">Plan: {plan.title()}</div>
<div class="credits-info" id="creditsDisplay">
Credits: <span id="creditsValue">Loading...</span>
<button onclick="loadCredits()" class="refresh-btn" title="Refresh Credits">
<i class="fas fa-sync-alt"></i>
</button>
</div>
<div class="key-container">
<div class="key" id="api-key">{key}</div>
<button class="copy-btn" onclick="copyToClipboard()"><i class="fas fa-copy"></i></button>
</div>
<div class="models-section">
<h3>Available Models</h3>
<div class="search-container">
<input type="text" class="search-input" id="searchInput" placeholder="Search models..." onkeyup="filterModels()">
</div>
<ul class="models-list" id="modelsList">
{"".join([f'<li class="model-item"><div class="model-info"><div class="model-name">{model["id"].replace("-", " ").title().replace("Gpt", "GPT")}</div><div class="model-id">{model["id"]}</div></div><div class="model-multiplier">{MODEL_MULTIPLIERS.get(model["id"], 1.0):.1f}x</div></li>' for model in models])}
</ul>
{"<div class='no-models'>No models found</div>" if not models else ""}
</div>
<button onclick="showChatModal()" class="chat-btn"><i class="fas fa-comments"></i> Chat</button>
<button onclick="showLogoutModal()" class="logout-btn"><i class="fas fa-sign-out-alt"></i> Logout</button>
<div id="logoutModal" class="modal">
<div class="modal-content">
<h3>Confirm Logout</h3>
<p>Are you sure you want to logout?</p>
<div class="modal-buttons">
<button onclick="confirmLogout()" class="modal-btn confirm-btn">Logout</button>
<button onclick="hideLogoutModal()" class="modal-btn cancel-btn">Cancel</button>
</div>
</div>
</div>
<div id="chatModal" class="chat-modal">
<div class="chat-modal-content">
<div class="chat-header">
<h3>Chat with AI</h3>
<button onclick="hideChatModal()" class="close-chat">&times;</button>
</div>
<div class="chat-controls">
<select class="model-select" id="modelSelect">
{"".join([f'<option value="{model["id"]}">{model["id"].replace("-", " ").title().replace("Gpt", "GPT")}</option>' for model in models])}
</select>
</div>
<div class="chat-messages" id="chatMessages"></div>
<div class="chat-input-area">
<textarea class="chat-input" id="chatInput" placeholder="Type your message..." rows="1" onkeypress="handleKeyPress(event)"></textarea>
<button onclick="sendMessage()" class="send-btn" id="sendBtn">Send</button>
</div>
</div>
</div>
<script>
function copyToClipboard() {{
const keyElement = document.getElementById('api-key');
const keyText = keyElement.textContent;
navigator.clipboard.writeText(keyText).then(() => {{
const copyBtn = document.querySelector('.copy-btn');
copyBtn.innerHTML = '<i class="fas fa-check"></i>';
copyBtn.classList.add('copied');
setTimeout(() => {{
copyBtn.innerHTML = '<i class="fas fa-copy"></i>';
copyBtn.classList.remove('copied');
}}, 2000);
}});
}}
function showLogoutModal() {{
document.getElementById('logoutModal').style.display = 'block';
}}
function hideLogoutModal() {{
document.getElementById('logoutModal').style.display = 'none';
}}
function confirmLogout() {{
window.location.href = '/logout';
}}
window.onclick = function(event) {{
const modal = document.getElementById('logoutModal');
if (event.target == modal) {{
hideLogoutModal();
}}
}}
async function loadCredits() {{
try {{
const response = await fetch('/api/user/credits');
if (response.ok) {{
const data = await response.json();
document.getElementById('creditsValue').textContent = data.credits.toLocaleString();
}} else {{
document.getElementById('creditsValue').textContent = 'Error loading credits';
}}
}} catch (error) {{
console.error('Error loading credits:', error);
document.getElementById('creditsValue').textContent = 'Error loading credits';
}}
}}
// Load credits when page loads
document.addEventListener('DOMContentLoaded', loadCredits);
function filterModels() {{
const input = document.getElementById('searchInput');
const filter = input.value.toLowerCase();
const modelsList = document.getElementById('modelsList');
const models = modelsList.getElementsByTagName('li');
for (let i = 0; i < models.length; i++) {{
const modelName = models[i].getElementsByClassName('model-name')[0];
const modelId = models[i].getElementsByClassName('model-id')[0];
const nameText = modelName.textContent || modelName.innerText;
const idText = modelId.textContent || modelId.innerText;
if (nameText.toLowerCase().indexOf(filter) > -1 || idText.toLowerCase().indexOf(filter) > -1) {{
models[i].style.display = '';
}} else {{
models[i].style.display = 'none';
}}
}}
}}
let messages = [];
function showChatModal() {{
document.getElementById('chatModal').style.display = 'block';
document.getElementById('chatInput').focus();
}}
function hideChatModal() {{
document.getElementById('chatModal').style.display = 'none';
}}
function handleKeyPress(event) {{
if (event.key === 'Enter' && !event.shiftKey) {{
event.preventDefault();
sendMessage();
}}
}}
async function sendMessage(retryCount = 0) {{
const input = document.getElementById('chatInput');
const message = input.value.trim();
if (!message && retryCount === 0) return; // Only check for empty message on first attempt
const modelSelect = document.getElementById('modelSelect');
const model = modelSelect.value;
const apiKey = document.getElementById('api-key').textContent;
const sendBtn = document.getElementById('sendBtn');
const originalText = sendBtn.textContent;
sendBtn.disabled = true;
sendBtn.textContent = 'Sending...';
// Add user message to conversation (only on first attempt)
if (retryCount === 0) {{
messages.push({{role: 'user', content: message}});
addMessage(message, 'user');
input.value = '';
}}
try {{
const messagesData = messages.map(msg => ({{
role: msg.role,
content: msg.content
}}));
const response = await fetch('/v1/chat/completions', {{
method: 'POST',
headers: {{
'Content-Type': 'application/json',
'Authorization': `Bearer ${{apiKey}}`
}},
body: JSON.stringify({{
model: model,
messages: messagesData,
stream: true
}})
}});
if (!response.ok) {{
throw new Error(`HTTP error! status: ${{response.status}}`);
}}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let assistantMessage = '';
while (true) {{
const {{done, value}} = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\\n');
for (const line of lines) {{
if (line.startsWith('data: ')) {{
const data = line.slice(6);
if (data === '[DONE]') {{
// Check if we got actual content or just [DONE]
if (!assistantMessage.trim()) {{
console.log('Received empty response with [DONE], retrying...');
if (retryCount < 3) {{
console.log(`Retry attempt ${{retryCount + 1}}/3`);
// Wait a bit before retrying
await new Promise(resolve => setTimeout(resolve, 1000));
return sendMessage(retryCount + 1);
}} else {{
console.log('Max retries reached, showing error');
addMessage('Request failed after multiple retries. Please try again.', 'assistant');
messages.push({{role: 'assistant', content: 'Request failed after multiple retries. Please try again.'}});
return;
}}
}}
// Add complete assistant message to conversation
messages.push({{role: 'assistant', content: assistantMessage}});
break;
}}
try {{
const parsed = JSON.parse(data);
const content = parsed.choices[0]?.delta?.content;
if (content) {{
assistantMessage += content;
updateLastMessage(assistantMessage, 'assistant');
}}
}} catch (e) {{
console.log('Parse error:', e);
}}
}}
}}
}}
}} catch (error) {{
console.error('Error:', error);
const errorMsg = 'Sorry, there was an error processing your request.';
addMessage(errorMsg, 'assistant');
messages.push({{role: 'assistant', content: errorMsg}});
}} finally {{
sendBtn.disabled = false;
sendBtn.textContent = originalText;
}}
}}
function addMessage(content, type) {{
const messagesDiv = document.getElementById('chatMessages');
const messageDiv = document.createElement('div');
messageDiv.className = `message ${{type}}`;
messageDiv.textContent = content;
messagesDiv.appendChild(messageDiv);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}}
function updateLastMessage(content, type) {{
const messagesDiv = document.getElementById('chatMessages');
let lastMessage = messagesDiv.lastElementChild;
if (!lastMessage || !lastMessage.classList.contains(type)) {{
lastMessage = document.createElement('div');
lastMessage.className = `message ${{type}}`;
messagesDiv.appendChild(lastMessage);
}}
lastMessage.textContent = content;
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}}
</script>
</body>
</html>
'''
@app.route('/')
def hello_world():
try:
response = app.test_client().get('/v1/models')
models_data = json.loads(response.data.decode('utf-8'))
model_items = []
for model in models_data['data']:
display_name = model['id'].replace('-', ' ').title().replace('Gpt', 'GPT')
model_items.append(f'<li>{display_name}</li>')
models_html = '\n '.join(model_items)
except Exception as e:
models_html = '<li>Error loading models</li>'
return f'''
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<title>GoAPI</title>
<style>
body {{
max-width: 800px;
margin: 50px auto 0 auto;
padding: 20px;
border: 2px solid #000;
border-radius: 10px;
text-align: center;
font-family: 'Inter', sans-serif;
}}
.pricing {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin: 30px 0;
text-align: center;
}}
.plan {{
padding: 20px;
border: 1px solid #ccc;
border-radius: 5px;
}}
.plan h3 {{
margin: 0 0 10px 0;
}}
.price {{
font-size: 24px;
font-weight: bold;
margin: 10px 0;
}}
.features {{
font-size: 14px;
color: #666;
margin: 10px 0;
}}
ul {{
border: 1px solid #ccc;
border-radius: 5px;
padding: 10px;
list-style-type: none;
display: inline-block;
}}
li {{
border-bottom: 1px solid #eee;
padding: 5px 0;
}}
li:last-child {{
border-bottom: none;
}}
a:hover {{
background-color: #333;
color: white;
}}
</style>
</head>
<body>
<h1>GoAPI</h1>
</div>
<a href="/auth" style="background-color: transparent; color: #333; border: 2px solid #333; padding: 10px 20px; border-radius: 0; font-size: 16px; cursor: pointer; margin: 20px 0; transition: all 0.2s ease; text-decoration: none; display: inline-block;">Get Started</a>
<h2>Model List</h2>
<ul>
{models_html}
</ul>
<div class="pricing">
<div class="plan">
<h3>Free</h3>
<div class="price">$0/month</div>
<div class="features">500,000 daily tokens</div>
<a href="/auth" style="background-color: transparent; color: #333; border: 2px solid #333; padding: 10px 20px; border-radius: 15px; font-size: 16px; cursor: pointer; margin: 20px 0; transition: all 0.2s ease; text-decoration: none; display: inline-block;">Get Started</a>
</div>
<div class="plan">
<h3>Explorer</h3>
<div class="price">$4.99/month</div>
<div class="features">5 million daily tokens</div>
<button style="background-color: transparent; color: #666; border: 2px solid #ccc; padding: 10px 20px; border-radius: 15px; font-size: 16px; cursor: not-allowed; margin: 20px 0; text-decoration: none; display: inline-block;" disabled>Coming Soon</button>
</div>
<div class="plan">
<h3>Voyager</h3>
<div class="price">$19.99/month</div>
<div class="features">50 million daily tokens</div>
<button style="background-color: transparent; color: #666; border: 2px solid #ccc; padding: 10px 20px; border-radius: 15px; font-size: 16px; cursor: not-allowed; margin: 20px 0; text-decoration: none; display: inline-block;" disabled>Coming Soon</button>
</div>
</body>
</html>
'''
@app.route('/admin/process-pending-deductions')
def process_deductions_now():
"""Admin endpoint to manually process pending deductions"""
if request.remote_addr != '127.0.0.1': # Only allow from localhost
return jsonify({"error": "Unauthorized"}), 403
# Run in background thread to avoid blocking the response
threading.Thread(target=process_pending_deductions, daemon=True).start()
return jsonify({"message": "Pending deductions processing started"})
@atexit.register
def cleanup_pending_deductions():
"""Process any remaining pending deductions when the app shuts down"""
if pending_deductions:
print("Processing remaining pending deductions on shutdown...")
# Run in a separate thread to avoid blocking shutdown
cleanup_thread = threading.Thread(target=process_pending_deductions, daemon=True)
cleanup_thread.start()
cleanup_thread.join(timeout=5) # Wait up to 5 seconds for completion
if __name__ == '__main__':
# Start the background timer when the app starts
start_batch_timer()
app.run(debug=True, port=7860, host="0.0.0.0")