Commit fe8b625a authored by Your Name's avatar Your Name

Analytics system

parent 823291c7
......@@ -54,7 +54,7 @@ from .auth.qwen import QwenOAuth2
from .handlers import RequestHandler, RotationHandler, AutoselectHandler
from .utils import count_messages_tokens, split_messages_into_chunks, get_max_request_tokens_for_model
__version__ = "0.99.28"
__version__ = "0.99.29"
__all__ = [
# Config
"config",
......
......@@ -121,7 +121,10 @@ class Analytics:
rotation_id: Optional[str] = None,
autoselect_id: Optional[str] = None,
user_id: Optional[int] = None,
token_id: Optional[int] = None
token_id: Optional[int] = None,
prompt_tokens: Optional[int] = None,
completion_tokens: Optional[int] = None,
actual_cost: Optional[float] = None
):
"""
Record a request for analytics.
......@@ -137,6 +140,9 @@ class Analytics:
autoselect_id: Optional autoselect identifier if request went through autoselect
user_id: Optional user ID if request was made with API token
token_id: Optional API token ID if request was made with API token
prompt_tokens: Optional number of input/prompt tokens
completion_tokens: Optional number of output/completion tokens
actual_cost: Optional actual cost returned by provider (in USD)
"""
# Initialize provider tracking if needed
if provider_id not in self._request_counts:
......@@ -161,7 +167,8 @@ class Analytics:
# Persist to database
if tokens_used > 0:
self.db.record_token_usage(provider_id, model_name, tokens_used, user_id)
logger.info(f"Analytics.record_request: Recording to DB - provider={provider_id}, latency_ms={latency_ms}, success={success}, prompt={prompt_tokens}, completion={completion_tokens}, cost={actual_cost}")
self.db.record_token_usage(provider_id, model_name, tokens_used, user_id, success, latency_ms, error_type, token_id, prompt_tokens, completion_tokens, actual_cost)
# Also record user token usage if this was an API token request
if user_id is not None and token_id is not None:
......@@ -231,16 +238,32 @@ class Analytics:
placeholder = '?' if self.db.db_type == 'sqlite' else '%s'
# Build query with optional user filter
user_condition = f" AND user_id = {placeholder}" if user_filter is not None else ""
# user_filter = -1 means "only global requests" (user_id IS NULL)
# user_filter = None means "all requests"
# user_filter = <id> means "only requests from that user"
if user_filter == -1:
user_condition = " AND user_id IS NULL"
params = [provider_id, from_datetime.isoformat(), to_datetime.isoformat()]
elif user_filter is not None:
user_condition = f" AND user_id = {placeholder}"
params = [provider_id, from_datetime.isoformat(), to_datetime.isoformat(), user_filter]
else:
user_condition = ""
params = [provider_id, from_datetime.isoformat(), to_datetime.isoformat()]
if user_filter is not None:
params.append(user_filter)
# Get token usage and actual time range of requests
cursor.execute(f'''
SELECT
COUNT(*) as total_requests,
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count,
SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as error_count,
AVG(COALESCE(latency_ms, 0)) as avg_latency,
MIN(COALESCE(latency_ms, 0)) as min_latency,
MAX(COALESCE(latency_ms, 0)) as max_latency,
SUM(tokens_used) as total_tokens,
SUM(COALESCE(prompt_tokens, 0)) as total_prompt_tokens,
SUM(COALESCE(completion_tokens, 0)) as total_completion_tokens,
SUM(COALESCE(actual_cost, 0)) as total_actual_cost,
MIN(timestamp) as first_request,
MAX(timestamp) as last_request
FROM token_usage
......@@ -252,9 +275,17 @@ class Analytics:
result = cursor.fetchone()
total_requests = result[0] if result else 0
total_tokens = result[1] if result else 0
first_request = result[2] if result and result[2] else None
last_request = result[3] if result and result[3] else None
success_count = result[1] if result else 0
error_count = result[2] if result else 0
avg_latency = result[3] if result and result[3] else 0
min_latency = result[4] if result and result[4] else 0
max_latency = result[5] if result and result[5] else 0
total_tokens = result[6] if result else 0
total_prompt_tokens = result[7] if result else 0
total_completion_tokens = result[8] if result else 0
total_actual_cost = result[9] if result else 0
first_request = result[10] if result and result[10] else None
last_request = result[11] if result and result[11] else None
# Calculate time-based rates using ACTUAL usage window, not query window
# This gives more accurate rate limiting metrics
......@@ -289,15 +320,18 @@ class Analytics:
return {
'provider_id': provider_id,
'requests': {'total': total_requests, 'success': total_requests, 'error': 0},
'latency': {'count': 0, 'total_ms': 0.0, 'min_ms': 0, 'max_ms': 0},
'errors': {},
'requests': {'total': total_requests, 'success': success_count, 'error': error_count},
'latency': {'count': total_requests, 'total_ms': avg_latency * total_requests if total_requests > 0 else 0, 'min_ms': min_latency, 'max_ms': max_latency},
'errors': {}, # Could be populated from error_type column if needed
'tokens': {
'total': total_tokens,
'prompt': total_prompt_tokens,
'completion': total_completion_tokens,
'TPM': tpm,
'TPH': tph,
'TPD': tpd
}
},
'actual_cost': total_actual_cost # Actual cost from provider responses
}
def get_token_usage_by_date_range(
......@@ -374,7 +408,7 @@ class Analytics:
Args:
from_datetime: Optional start datetime for filtering
to_datetime: Optional end datetime for filtering
user_filter: Optional user ID filter
user_filter: Optional user ID filter (-1 for global only, None for all, or user ID)
Returns:
List of provider statistics dictionaries
......@@ -388,10 +422,15 @@ class Analytics:
placeholder = '?' if self.db.db_type == 'sqlite' else '%s'
# Build query with optional user filter
user_condition = f" AND user_id = {placeholder}" if user_filter is not None else ""
if user_filter == -1:
user_condition = " AND user_id IS NULL"
params = [start.isoformat(), end.isoformat()]
elif user_filter is not None:
user_condition = f" AND user_id = {placeholder}"
params = [start.isoformat(), end.isoformat(), user_filter]
else:
user_condition = ""
params = [start.isoformat(), end.isoformat()]
if user_filter is not None:
params.append(user_filter)
cursor.execute(f'''
SELECT DISTINCT provider_id
......@@ -503,7 +542,18 @@ class Analytics:
time_bucket_expr = f"DATE_FORMAT(timestamp, '{date_format}')"
if provider_id:
if user_filter:
if user_filter == -1:
# Global requests only
cursor.execute(f'''
SELECT
{time_bucket_expr} as time_bucket,
SUM(tokens_used) as tokens
FROM token_usage
WHERE provider_id = {placeholder} AND user_id IS NULL AND timestamp >= {placeholder} AND timestamp <= {placeholder}
GROUP BY time_bucket
ORDER BY time_bucket
''', (provider_id, cutoff.isoformat(), end_time.isoformat()))
elif user_filter:
cursor.execute(f'''
SELECT
{time_bucket_expr} as time_bucket,
......@@ -524,7 +574,19 @@ class Analytics:
ORDER BY time_bucket
''', (provider_id, cutoff.isoformat(), end_time.isoformat()))
else:
if user_filter:
if user_filter == -1:
# Global requests only
cursor.execute(f'''
SELECT
{time_bucket_expr} as time_bucket,
SUM(tokens_used) as tokens,
provider_id
FROM token_usage
WHERE user_id IS NULL AND timestamp >= {placeholder} AND timestamp <= {placeholder}
GROUP BY time_bucket, provider_id
ORDER BY time_bucket
''', (cutoff.isoformat(), end_time.isoformat()))
elif user_filter:
cursor.execute(f'''
SELECT
{time_bucket_expr} as time_bucket,
......@@ -706,7 +768,8 @@ class Analytics:
self,
provider_id: str,
tokens_used: int,
prompt_tokens: Optional[int] = None
prompt_tokens: Optional[int] = None,
completion_tokens: Optional[int] = None
) -> float:
"""
Estimate cost for token usage.
......@@ -715,6 +778,7 @@ class Analytics:
provider_id: Provider identifier
tokens_used: Total tokens used
prompt_tokens: Optional breakdown of prompt tokens
completion_tokens: Optional breakdown of completion tokens
Returns:
Estimated cost in USD
......@@ -722,14 +786,19 @@ class Analytics:
# Get provider-specific pricing (checks subscription status and custom pricing)
provider_pricing = self._get_provider_pricing(provider_id)
# Calculate cost
if prompt_tokens is not None:
completion_tokens = tokens_used - prompt_tokens
# Calculate cost with actual token breakdown if available
if prompt_tokens is not None and completion_tokens is not None:
prompt_cost = (prompt_tokens / 1_000_000) * provider_pricing.get('prompt', 0)
completion_cost = (completion_tokens / 1_000_000) * provider_pricing.get('completion', 0)
return prompt_cost + completion_cost
elif prompt_tokens is not None:
# Only prompt tokens provided, calculate completion from total
completion_tokens_calc = tokens_used - prompt_tokens
prompt_cost = (prompt_tokens / 1_000_000) * provider_pricing.get('prompt', 0)
completion_cost = (completion_tokens_calc / 1_000_000) * provider_pricing.get('completion', 0)
return prompt_cost + completion_cost
else:
# Assume 25% prompt, 75% completion (common for chat)
# No breakdown available - use estimation (25% prompt, 75% completion is common for chat)
prompt_tokens_est = tokens_used * 0.25
completion_tokens_est = tokens_used * 0.75
prompt_cost = (prompt_tokens_est / 1_000_000) * provider_pricing.get('prompt', 0)
......@@ -776,13 +845,24 @@ class Analytics:
tokens = provider['tokens']
total_tokens = tokens['TPD'] # Use daily tokens for cost estimation
cost = self.estimate_cost(provider_id, total_tokens)
# Get actual prompt/completion tokens from provider stats
prompt_tokens = tokens.get('prompt', 0) if not (from_datetime or to_datetime) else None
completion_tokens = tokens.get('completion', 0) if not (from_datetime or to_datetime) else None
# Use actual cost if available, otherwise estimate
actual_cost = provider.get('actual_cost', 0)
if actual_cost > 0:
cost = actual_cost
else:
cost = self.estimate_cost(provider_id, total_tokens, prompt_tokens, completion_tokens)
total_cost += cost
provider_costs.append({
'provider_id': provider_id,
'tokens_today': total_tokens,
'estimated_cost': cost
'estimated_cost': cost,
'is_actual': actual_cost > 0
})
return {
......
......@@ -27,6 +27,7 @@ import hashlib
import base64
import webbrowser
import time
import asyncio
import httpx
from pathlib import Path
from typing import Optional, Dict
......@@ -223,7 +224,7 @@ class ClaudeAuth:
)
return response
def refresh_token(self, max_retries: int = 3) -> bool:
async def refresh_token(self, max_retries: int = 3) -> bool:
"""
Use the refresh token to get a new access token without logging in.
......@@ -270,7 +271,7 @@ class ClaudeAuth:
# Rate limited - wait and retry with exponential backoff
wait_time = (2 ** attempt) * 5 # 5, 10, 20 seconds
logger.warning(f"Rate limited (429). Waiting {wait_time} seconds before retry {attempt + 1}/{max_retries}")
time.sleep(wait_time)
await asyncio.sleep(wait_time)
continue
else:
logger.error(f"Token refresh failed: {response.status_code} - {response.text}")
......@@ -280,14 +281,14 @@ class ClaudeAuth:
if attempt < max_retries - 1:
wait_time = (2 ** attempt) * 5
logger.info(f"Retrying in {wait_time} seconds...")
time.sleep(wait_time)
await asyncio.sleep(wait_time)
continue
return False
logger.error(f"Token refresh failed after {max_retries} attempts")
return False
def get_valid_token(self, auto_login: bool = False) -> str:
async def get_valid_token(self, auto_login: bool = False) -> str:
"""
Get a valid access token, refreshing it if necessary.
......@@ -311,7 +312,7 @@ class ClaudeAuth:
# Refresh if less than 5 minutes remain
if time.time() > (self.tokens.get('expires_at', 0) - 300):
logger.info("Token expiring soon, refreshing...")
if not self.refresh_token():
if not await self.refresh_token():
if not auto_login:
logger.error("Token refresh failed and auto_login is disabled")
raise Exception("Claude token refresh failed. Please re-authenticate via /dashboard/claude/auth/start or MCP tool.")
......@@ -540,7 +541,7 @@ class ClaudeAuth:
logger.info("OAuth2 login flow completed successfully")
def exchange_code_for_tokens(self, code: str, state: str, verifier: str = None, max_retries: int = 3) -> bool:
async def exchange_code_for_tokens(self, code: str, state: str, verifier: str = None, max_retries: int = 3) -> bool:
"""
Exchange authorization code for access tokens.
Matches CLIProxyAPI implementation exactly.
......@@ -621,7 +622,7 @@ class ClaudeAuth:
# Rate limited - wait and retry with exponential backoff
wait_time = (2 ** attempt) * 5 # 5, 10, 20 seconds
logger.warning(f"Rate limited (429). Waiting {wait_time} seconds before retry {attempt + 1}/{max_retries}")
time.sleep(wait_time)
await asyncio.sleep(wait_time)
continue
else:
logger.error(f"Token exchange failed: {response.status_code} - {response.text}")
......@@ -631,7 +632,7 @@ class ClaudeAuth:
if attempt < max_retries - 1:
wait_time = (2 ** attempt) * 5
logger.info(f"Retrying in {wait_time} seconds...")
time.sleep(wait_time)
await asyncio.sleep(wait_time)
continue
return False
......@@ -652,14 +653,16 @@ class ClaudeAuth:
# Example usage
if __name__ == "__main__":
import asyncio
logging.basicConfig(level=logging.INFO)
async def main():
auth = ClaudeAuth()
token = auth.get_valid_token()
# Use the token for an API call
client = httpx.Client()
response = client.post(
async with httpx.AsyncClient() as client:
response = await client.post(
"https://api.anthropic.com/v1/messages",
headers={
"Authorization": f"Bearer {token}",
......@@ -674,3 +677,5 @@ if __name__ == "__main__":
}
)
print(response.json())
asyncio.run(main())
......@@ -152,13 +152,14 @@ class QwenOAuth2:
return code_verifier, code_challenge
def _acquire_lock(self, max_attempts: int = 20) -> bool:
async def _acquire_lock(self, max_attempts: int = 20) -> bool:
"""
Acquire a file lock to prevent concurrent token refreshes.
Returns:
True if lock acquired, False otherwise.
"""
import asyncio
lock_id = str(uuid.uuid4())
interval = 0.1
......@@ -182,7 +183,7 @@ class QwenOAuth2:
# Lock might have been removed by another process
continue
time.sleep(interval)
await asyncio.sleep(interval)
interval = min(interval * 1.5, 2.0) # Exponential backoff
return False
......@@ -461,7 +462,7 @@ class QwenOAuth2:
logger.info("QwenOAuth2: Refreshing access token...")
# Acquire lock to prevent concurrent refreshes
if not self._acquire_lock():
if not await self._acquire_lock():
logger.error("QwenOAuth2: Failed to acquire lock for token refresh")
return False
......
......@@ -534,7 +534,7 @@ class ContextManager:
"max_tokens": 1000,
"stream": False
}
response = await self._rotation_handler.handle_rotation_request(self._rotation_id, condensation_request)
response = await self._rotation_handler.handle_rotation_request(self._rotation_id, condensation_request, None, None)
if isinstance(response, dict):
summary_content = response.get('choices', [{}])[0].get('message', {}).get('content', '')
else:
......@@ -642,7 +642,7 @@ Provide only the relevant information in a concise format."""
"max_tokens": 2000,
"stream": False
}
response = await self._rotation_handler.handle_rotation_request(self._rotation_id, condensation_request)
response = await self._rotation_handler.handle_rotation_request(self._rotation_id, condensation_request, None, None)
if isinstance(response, dict):
pruned_content = response.get('choices', [{}])[0].get('message', {}).get('content', '')
else:
......
"""
Copyleft (C) 2026 Stefy Lanza <stefy@nexlab.net>
AISBF - AI Service Broker Framework || AI Should Be Free
Cost extraction utilities for provider responses.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import logging
from typing import Dict, Optional, Any
logger = logging.getLogger(__name__)
def extract_cost_from_response(response: Dict[str, Any], provider_id: str) -> Optional[float]:
"""
Extract actual cost from provider response if available.
Args:
response: Provider response dictionary
provider_id: Provider identifier
Returns:
Cost in USD if found, None otherwise
"""
if not response or not isinstance(response, dict):
return None
try:
# AWS Bedrock - may include cost in usage
if provider_id in ['amazon', 'bedrock', 'aws']:
usage = response.get('usage', {})
if isinstance(usage, dict):
cost = usage.get('cost')
if cost is not None:
return float(cost)
# Cohere - has billed_units but not direct cost
# Would need pricing config to convert
if provider_id == 'cohere':
meta = response.get('meta', {})
if isinstance(meta, dict):
billed_units = meta.get('billed_units', {})
if billed_units:
# Return None - we'll calculate from tokens
# Could enhance this to calculate from billed_units
pass
# Replicate - has prediction time
if provider_id == 'replicate':
metrics = response.get('metrics', {})
if isinstance(metrics, dict):
predict_time = metrics.get('predict_time')
if predict_time:
# Would need pricing per second to calculate
# Return None for now - calculate from tokens
pass
# Check for generic cost fields that some providers might use
for cost_field in ['cost', 'price', 'amount', 'total_cost']:
if cost_field in response:
cost = response[cost_field]
if cost is not None:
return float(cost)
# Check in usage object
usage = response.get('usage', {})
if isinstance(usage, dict) and cost_field in usage:
cost = usage[cost_field]
if cost is not None:
return float(cost)
return None
except Exception as e:
logger.debug(f"Error extracting cost from {provider_id} response: {e}")
return None
def extract_cost_from_streaming_chunk(chunk: Dict[str, Any], provider_id: str) -> Optional[float]:
"""
Extract cost from streaming response chunk if available.
Most providers don't include cost in streaming chunks, but some might
include it in the final chunk.
Args:
chunk: Streaming chunk dictionary
provider_id: Provider identifier
Returns:
Cost in USD if found, None otherwise
"""
if not chunk or not isinstance(chunk, dict):
return None
try:
# Check if this is a final chunk with usage/cost info
usage = chunk.get('usage', {})
if isinstance(usage, dict):
# Try to extract cost from usage
cost = usage.get('cost')
if cost is not None:
return float(cost)
# Some providers might include cost at top level in final chunk
cost = chunk.get('cost')
if cost is not None:
return float(cost)
return None
except Exception as e:
logger.debug(f"Error extracting cost from {provider_id} streaming chunk: {e}")
return None
......@@ -27,6 +27,8 @@ from pathlib import Path
from typing import Dict, List, Optional, Tuple, Any
from datetime import datetime, timedelta
import logging
import asyncio
from concurrent.futures import ThreadPoolExecutor
try:
import mysql.connector as _mysql_connector
......@@ -37,12 +39,23 @@ except ImportError:
logger = logging.getLogger(__name__)
# Global thread pool executor for database operations
_db_executor = None
def get_db_executor():
"""Get or create the global database thread pool executor."""
global _db_executor
if _db_executor is None:
_db_executor = ThreadPoolExecutor(max_workers=10, thread_name_prefix="db_worker")
return _db_executor
class DatabaseManager:
"""
Manages database for persistent tracking of context dimensions and rate limiting.
Supports both SQLite and MySQL databases.
All database operations are non-blocking using asyncio and thread pool executors.
"""
def __init__(self, db_config: Optional[Dict[str, Any]] = None):
......@@ -69,7 +82,7 @@ class DatabaseManager:
self.db_config = db_config
self.db_type = self.db_config.get('type', 'sqlite').lower()
self.executor = get_db_executor()
if self.db_type == 'mysql':
if not MYSQL_AVAILABLE:
......@@ -99,7 +112,58 @@ class DatabaseManager:
else:
raise ValueError(f"Unsupported database type: {self.db_type}")
async def _run_in_executor(self, func, *args):
"""Run a blocking database operation in a thread pool executor."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(self.executor, func, *args)
async def record_context_dimension_async(
self,
provider_id: str,
model_name: str,
context_size: Optional[int] = None,
condense_context: Optional[int] = None,
condense_method: Optional[str] = None
):
"""
Record or update context dimension configuration for a model (async version).
Args:
provider_id: The provider identifier
model_name: The model name
context_size: Maximum context size in tokens
condense_context: Percentage (0-100) at which to trigger condensation
condense_method: Condensation method(s) as string or list
"""
def _sync_operation():
with self._get_connection() as conn:
cursor = conn.cursor()
# Convert condense_method to JSON string if it's a list
condense_method_str = json.dumps(condense_method) if isinstance(condense_method, list) else condense_method
if self.db_type == 'sqlite':
cursor.execute('''
INSERT OR REPLACE INTO context_dimensions
(provider_id, model_name, context_size, condense_context, condense_method, last_updated)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
''', (provider_id, model_name, context_size, condense_context, condense_method_str))
else: # mysql
cursor.execute('''
INSERT INTO context_dimensions
(provider_id, model_name, context_size, condense_context, condense_method, last_updated)
VALUES (%s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
context_size=VALUES(context_size), condense_context=VALUES(condense_context),
condense_method=VALUES(condense_method), last_updated=CURRENT_TIMESTAMP
''', (provider_id, model_name, context_size, condense_context, condense_method_str))
conn.commit()
logger.debug(f"Recorded context dimension for {provider_id}/{model_name}")
await self._run_in_executor(_sync_operation)
def record_context_dimension(
self,
......@@ -217,27 +281,44 @@ class DatabaseManager:
provider_id: str,
model_name: str,
tokens_used: int,
user_id: Optional[int] = None
user_id: Optional[int] = None,
success: bool = True,
latency_ms: float = 0,
error_type: Optional[str] = None,
token_id: Optional[int] = None,
prompt_tokens: Optional[int] = None,
completion_tokens: Optional[int] = None,
actual_cost: Optional[float] = None
):
"""
Record token usage for rate limiting.
Record token usage for rate limiting and analytics.
Args:
provider_id: The provider identifier
model_name: The model name
tokens_used: Number of tokens used in the request
tokens_used: Number of tokens used in the request (total)
user_id: Optional user ID for user-specific tracking
success: Whether the request was successful
latency_ms: Request latency in milliseconds (float)
error_type: Optional error type if request failed
token_id: Optional API token ID used for the request
prompt_tokens: Optional number of input/prompt tokens
completion_tokens: Optional number of output/completion tokens
actual_cost: Optional actual cost returned by provider (in USD)
"""
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
# Convert latency to int for storage
latency_int = int(latency_ms) if latency_ms else 0
logger.info(f"DB.record_token_usage: provider={provider_id}, latency_ms={latency_ms} -> latency_int={latency_int}, success={success}, prompt={prompt_tokens}, completion={completion_tokens}, cost={actual_cost}")
cursor.execute(f'''
INSERT INTO token_usage (user_id, provider_id, model_name, tokens_used, timestamp)
VALUES ({placeholder}, {placeholder}, {placeholder}, {placeholder}, CURRENT_TIMESTAMP)
''', (user_id, provider_id, model_name, tokens_used))
INSERT INTO token_usage (user_id, provider_id, model_name, tokens_used, prompt_tokens, completion_tokens, actual_cost, success, latency_ms, error_type, token_id, timestamp)
VALUES ({placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, CURRENT_TIMESTAMP)
''', (user_id, provider_id, model_name, tokens_used, prompt_tokens, completion_tokens, actual_cost, success, latency_int, error_type, token_id))
conn.commit()
logger.debug(f"Recorded token usage for {provider_id}/{model_name}: {tokens_used} (user_id={user_id})")
logger.debug(f"Recorded token usage for {provider_id}/{model_name}: {tokens_used} (prompt={prompt_tokens}, completion={completion_tokens}, cost={actual_cost}, user_id={user_id}, success={success}, latency={latency_int}ms)")
def get_token_usage(
self,
......@@ -2849,20 +2930,20 @@ def DatabaseManager__initialize_database(self):
if self.database_type == DatabaseRegistry.TYPE_CONFIG:
# ONLY CREATE CONFIG TABLES IN CONFIG DATABASE
# Create context_dimensions table for tracking context usage
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS context_dimensions (
id INTEGER PRIMARY KEY {auto_increment},
provider_id VARCHAR(255) NOT NULL,
model_name VARCHAR(255) NOT NULL,
context_size INTEGER,
condense_context INTEGER,
condense_method TEXT,
effective_context INTEGER DEFAULT 0,
last_updated TIMESTAMP DEFAULT {timestamp_default},
UNIQUE(provider_id, model_name)
)
''')
# cursor.execute(f'''
# CREATE TABLE IF NOT EXISTS context_dimensions (
# id INTEGER PRIMARY KEY {auto_increment},
# provider_id VARCHAR(255) NOT NULL,
# model_name VARCHAR(255) NOT NULL,
# context_size INTEGER,
# condense_context INTEGER,
# condense_method TEXT,
# effective_context INTEGER DEFAULT 0,
# last_updated TIMESTAMP DEFAULT {timestamp_default},
# UNIQUE(provider_id, model_name)
# )
# ''')
#
# Create token_usage table for tracking rate limiting
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS token_usage (
......@@ -2871,219 +2952,554 @@ def DatabaseManager__initialize_database(self):
provider_id VARCHAR(255) NOT NULL,
model_name VARCHAR(255) NOT NULL,
tokens_used INTEGER NOT NULL,
prompt_tokens INTEGER,
completion_tokens INTEGER,
actual_cost DECIMAL(10,6),
timestamp TIMESTAMP DEFAULT {timestamp_default}
)
''')
#
#
#
# Create indexes for better query performance
try:
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_context_provider_model
ON context_dimensions(provider_id, model_name)
''')
except:
pass # Index might already exist
try:
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_token_provider_model
ON token_usage(provider_id, model_name)
''')
except:
pass
try:
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_token_timestamp
ON token_usage(timestamp)
''')
except:
pass
# try:
# cursor.execute('''
# CREATE INDEX IF NOT EXISTS idx_context_provider_model
# ON context_dimensions(provider_id, model_name)
# ''')
# except:
# pass # Index might already exist
#
# try:
# cursor.execute('''
# CREATE INDEX IF NOT EXISTS idx_token_provider_model
# ON token_usage(provider_id, model_name)
# ''')
# except:
# pass
#
# try:
# cursor.execute('''
# CREATE INDEX IF NOT EXISTS idx_token_timestamp
# ON token_usage(timestamp)
# ''')
# except:
# pass
#
# Create model_embeddings table for caching vectorized model descriptions
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS model_embeddings (
id INTEGER PRIMARY KEY {auto_increment},
provider_id VARCHAR(255) NOT NULL,
model_name VARCHAR(255) NOT NULL,
description TEXT,
embedding TEXT,
last_updated TIMESTAMP DEFAULT {timestamp_default},
UNIQUE(provider_id, model_name)
)
''')
try:
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_model_embeddings_provider_model
ON model_embeddings(provider_id, model_name)
''')
except:
pass
# cursor.execute(f'''
# CREATE TABLE IF NOT EXISTS model_embeddings (
# id INTEGER PRIMARY KEY {auto_increment},
# provider_id VARCHAR(255) NOT NULL,
# model_name VARCHAR(255) NOT NULL,
# description TEXT,
# embedding TEXT,
# last_updated TIMESTAMP DEFAULT {timestamp_default},
# UNIQUE(provider_id, model_name)
# )
# ''')
#
# try:
# cursor.execute('''
# CREATE INDEX IF NOT EXISTS idx_model_embeddings_provider_model
# ON model_embeddings(provider_id, model_name)
# ''')
# except:
# pass
#
# Create users table for multi-user management
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY {auto_increment},
username VARCHAR(255) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE,
display_name VARCHAR(255),
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(50) DEFAULT 'user',
created_by VARCHAR(255),
created_at TIMESTAMP DEFAULT {timestamp_default},
last_login TIMESTAMP NULL,
is_active {boolean_type} DEFAULT 1,
email_verified {boolean_type} DEFAULT 0,
verification_token VARCHAR(255),
verification_token_expires TIMESTAMP NULL,
last_verification_email_sent TIMESTAMP NULL
)
''')
# cursor.execute(f'''
# CREATE TABLE IF NOT EXISTS users (
# id INTEGER PRIMARY KEY {auto_increment},
# username VARCHAR(255) UNIQUE NOT NULL,
# email VARCHAR(255) UNIQUE,
# display_name VARCHAR(255),
# password_hash VARCHAR(255) NOT NULL,
# role VARCHAR(50) DEFAULT 'user',
# created_by VARCHAR(255),
# created_at TIMESTAMP DEFAULT {timestamp_default},
# last_login TIMESTAMP NULL,
# is_active {boolean_type} DEFAULT 1,
# email_verified {boolean_type} DEFAULT 0,
# verification_token VARCHAR(255),
# verification_token_expires TIMESTAMP NULL,
# last_verification_email_sent TIMESTAMP NULL
# )
# ''')
#
# Migration: Add display_name column if it doesn't exist
try:
# try:
# Check if display_name column exists
if self.db_type == 'sqlite':
cursor.execute("PRAGMA table_info(users)")
columns = [row[1] for row in cursor.fetchall()]
else:
cursor.execute("""
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'users'
""")
columns = [row[0] for row in cursor.fetchall()]
if 'display_name' not in columns:
logger.info("Adding display_name column to users table")
cursor.execute("ALTER TABLE users ADD COLUMN display_name VARCHAR(255)")
conn.commit()
# if self.db_type == 'sqlite':
# cursor.execute("PRAGMA table_info(users)")
# columns = [row[1] for row in cursor.fetchall()]
# else:
# cursor.execute("""
# SELECT COLUMN_NAME
# FROM INFORMATION_SCHEMA.COLUMNS
# WHERE TABLE_NAME = 'users'
# """)
# columns = [row[0] for row in cursor.fetchall()]
#
# if 'display_name' not in columns:
# logger.info("Adding display_name column to users table")
# cursor.execute("ALTER TABLE users ADD COLUMN display_name VARCHAR(255)")
# conn.commit()
#
# Populate display_name for existing users
cursor.execute("UPDATE users SET display_name = username WHERE display_name IS NULL")
conn.commit()
logger.info("Migration complete: display_name column added and populated")
except Exception as e:
logger.warning(f"Migration warning (display_name): {e}")
# Continue even if migration fails - column might already exist
# User-specific configuration tables for multi-user isolation
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS user_providers (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL,
provider_id VARCHAR(255) NOT NULL,
config TEXT NOT NULL,
created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(user_id, provider_id)
)
''')
# cursor.execute("UPDATE users SET display_name = username WHERE display_name IS NULL")
# conn.commit()
# logger.info("Migration complete: display_name column added and populated")
# except Exception as e:
# logger.warning(f"Migration warning (display_name): {e}")
#
# User-specific configuration tables for multi-user isolation - commented out to fix import
# cursor.execute(f'''
# CREATE TABLE IF NOT EXISTS user_providers (
# id INTEGER PRIMARY KEY {auto_increment},
# user_id INTEGER NOT NULL,
# provider_id VARCHAR(255) NOT NULL,
# config TEXT NOT NULL,
# created_at TIMESTAMP DEFAULT {timestamp_default},
# updated_at TIMESTAMP DEFAULT {timestamp_default},
# FOREIGN KEY (user_id) REFERENCES users(id),
# UNIQUE(user_id, provider_id)
# )
# ''')
#
# cursor.execute(f'''
# CREATE TABLE IF NOT EXISTS user_rotations (
# id INTEGER PRIMARY KEY {auto_increment},
# user_id INTEGER NOT NULL,
# rotation_id VARCHAR(255) NOT NULL,
# config TEXT NOT NULL,
# created_at TIMESTAMP DEFAULT {timestamp_default},
# updated_at TIMESTAMP DEFAULT {timestamp_default},
# FOREIGN KEY (user_id) REFERENCES users(id),
# UNIQUE(user_id, rotation_id)
# )
# ''')
#
# cursor.execute(f'''
# CREATE TABLE IF NOT EXISTS user_autoselects (
# id INTEGER PRIMARY KEY {auto_increment},
# user_id INTEGER NOT NULL,
# autoselect_id VARCHAR(255) NOT NULL,
# config TEXT NOT NULL,
# created_at TIMESTAMP DEFAULT {timestamp_default},
# updated_at TIMESTAMP DEFAULT {timestamp_default},
# FOREIGN KEY (user_id) REFERENCES users(id),
# UNIQUE(user_id, autoselect_id)
# )
# ''')
#
# cursor.execute(f'''
# CREATE TABLE IF NOT EXISTS user_prompts (
# id INTEGER PRIMARY KEY {auto_increment},
# user_id INTEGER NOT NULL,
# prompt_key VARCHAR(255) NOT NULL,
# content TEXT NOT NULL,
# created_at TIMESTAMP DEFAULT {timestamp_default},
# updated_at TIMESTAMP DEFAULT {timestamp_default},
# FOREIGN KEY (user_id) REFERENCES users(id),
# UNIQUE(user_id, prompt_key)
# )
# ''')
#
# cursor.execute(f'''
# CREATE TABLE IF NOT EXISTS user_api_tokens (
# id INTEGER PRIMARY KEY {auto_increment},
# user_id INTEGER NOT NULL,
# token VARCHAR(255) UNIQUE NOT NULL,
# description TEXT,
# created_at TIMESTAMP DEFAULT {timestamp_default},
# last_used TIMESTAMP NULL,
# is_active {boolean_type} DEFAULT 1,
# FOREIGN KEY (user_id) REFERENCES users(id)
# )
# ''')
#
# cursor.execute(f'''
# CREATE TABLE IF NOT EXISTS user_token_usage (
# id INTEGER PRIMARY KEY {auto_increment},
# user_id INTEGER NOT NULL,
# token_id INTEGER,
# provider_id VARCHAR(255) NOT NULL,
# model_name VARCHAR(255) NOT NULL,
# tokens_used INTEGER NOT NULL,
# timestamp TIMESTAMP DEFAULT {timestamp_default},
# FOREIGN KEY (user_id) REFERENCES users(id),
# FOREIGN KEY (token_id) REFERENCES user_api_tokens(id)
# )
# ''')
#
# Create user_auth_files table for storing authentication file metadata
# cursor.execute(f'''
# CREATE TABLE IF NOT EXISTS user_auth_files (
# id INTEGER PRIMARY KEY {auto_increment},
# user_id INTEGER NOT NULL,
# provider_id VARCHAR(255) NOT NULL,
# file_type VARCHAR(50) NOT NULL,
# original_filename VARCHAR(255) NOT NULL,
# stored_filename VARCHAR(255) NOT NULL,
# file_path TEXT NOT NULL,
# file_size INTEGER,
# mime_type VARCHAR(100),
# created_at TIMESTAMP DEFAULT {timestamp_default},
# updated_at TIMESTAMP DEFAULT {timestamp_default},
# FOREIGN KEY (user_id) REFERENCES users(id),
# UNIQUE(user_id, provider_id, file_type)
# )
# ''')
#
# Create user_oauth2_credentials table for storing OAuth2 tokens per user/provider
# cursor.execute(f'''
# CREATE TABLE IF NOT EXISTS user_oauth2_credentials (
# id INTEGER PRIMARY KEY {auto_increment},
# user_id INTEGER NOT NULL,
# provider_id VARCHAR(255) NOT NULL,
# auth_type VARCHAR(50) NOT NULL,
# credentials TEXT NOT NULL,
# created_at TIMESTAMP DEFAULT {timestamp_default},
# updated_at TIMESTAMP DEFAULT {timestamp_default},
# FOREIGN KEY (user_id) REFERENCES users(id),
# UNIQUE(user_id, provider_id, auth_type)
# )
# ''')
#
# ==============================================
# UNIVERSAL MIGRATIONS - RUN ON EVERY STARTUP
# ==============================================
# logger.info("Running database migrations...")
#
# Migration: Create account_tiers table if missing
# try:
# if self.db_type == 'sqlite':
# cursor.execute("PRAGMA table_info(account_tiers)")
# if not cursor.fetchall():
# cursor.execute(f'''
# CREATE TABLE account_tiers (
# id INTEGER PRIMARY KEY {auto_increment},
# name VARCHAR(255) UNIQUE NOT NULL,
# description TEXT,
# price_monthly DECIMAL(10,2) DEFAULT 0.00,
# price_yearly DECIMAL(10,2) DEFAULT 0.00,
# is_default {boolean_type} DEFAULT 0,
# is_active {boolean_type} DEFAULT 1,
# max_requests_per_day INTEGER DEFAULT -1,
# max_requests_per_month INTEGER DEFAULT -1,
# max_providers INTEGER DEFAULT -1,
# max_rotations INTEGER DEFAULT -1,
# max_autoselections INTEGER DEFAULT -1,
# max_rotation_models INTEGER DEFAULT -1,
# max_autoselection_models INTEGER DEFAULT -1,
# created_at TIMESTAMP DEFAULT {timestamp_default},
# updated_at TIMESTAMP DEFAULT {timestamp_default}
# )
# ''')
# conn.commit()
# logger.info("✅ Migration: Created missing account_tiers table")
# except Exception as e:
# logger.warning(f"Migration check for account_tiers table: {e}")
#
# Migration: Add missing columns to account_tiers
# try:
# if self.db_type == 'sqlite':
# cursor.execute("PRAGMA table_info(account_tiers)")
# existing_columns = [row[1] for row in cursor.fetchall()]
# tier_columns = [
# ('max_requests_per_day', 'INTEGER DEFAULT -1'),
# ('max_requests_per_month', 'INTEGER DEFAULT -1'),
# ('max_providers', 'INTEGER DEFAULT -1'),
# ('max_rotations', 'INTEGER DEFAULT -1'),
# ('max_autoselections', 'INTEGER DEFAULT -1'),
# ('max_rotation_models', 'INTEGER DEFAULT -1'),
# ('max_autoselection_models', 'INTEGER DEFAULT -1'),
# ('is_default', f'{boolean_type} DEFAULT 0'),
# ('is_active', f'{boolean_type} DEFAULT 1'),
# ('is_visible', f'{boolean_type} DEFAULT 1')
# ]
# col_count = 0
# for col_name, col_def in tier_columns:
# if col_name not in existing_columns:
# cursor.execute(f'ALTER TABLE account_tiers ADD COLUMN {col_name} {col_def}')
# col_count += 1
# if col_count > 0:
# logger.info(f"✅ Migration: Added {col_count} missing columns to account_tiers")
# else:
# MySQL/MariaDB
# cursor.execute("""
# SELECT COLUMN_NAME
# FROM INFORMATION_SCHEMA.COLUMNS
# WHERE TABLE_NAME = 'account_tiers'
# AND TABLE_SCHEMA = DATABASE()
# """)
# existing_columns = [row[0] for row in cursor.fetchall()]
# tier_columns = [
# ('max_requests_per_day', 'INTEGER DEFAULT -1'),
# ('max_requests_per_month', 'INTEGER DEFAULT -1'),
# ('max_providers', 'INTEGER DEFAULT -1'),
# ('max_rotations', 'INTEGER DEFAULT -1'),
# ('max_autoselections', 'INTEGER DEFAULT -1'),
# ('max_rotation_models', 'INTEGER DEFAULT -1'),
# ('max_autoselection_models', 'INTEGER DEFAULT -1'),
# ('is_default', f'{boolean_type} DEFAULT 0'),
# ('is_active', f'{boolean_type} DEFAULT 1'),
# ('is_visible', f'{boolean_type} DEFAULT 1')
# ]
# col_count = 0
# for col_name, col_def in tier_columns:
# if col_name not in existing_columns:
# cursor.execute(f'ALTER TABLE account_tiers ADD COLUMN {col_name} {col_def}')
# col_count += 1
# if col_count > 0:
# conn.commit()
# logger.info(f"✅ Migration: Added {col_count} missing columns to account_tiers")
# except Exception as e:
# logger.warning(f"Migration check for account_tiers columns: {e}")
#
# Migration: Ensure default free tier exists
# try:
# cursor.execute(f'SELECT COUNT(*) FROM account_tiers WHERE is_default = 1')
# free_tier_count = cursor.fetchone()[0]
# if free_tier_count == 0:
# cursor.execute(f'''
# INSERT INTO account_tiers
# (name, description, price_monthly, price_yearly, is_default, is_active,
# max_requests_per_day, max_requests_per_month, max_providers, max_rotations,
# max_autoselections, max_rotation_models, max_autoselection_models)
# VALUES
# ('Free Tier', 'Default free account tier with unlimited access', 0.00, 0.00, 1, 1,
# -1, -1, -1, -1, -1, -1, -1)
# ''')
# logger.info("✅ Migration: Inserted default free tier")
# except Exception as e:
# logger.warning(f"Migration check for default free tier: {e}")
#
# Migration: Add tier_id column to users table
# try:
# if self.db_type == 'sqlite':
# cursor.execute("PRAGMA table_info(users)")
# columns = [row[1] for row in cursor.fetchall()]
# if 'tier_id' not in columns:
# cursor.execute('ALTER TABLE users ADD COLUMN tier_id INTEGER DEFAULT 1')
# cursor.execute('ALTER TABLE users ADD COLUMN subscription_expires TIMESTAMP NULL')
# logger.info("✅ Migration: Added tier_id and subscription_expires columns to users")
# else:
# cursor.execute("""
# SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
# WHERE TABLE_NAME = 'users' AND COLUMN_NAME = 'tier_id'
# """)
# if not cursor.fetchone():
# cursor.execute('ALTER TABLE users ADD COLUMN tier_id INTEGER DEFAULT 1')
# cursor.execute('ALTER TABLE users ADD COLUMN subscription_expires TIMESTAMP NULL')
# logger.info("✅ Migration: Added tier_id and subscription_expires columns to users")
# except Exception as e:
# logger.warning(f"Migration check for users.tier_id: {e}")
#
# Migration: Add password reset token columns to users table
# try:
# if self.db_type == 'sqlite':
# cursor.execute("PRAGMA table_info(users)")
# columns = [row[1] for row in cursor.fetchall()]
# if 'reset_password_token' not in columns:
# cursor.execute('ALTER TABLE users ADD COLUMN reset_password_token VARCHAR(255)')
# cursor.execute('ALTER TABLE users ADD COLUMN reset_password_token_expires TIMESTAMP NULL')
# logger.info("✅ Migration: Added password reset token columns to users")
# else:
# cursor.execute("""
# SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
# WHERE TABLE_NAME = 'users' AND COLUMN_NAME = 'reset_password_token'
# """)
# if not cursor.fetchone():
# cursor.execute('ALTER TABLE users ADD COLUMN reset_password_token VARCHAR(255)')
# cursor.execute('ALTER TABLE users ADD COLUMN reset_password_token_expires TIMESTAMP NULL')
# logger.info("✅ Migration: Added password reset token columns to users")
# except Exception as e:
# logger.warning(f"Migration check for users.reset_password_token: {e}")
#
# Migration: Add last_verification_email_sent column to users table
# try:
# if self.db_type == 'sqlite':
# cursor.execute("PRAGMA table_info(users)")
# columns = [row[1] for row in cursor.fetchall()]
# if 'last_verification_email_sent' not in columns:
# cursor.execute('ALTER TABLE users ADD COLUMN last_verification_email_sent TIMESTAMP NULL')
# logger.info("✅ Migration: Added last_verification_email_sent column to users")
# else:
# cursor.execute("""
# SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
# WHERE TABLE_NAME = 'users' AND COLUMN_NAME = 'last_verification_email_sent'
# """)
# if not cursor.fetchone():
# cursor.execute('ALTER TABLE users ADD COLUMN last_verification_email_sent TIMESTAMP NULL')
# logger.info("✅ Migration: Added last_verification_email_sent column to users")
# except Exception as e:
# logger.warning(f"Migration check for users.last_verification_email_sent: {e}")
#
# Migration: Create payment_methods, user_subscriptions, payment_transactions tables
# for table_name, create_sql in [
# ('payment_methods', f'''
# CREATE TABLE payment_methods (
# id INTEGER PRIMARY KEY {auto_increment},
# user_id INTEGER NOT NULL,
# type VARCHAR(50) NOT NULL,
# identifier VARCHAR(255) NOT NULL,
# is_default {boolean_type} DEFAULT 0,
# is_active {boolean_type} DEFAULT 1,
# metadata TEXT,
# created_at TIMESTAMP DEFAULT {timestamp_default},
# updated_at TIMESTAMP DEFAULT {timestamp_default},
# FOREIGN KEY (user_id) REFERENCES users(id)
# )
# '''),
# ('admin_settings', f'''
# CREATE TABLE admin_settings (
# id INTEGER PRIMARY KEY {auto_increment},
# setting_key VARCHAR(255) UNIQUE NOT NULL,
# setting_value TEXT,
# updated_at TIMESTAMP DEFAULT {timestamp_default}
# )
# '''),
# ('user_subscriptions', f'''
# CREATE TABLE user_subscriptions (
# id INTEGER PRIMARY KEY {auto_increment},
# user_id INTEGER NOT NULL,
# tier_id INTEGER NOT NULL,
# status VARCHAR(50) DEFAULT 'active',
# start_date TIMESTAMP DEFAULT {timestamp_default},
# end_date TIMESTAMP NULL,
# next_billing_date TIMESTAMP NULL,
# trial_end_date TIMESTAMP NULL,
# payment_method_id INTEGER,
# auto_renew {boolean_type} DEFAULT 1,
# created_at TIMESTAMP DEFAULT {timestamp_default},
# updated_at TIMESTAMP DEFAULT {timestamp_default},
# FOREIGN KEY (user_id) REFERENCES users(id),
# FOREIGN KEY (tier_id) REFERENCES account_tiers(id),
# FOREIGN KEY (payment_method_id) REFERENCES payment_methods(id),
# UNIQUE(user_id, tier_id)
# )
# '''),
# ('payment_transactions', f'''
# CREATE TABLE payment_transactions (
# id INTEGER PRIMARY KEY {auto_increment},
# user_id INTEGER NOT NULL,
# tier_id INTEGER,
# subscription_id INTEGER,
# payment_method_id INTEGER,
# amount DECIMAL(10,2) NOT NULL,
# currency VARCHAR(10) DEFAULT 'USD',
# status VARCHAR(50) NOT NULL,
# transaction_type VARCHAR(50) NOT NULL,
# external_transaction_id VARCHAR(255),
# metadata TEXT,
# created_at TIMESTAMP DEFAULT {timestamp_default},
# completed_at TIMESTAMP NULL,
# FOREIGN KEY (user_id) REFERENCES users(id),
# FOREIGN KEY (tier_id) REFERENCES account_tiers(id),
# FOREIGN KEY (subscription_id) REFERENCES user_subscriptions(id),
# FOREIGN KEY (payment_method_id) REFERENCES payment_methods(id)
# )
# ''')
# ]:
# try:
# if self.db_type == 'sqlite':
# cursor.execute(f"PRAGMA table_info({table_name})")
# if not cursor.fetchall():
# cursor.execute(create_sql)
# logger.info(f"✅ Migration: Created missing {table_name} table")
# else:
# cursor.execute(f"""
# SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES
# WHERE TABLE_NAME = '{table_name}'
# """)
# if not cursor.fetchone():
# cursor.execute(create_sql)
# logger.info(f"✅ Migration: Created missing {table_name} table")
# except Exception as e:
# logger.warning(f"Migration check for {table_name} table: {e}")
#
# conn.commit()
# logger.info("✅ All database migrations completed")
#
# else:
# CACHE DATABASE GETS MINIMAL TABLES ONLY
# cursor.execute(f'''
# CREATE TABLE IF NOT EXISTS token_usage (
# id INTEGER PRIMARY KEY {auto_increment},
# user_id INTEGER,
# provider_id VARCHAR(255) NOT NULL,
# model_name VARCHAR(255) NOT NULL,
# tokens_used INTEGER NOT NULL,
# timestamp TIMESTAMP DEFAULT {timestamp_default}
# )
# ''')
#
# cursor.execute(f'''
# CREATE TABLE IF NOT EXISTS context_dimensions (
# id INTEGER PRIMARY KEY {auto_increment},
# provider_id VARCHAR(255) NOT NULL,
# model_name VARCHAR(255) NOT NULL,
# context_size INTEGER,
# condense_context INTEGER,
# condense_method TEXT,
# effective_context INTEGER DEFAULT 0,
# last_updated TIMESTAMP DEFAULT {timestamp_default},
# UNIQUE(provider_id, model_name)
# )
# ''')
#
# logger.info("⚠️ CACHE DATABASE: Only minimal cache tables created - NO USER TABLES")
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS user_rotations (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL,
rotation_id VARCHAR(255) NOT NULL,
config TEXT NOT NULL,
created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(user_id, rotation_id)
)
''')
conn.commit()
logger.info(f"Database tables initialized successfully for {self.database_type} database")
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS user_autoselects (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL,
autoselect_id VARCHAR(255) NOT NULL,
config TEXT NOT NULL,
created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(user_id, autoselect_id)
)
''')
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS user_prompts (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL,
prompt_key VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(user_id, prompt_key)
)
''')
def DatabaseManager__create_config_tables(self, cursor, auto_increment, timestamp_default, boolean_type):
"""Create all permanent configuration tables (CONFIG DB ONLY) - UNUSED METHOD"""
pass # Method disabled
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS user_api_tokens (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL,
token VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT {timestamp_default},
last_used TIMESTAMP NULL,
is_active {boolean_type} DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users(id)
)
''')
def DatabaseManager__create_cache_tables(self, cursor, auto_increment, timestamp_default, boolean_type):
"""Create only temporary cache tables (CACHE DB ONLY)"""
# Only minimal tracking tables for cache database
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS user_token_usage (
CREATE TABLE IF NOT EXISTS token_usage (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL,
token_id INTEGER,
user_id INTEGER,
provider_id VARCHAR(255) NOT NULL,
model_name VARCHAR(255) NOT NULL,
tokens_used INTEGER NOT NULL,
timestamp TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (token_id) REFERENCES user_api_tokens(id)
prompt_tokens INTEGER,
completion_tokens INTEGER,
actual_cost DECIMAL(10,6),
timestamp TIMESTAMP DEFAULT {timestamp_default}
)
''')
# Create user_auth_files table for storing authentication file metadata
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS user_auth_files (
CREATE TABLE IF NOT EXISTS context_dimensions (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL,
provider_id VARCHAR(255) NOT NULL,
file_type VARCHAR(50) NOT NULL,
original_filename VARCHAR(255) NOT NULL,
stored_filename VARCHAR(255) NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER,
mime_type VARCHAR(100),
created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(user_id, provider_id, file_type)
model_name VARCHAR(255) NOT NULL,
context_size INTEGER,
condense_context INTEGER,
condense_method TEXT,
effective_context INTEGER DEFAULT 0,
last_updated TIMESTAMP DEFAULT {timestamp_default},
UNIQUE(provider_id, model_name)
)
''')
# Create user_oauth2_credentials table for storing OAuth2 tokens per user/provider
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS user_oauth2_credentials (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL,
provider_id VARCHAR(255) NOT NULL,
auth_type VARCHAR(50) NOT NULL,
credentials TEXT NOT NULL,
created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(user_id, provider_id, auth_type)
)
''')
logger.info("⚠️ CACHE DATABASE: Only minimal cache tables created - NO USER TABLES")
def DatabaseManager__run_config_migrations(self, cursor, auto_increment, timestamp_default, boolean_type):
"""Run all configuration database migrations"""
# ==============================================
# UNIVERSAL MIGRATIONS - RUN ON EVERY STARTUP
# These run for ALL databases, new and existing
# ==============================================
logger.info("Running database migrations...")
......@@ -3114,6 +3530,34 @@ def DatabaseManager__initialize_database(self):
''')
conn.commit()
logger.info("✅ Migration: Created missing account_tiers table")
else:
cursor.execute("""
SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_NAME = 'account_tiers'
""")
if not cursor.fetchone():
cursor.execute(f'''
CREATE TABLE account_tiers (
id INTEGER PRIMARY KEY {auto_increment},
name VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
price_monthly DECIMAL(10,2) DEFAULT 0.00,
price_yearly DECIMAL(10,2) DEFAULT 0.00,
is_default {boolean_type} DEFAULT 0,
is_active {boolean_type} DEFAULT 1,
max_requests_per_day INTEGER DEFAULT -1,
max_requests_per_month INTEGER DEFAULT -1,
max_providers INTEGER DEFAULT -1,
max_rotations INTEGER DEFAULT -1,
max_autoselections INTEGER DEFAULT -1,
max_rotation_models INTEGER DEFAULT -1,
max_autoselection_models INTEGER DEFAULT -1,
created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default}
)
''')
conn.commit()
logger.info("✅ Migration: Created missing account_tiers table")
except Exception as e:
logger.warning(f"Migration check for account_tiers table: {e}")
......@@ -3131,8 +3575,7 @@ def DatabaseManager__initialize_database(self):
('max_rotation_models', 'INTEGER DEFAULT -1'),
('max_autoselection_models', 'INTEGER DEFAULT -1'),
('is_default', f'{boolean_type} DEFAULT 0'),
('is_active', f'{boolean_type} DEFAULT 1'),
('is_visible', f'{boolean_type} DEFAULT 1')
('is_active', f'{boolean_type} DEFAULT 1')
]
col_count = 0
for col_name, col_def in tier_columns:
......@@ -3141,35 +3584,6 @@ def DatabaseManager__initialize_database(self):
col_count += 1
if col_count > 0:
logger.info(f"✅ Migration: Added {col_count} missing columns to account_tiers")
else:
# MySQL/MariaDB
cursor.execute("""
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'account_tiers'
AND TABLE_SCHEMA = DATABASE()
""")
existing_columns = [row[0] for row in cursor.fetchall()]
tier_columns = [
('max_requests_per_day', 'INTEGER DEFAULT -1'),
('max_requests_per_month', 'INTEGER DEFAULT -1'),
('max_providers', 'INTEGER DEFAULT -1'),
('max_rotations', 'INTEGER DEFAULT -1'),
('max_autoselections', 'INTEGER DEFAULT -1'),
('max_rotation_models', 'INTEGER DEFAULT -1'),
('max_autoselection_models', 'INTEGER DEFAULT -1'),
('is_default', f'{boolean_type} DEFAULT 0'),
('is_active', f'{boolean_type} DEFAULT 1'),
('is_visible', f'{boolean_type} DEFAULT 1')
]
col_count = 0
for col_name, col_def in tier_columns:
if col_name not in existing_columns:
cursor.execute(f'ALTER TABLE account_tiers ADD COLUMN {col_name} {col_def}')
col_count += 1
if col_count > 0:
conn.commit()
logger.info(f"✅ Migration: Added {col_count} missing columns to account_tiers")
except Exception as e:
logger.warning(f"Migration check for account_tiers columns: {e}")
......@@ -3212,46 +3626,6 @@ def DatabaseManager__initialize_database(self):
except Exception as e:
logger.warning(f"Migration check for users.tier_id: {e}")
# Migration: Add password reset token columns to users table
try:
if self.db_type == 'sqlite':
cursor.execute("PRAGMA table_info(users)")
columns = [row[1] for row in cursor.fetchall()]
if 'reset_password_token' not in columns:
cursor.execute('ALTER TABLE users ADD COLUMN reset_password_token VARCHAR(255)')
cursor.execute('ALTER TABLE users ADD COLUMN reset_password_token_expires TIMESTAMP NULL')
logger.info("✅ Migration: Added password reset token columns to users")
else:
cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'users' AND COLUMN_NAME = 'reset_password_token'
""")
if not cursor.fetchone():
cursor.execute('ALTER TABLE users ADD COLUMN reset_password_token VARCHAR(255)')
cursor.execute('ALTER TABLE users ADD COLUMN reset_password_token_expires TIMESTAMP NULL')
logger.info("✅ Migration: Added password reset token columns to users")
except Exception as e:
logger.warning(f"Migration check for users.reset_password_token: {e}")
# Migration: Add last_verification_email_sent column to users table
try:
if self.db_type == 'sqlite':
cursor.execute("PRAGMA table_info(users)")
columns = [row[1] for row in cursor.fetchall()]
if 'last_verification_email_sent' not in columns:
cursor.execute('ALTER TABLE users ADD COLUMN last_verification_email_sent TIMESTAMP NULL')
logger.info("✅ Migration: Added last_verification_email_sent column to users")
else:
cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'users' AND COLUMN_NAME = 'last_verification_email_sent'
""")
if not cursor.fetchone():
cursor.execute('ALTER TABLE users ADD COLUMN last_verification_email_sent TIMESTAMP NULL')
logger.info("✅ Migration: Added last_verification_email_sent column to users")
except Exception as e:
logger.warning(f"Migration check for users.last_verification_email_sent: {e}")
# Migration: Create payment_methods, user_subscriptions, payment_transactions tables
for table_name, create_sql in [
('payment_methods', f'''
......@@ -3268,14 +3642,6 @@ def DatabaseManager__initialize_database(self):
FOREIGN KEY (user_id) REFERENCES users(id)
)
'''),
('admin_settings', f'''
CREATE TABLE admin_settings (
id INTEGER PRIMARY KEY {auto_increment},
setting_key VARCHAR(255) UNIQUE NOT NULL,
setting_value TEXT,
updated_at TIMESTAMP DEFAULT {timestamp_default}
)
'''),
('user_subscriptions', f'''
CREATE TABLE user_subscriptions (
id INTEGER PRIMARY KEY {auto_increment},
......@@ -3335,494 +3701,51 @@ def DatabaseManager__initialize_database(self):
except Exception as e:
logger.warning(f"Migration check for {table_name} table: {e}")
conn.commit()
logger.info("✅ All database migrations completed")
else:
# CACHE DATABASE GETS MINIMAL TABLES ONLY
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS token_usage (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER,
provider_id VARCHAR(255) NOT NULL,
model_name VARCHAR(255) NOT NULL,
tokens_used INTEGER NOT NULL,
timestamp TIMESTAMP DEFAULT {timestamp_default}
)
''')
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS context_dimensions (
id INTEGER PRIMARY KEY {auto_increment},
provider_id VARCHAR(255) NOT NULL,
model_name VARCHAR(255) NOT NULL,
context_size INTEGER,
condense_context INTEGER,
condense_method TEXT,
effective_context INTEGER DEFAULT 0,
last_updated TIMESTAMP DEFAULT {timestamp_default},
UNIQUE(provider_id, model_name)
)
''')
logger.info("⚠️ CACHE DATABASE: Only minimal cache tables created - NO USER TABLES")
conn.commit()
logger.info(f"Database tables initialized successfully for {self.database_type} database")
def DatabaseManager__create_config_tables(self, cursor, auto_increment, timestamp_default, boolean_type):
"""Create all permanent configuration tables (CONFIG DB ONLY)"""
# Create context_dimensions table for tracking context usage
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS context_dimensions (
id INTEGER PRIMARY KEY {auto_increment},
provider_id VARCHAR(255) NOT NULL,
model_name VARCHAR(255) NOT NULL,
context_size INTEGER,
condense_context INTEGER,
condense_method TEXT,
effective_context INTEGER DEFAULT 0,
last_updated TIMESTAMP DEFAULT {timestamp_default},
UNIQUE(provider_id, model_name)
)
''')
# Create token_usage table for tracking rate limiting
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS token_usage (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER,
provider_id VARCHAR(255) NOT NULL,
model_name VARCHAR(255) NOT NULL,
tokens_used INTEGER NOT NULL,
timestamp TIMESTAMP DEFAULT {timestamp_default}
)
''')
# Create indexes for better query performance
try:
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_context_provider_model
ON context_dimensions(provider_id, model_name)
''')
except:
pass # Index might already exist
try:
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_token_provider_model
ON token_usage(provider_id, model_name)
''')
except:
pass
try:
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_token_timestamp
ON token_usage(timestamp)
''')
except:
pass
# Create model_embeddings table for caching vectorized model descriptions
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS model_embeddings (
id INTEGER PRIMARY KEY {auto_increment},
provider_id VARCHAR(255) NOT NULL,
model_name VARCHAR(255) NOT NULL,
description TEXT,
embedding TEXT,
last_updated TIMESTAMP DEFAULT {timestamp_default},
UNIQUE(provider_id, model_name)
)
''')
try:
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_model_embeddings_provider_model
ON model_embeddings(provider_id, model_name)
''')
except:
pass
# Create users table for multi-user management
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY {auto_increment},
username VARCHAR(255) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(50) DEFAULT 'user',
created_by VARCHAR(255),
created_at TIMESTAMP DEFAULT {timestamp_default},
last_login TIMESTAMP NULL,
is_active {boolean_type} DEFAULT 1,
email_verified {boolean_type} DEFAULT 0,
verification_token VARCHAR(255),
verification_token_expires TIMESTAMP NULL
)
''')
# User-specific configuration tables for multi-user isolation
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS user_providers (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL,
provider_id VARCHAR(255) NOT NULL,
config TEXT NOT NULL,
created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(user_id, provider_id)
)
''')
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS user_rotations (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL,
rotation_id VARCHAR(255) NOT NULL,
config TEXT NOT NULL,
created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(user_id, rotation_id)
)
''')
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS user_autoselects (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL,
autoselect_id VARCHAR(255) NOT NULL,
config TEXT NOT NULL,
created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(user_id, autoselect_id)
)
''')
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS user_prompts (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL,
prompt_key VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(user_id, prompt_key)
)
''')
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS user_api_tokens (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL,
token VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT {timestamp_default},
last_used TIMESTAMP NULL,
is_active {boolean_type} DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users(id)
)
''')
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS user_token_usage (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL,
token_id INTEGER,
provider_id VARCHAR(255) NOT NULL,
model_name VARCHAR(255) NOT NULL,
tokens_used INTEGER NOT NULL,
timestamp TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (token_id) REFERENCES user_api_tokens(id)
)
''')
# Create user_auth_files table for storing authentication file metadata
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS user_auth_files (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL,
provider_id VARCHAR(255) NOT NULL,
file_type VARCHAR(50) NOT NULL,
original_filename VARCHAR(255) NOT NULL,
stored_filename VARCHAR(255) NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER,
mime_type VARCHAR(100),
created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(user_id, provider_id, file_type)
)
''')
# Create user_oauth2_credentials table for storing OAuth2 tokens per user/provider
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS user_oauth2_credentials (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL,
provider_id VARCHAR(255) NOT NULL,
auth_type VARCHAR(50) NOT NULL,
credentials TEXT NOT NULL,
created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(user_id, provider_id, auth_type)
)
''')
def DatabaseManager__create_cache_tables(self, cursor, auto_increment, timestamp_default, boolean_type):
"""Create only temporary cache tables (CACHE DB ONLY)"""
# Only minimal tracking tables for cache database
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS token_usage (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER,
provider_id VARCHAR(255) NOT NULL,
model_name VARCHAR(255) NOT NULL,
tokens_used INTEGER NOT NULL,
timestamp TIMESTAMP DEFAULT {timestamp_default}
)
''')
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS context_dimensions (
id INTEGER PRIMARY KEY {auto_increment},
provider_id VARCHAR(255) NOT NULL,
model_name VARCHAR(255) NOT NULL,
context_size INTEGER,
condense_context INTEGER,
condense_method TEXT,
effective_context INTEGER DEFAULT 0,
last_updated TIMESTAMP DEFAULT {timestamp_default},
UNIQUE(provider_id, model_name)
)
''')
logger.info("⚠️ CACHE DATABASE: Only minimal cache tables created - NO USER TABLES")
def DatabaseManager__run_config_migrations(self, cursor, auto_increment, timestamp_default, boolean_type):
"""Run all configuration database migrations"""
# ==============================================
# UNIVERSAL MIGRATIONS - RUN ON EVERY STARTUP
# These run for ALL databases, new and existing
# ==============================================
logger.info("Running database migrations...")
# Migration: Create account_tiers table if missing
# Migration: Add prompt_tokens and completion_tokens columns to token_usage table
try:
if self.db_type == 'sqlite':
cursor.execute("PRAGMA table_info(account_tiers)")
if not cursor.fetchall():
cursor.execute(f'''
CREATE TABLE account_tiers (
id INTEGER PRIMARY KEY {auto_increment},
name VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
price_monthly DECIMAL(10,2) DEFAULT 0.00,
price_yearly DECIMAL(10,2) DEFAULT 0.00,
is_default {boolean_type} DEFAULT 0,
is_active {boolean_type} DEFAULT 1,
max_requests_per_day INTEGER DEFAULT -1,
max_requests_per_month INTEGER DEFAULT -1,
max_providers INTEGER DEFAULT -1,
max_rotations INTEGER DEFAULT -1,
max_autoselections INTEGER DEFAULT -1,
max_rotation_models INTEGER DEFAULT -1,
max_autoselection_models INTEGER DEFAULT -1,
created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default}
)
''')
conn.commit()
logger.info("✅ Migration: Created missing account_tiers table")
cursor.execute("PRAGMA table_info(token_usage)")
columns = [row[1] for row in cursor.fetchall()]
if 'prompt_tokens' not in columns:
cursor.execute('ALTER TABLE token_usage ADD COLUMN prompt_tokens INTEGER')
logger.info("✅ Migration: Added prompt_tokens column to token_usage")
if 'completion_tokens' not in columns:
cursor.execute('ALTER TABLE token_usage ADD COLUMN completion_tokens INTEGER')
logger.info("✅ Migration: Added completion_tokens column to token_usage")
if 'actual_cost' not in columns:
cursor.execute('ALTER TABLE token_usage ADD COLUMN actual_cost DECIMAL(10,6)')
logger.info("✅ Migration: Added actual_cost column to token_usage")
else:
cursor.execute("""
SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_NAME = 'account_tiers'
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'token_usage' AND COLUMN_NAME = 'prompt_tokens'
""")
if not cursor.fetchone():
cursor.execute(f'''
CREATE TABLE account_tiers (
id INTEGER PRIMARY KEY {auto_increment},
name VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
price_monthly DECIMAL(10,2) DEFAULT 0.00,
price_yearly DECIMAL(10,2) DEFAULT 0.00,
is_default {boolean_type} DEFAULT 0,
is_active {boolean_type} DEFAULT 1,
max_requests_per_day INTEGER DEFAULT -1,
max_requests_per_month INTEGER DEFAULT -1,
max_providers INTEGER DEFAULT -1,
max_rotations INTEGER DEFAULT -1,
max_autoselections INTEGER DEFAULT -1,
max_rotation_models INTEGER DEFAULT -1,
max_autoselection_models INTEGER DEFAULT -1,
created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default}
)
''')
conn.commit()
logger.info("✅ Migration: Created missing account_tiers table")
except Exception as e:
logger.warning(f"Migration check for account_tiers table: {e}")
# Migration: Add missing columns to account_tiers
try:
if self.db_type == 'sqlite':
cursor.execute("PRAGMA table_info(account_tiers)")
existing_columns = [row[1] for row in cursor.fetchall()]
tier_columns = [
('max_requests_per_day', 'INTEGER DEFAULT -1'),
('max_requests_per_month', 'INTEGER DEFAULT -1'),
('max_providers', 'INTEGER DEFAULT -1'),
('max_rotations', 'INTEGER DEFAULT -1'),
('max_autoselections', 'INTEGER DEFAULT -1'),
('max_rotation_models', 'INTEGER DEFAULT -1'),
('max_autoselection_models', 'INTEGER DEFAULT -1'),
('is_default', f'{boolean_type} DEFAULT 0'),
('is_active', f'{boolean_type} DEFAULT 1')
]
col_count = 0
for col_name, col_def in tier_columns:
if col_name not in existing_columns:
cursor.execute(f'ALTER TABLE account_tiers ADD COLUMN {col_name} {col_def}')
col_count += 1
if col_count > 0:
logger.info(f"✅ Migration: Added {col_count} missing columns to account_tiers")
except Exception as e:
logger.warning(f"Migration check for account_tiers columns: {e}")
# Migration: Ensure default free tier exists
try:
cursor.execute(f'SELECT COUNT(*) FROM account_tiers WHERE is_default = 1')
free_tier_count = cursor.fetchone()[0]
if free_tier_count == 0:
cursor.execute(f'''
INSERT INTO account_tiers
(name, description, price_monthly, price_yearly, is_default, is_active,
max_requests_per_day, max_requests_per_month, max_providers, max_rotations,
max_autoselections, max_rotation_models, max_autoselection_models)
VALUES
('Free Tier', 'Default free account tier with unlimited access', 0.00, 0.00, 1, 1,
-1, -1, -1, -1, -1, -1, -1)
''')
logger.info("✅ Migration: Inserted default free tier")
except Exception as e:
logger.warning(f"Migration check for default free tier: {e}")
cursor.execute('ALTER TABLE token_usage ADD COLUMN prompt_tokens INTEGER')
logger.info("✅ Migration: Added prompt_tokens column to token_usage")
# Migration: Add tier_id column to users table
try:
if self.db_type == 'sqlite':
cursor.execute("PRAGMA table_info(users)")
columns = [row[1] for row in cursor.fetchall()]
if 'tier_id' not in columns:
cursor.execute('ALTER TABLE users ADD COLUMN tier_id INTEGER DEFAULT 1')
cursor.execute('ALTER TABLE users ADD COLUMN subscription_expires TIMESTAMP NULL')
logger.info("✅ Migration: Added tier_id and subscription_expires columns to users")
else:
cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'users' AND COLUMN_NAME = 'tier_id'
WHERE TABLE_NAME = 'token_usage' AND COLUMN_NAME = 'completion_tokens'
""")
if not cursor.fetchone():
cursor.execute('ALTER TABLE users ADD COLUMN tier_id INTEGER DEFAULT 1')
cursor.execute('ALTER TABLE users ADD COLUMN subscription_expires TIMESTAMP NULL')
logger.info("✅ Migration: Added tier_id and subscription_expires columns to users")
except Exception as e:
logger.warning(f"Migration check for users.tier_id: {e}")
cursor.execute('ALTER TABLE token_usage ADD COLUMN completion_tokens INTEGER')
logger.info("✅ Migration: Added completion_tokens column to token_usage")
# Migration: Create payment_methods, user_subscriptions, payment_transactions tables
for table_name, create_sql in [
('payment_methods', f'''
CREATE TABLE payment_methods (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL,
type VARCHAR(50) NOT NULL,
identifier VARCHAR(255) NOT NULL,
is_default {boolean_type} DEFAULT 0,
is_active {boolean_type} DEFAULT 1,
metadata TEXT,
created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id)
)
'''),
('user_subscriptions', f'''
CREATE TABLE user_subscriptions (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL,
tier_id INTEGER NOT NULL,
status VARCHAR(50) DEFAULT 'active',
start_date TIMESTAMP DEFAULT {timestamp_default},
end_date TIMESTAMP NULL,
next_billing_date TIMESTAMP NULL,
trial_end_date TIMESTAMP NULL,
payment_method_id INTEGER,
auto_renew {boolean_type} DEFAULT 1,
created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (tier_id) REFERENCES account_tiers(id),
FOREIGN KEY (payment_method_id) REFERENCES payment_methods(id),
UNIQUE(user_id, tier_id)
)
'''),
('payment_transactions', f'''
CREATE TABLE payment_transactions (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL,
tier_id INTEGER,
subscription_id INTEGER,
payment_method_id INTEGER,
amount DECIMAL(10,2) NOT NULL,
currency VARCHAR(10) DEFAULT 'USD',
status VARCHAR(50) NOT NULL,
transaction_type VARCHAR(50) NOT NULL,
external_transaction_id VARCHAR(255),
metadata TEXT,
created_at TIMESTAMP DEFAULT {timestamp_default},
completed_at TIMESTAMP NULL,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (tier_id) REFERENCES account_tiers(id),
FOREIGN KEY (subscription_id) REFERENCES user_subscriptions(id),
FOREIGN KEY (payment_method_id) REFERENCES payment_methods(id)
)
''')
]:
try:
if self.db_type == 'sqlite':
cursor.execute(f"PRAGMA table_info({table_name})")
if not cursor.fetchall():
cursor.execute(create_sql)
logger.info(f"✅ Migration: Created missing {table_name} table")
else:
cursor.execute(f"""
SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_NAME = '{table_name}'
cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'token_usage' AND COLUMN_NAME = 'actual_cost'
""")
if not cursor.fetchone():
cursor.execute(create_sql)
logger.info(f"✅ Migration: Created missing {table_name} table")
cursor.execute('ALTER TABLE token_usage ADD COLUMN actual_cost DECIMAL(10,6)')
logger.info("✅ Migration: Added actual_cost column to token_usage")
except Exception as e:
logger.warning(f"Migration check for {table_name} table: {e}")
logger.warning(f"Migration check for token_usage columns: {e}")
conn.commit()
logger.info("✅ All database migrations completed")
# Patch the methods
DatabaseManager._initialize_database = DatabaseManager__initialize_database
DatabaseManager._create_config_tables = DatabaseManager__create_config_tables
DatabaseManager._create_cache_tables = DatabaseManager__create_cache_tables
DatabaseManager._run_config_migrations = DatabaseManager__run_config_migrations
......@@ -366,6 +366,9 @@ class RequestHandler:
# Record analytics for authentication failure
try:
analytics = get_analytics()
# Calculate latency for auth failure
latency_ms = (time.time() - request_start_time) * 1000
# Estimate tokens for the request
try:
messages = request_data.get('messages', [])
......@@ -378,11 +381,14 @@ class RequestHandler:
provider_id=provider_id,
model_name=request_data.get('model', 'unknown'),
tokens_used=estimated_tokens,
latency_ms=0,
latency_ms=latency_ms,
success=False,
error_type='AuthenticationError',
user_id=getattr(request.state, 'user_id', None),
token_id=getattr(request.state, 'token_id', None)
token_id=getattr(request.state, 'token_id', None),
prompt_tokens=estimated_tokens,
completion_tokens=0,
actual_cost=None
)
except Exception as analytics_error:
logger.warning(f"Analytics recording for auth failure failed: {analytics_error}")
......@@ -526,33 +532,48 @@ class RequestHandler:
try:
analytics = get_analytics()
latency_ms = (time.time() - request_start_time) * 1000
logger.info(f"Analytics: latency_ms={latency_ms:.2f}, request_start_time={request_start_time}, current_time={time.time()}")
if response and isinstance(response, dict):
usage = response.get('usage', {})
total_tokens = usage.get('total_tokens', 0)
prompt_tokens = usage.get('prompt_tokens', 0)
completion_tokens = usage.get('completion_tokens', 0)
# If no token usage provided, estimate it
# Try to extract actual cost from provider response
from ..cost_extractor import extract_cost_from_response
actual_cost = extract_cost_from_response(response, provider_id)
# If no token usage provided, estimate it with improved accuracy
if total_tokens == 0:
try:
messages = request_data.get('messages', [])
estimated_prompt_tokens = count_messages_tokens(messages, model_name)
# More realistic completion estimate based on max_tokens or typical response
# Count actual completion tokens from response instead of estimating
response_content = response.get('choices', [{}])[0].get('message', {}).get('content', '')
if response_content:
completion_tokens = count_messages_tokens([{
"role": "assistant",
"content": response_content
}], model_name)
else:
# Fallback to estimation if no content
max_tokens = request_data.get('max_tokens', 0)
if max_tokens > 0:
# Use max_tokens as upper bound for completion
estimated_completion = min(max_tokens, estimated_prompt_tokens * 2)
completion_tokens = min(max_tokens, estimated_prompt_tokens * 2)
else:
# No max_tokens specified, assume completion is similar to prompt
# but at least 50 tokens for typical responses
estimated_completion = max(estimated_prompt_tokens, 50)
completion_tokens = max(estimated_prompt_tokens, 50)
total_tokens = estimated_prompt_tokens + estimated_completion
logger.debug(f"Estimated token usage: {total_tokens} (prompt: {estimated_prompt_tokens}, completion: {estimated_completion})")
total_tokens = estimated_prompt_tokens + completion_tokens
prompt_tokens = estimated_prompt_tokens
logger.debug(f"Counted token usage: {total_tokens} (prompt: {estimated_prompt_tokens}, completion: {completion_tokens})")
except Exception as est_error:
logger.debug(f"Token estimation failed: {est_error}")
# Use a more realistic default if estimation fails
logger.debug(f"Token counting failed: {est_error}")
# Use a more realistic default if counting fails
total_tokens = 150
prompt_tokens = 0
completion_tokens = 0
# Always record analytics, even with estimated tokens
analytics.record_request(
......@@ -562,7 +583,10 @@ class RequestHandler:
latency_ms=latency_ms,
success=True,
user_id=getattr(request.state, 'user_id', None),
token_id=getattr(request.state, 'token_id', None)
token_id=getattr(request.state, 'token_id', None),
prompt_tokens=prompt_tokens if prompt_tokens > 0 else None,
completion_tokens=completion_tokens if completion_tokens > 0 else None,
actual_cost=actual_cost
)
except Exception as analytics_error:
logger.warning(f"Analytics recording failed: {analytics_error}")
......@@ -582,8 +606,12 @@ class RequestHandler:
messages = request_data.get('messages', [])
estimated_tokens = count_messages_tokens(messages, model_name)
total_tokens = estimated_tokens
prompt_tokens = estimated_tokens
completion_tokens = 0 # No completion for failed requests
except Exception:
total_tokens = 50 # Minimal estimate for failed requests
prompt_tokens = 50
completion_tokens = 0
analytics.record_request(
provider_id=provider_id,
......@@ -593,7 +621,10 @@ class RequestHandler:
success=False,
error_type=type(e).__name__,
user_id=getattr(request.state, 'user_id', None),
token_id=getattr(request.state, 'token_id', None)
token_id=getattr(request.state, 'token_id', None),
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
actual_cost=None
)
except Exception as analytics_error:
logger.warning(f"Analytics recording for failed request failed: {analytics_error}")
......@@ -667,6 +698,8 @@ class RequestHandler:
import time
import json
logger = logging.getLogger(__name__)
# Track request start time for latency calculation
request_start_time = time.time()
try:
# Apply rate limiting
await handler.apply_rate_limit()
......@@ -1200,6 +1233,10 @@ class RequestHandler:
# Record analytics for streaming request
try:
analytics = get_analytics()
# Calculate latency
latency_ms = (time.time() - request_start_time) * 1000
logger.info(f"Streaming Analytics: latency_ms={latency_ms:.2f}")
# Calculate total tokens from accumulated response
if accumulated_response_text:
completion_tokens = count_messages_tokens([{"role": "assistant", "content": accumulated_response_text}], request_data['model'])
......@@ -1211,10 +1248,13 @@ class RequestHandler:
provider_id=provider_id,
model_name=request_data['model'],
tokens_used=total_tokens,
latency_ms=0,
latency_ms=latency_ms,
success=True,
user_id=getattr(request.state, 'user_id', None),
token_id=getattr(request.state, 'token_id', None)
token_id=getattr(request.state, 'token_id', None),
prompt_tokens=effective_context,
completion_tokens=completion_tokens,
actual_cost=None # Streaming responses typically don't include cost
)
except Exception as analytics_error:
logger.warning(f"Analytics recording for streaming request failed: {analytics_error}")
......@@ -1225,23 +1265,34 @@ class RequestHandler:
# Record analytics for failed streaming request
try:
analytics = get_analytics()
# Calculate latency
latency_ms = (time.time() - request_start_time) * 1000
logger.info(f"Failed Streaming Analytics: latency_ms={latency_ms:.2f}")
# Estimate tokens for failed request
try:
messages = request_data.get('messages', [])
estimated_tokens = count_messages_tokens(messages, request_data['model'])
total_tokens = estimated_tokens
prompt_tokens = estimated_tokens
completion_tokens = 0
except Exception:
total_tokens = 50 # Minimal estimate for failed requests
prompt_tokens = 50
completion_tokens = 0
analytics.record_request(
provider_id=provider_id,
model_name=request_data['model'],
tokens_used=total_tokens,
latency_ms=0,
latency_ms=latency_ms,
success=False,
error_type=type(e).__name__,
user_id=getattr(request.state, 'user_id', None),
token_id=getattr(request.state, 'token_id', None)
token_id=getattr(request.state, 'token_id', None),
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
actual_cost=None
)
except Exception as analytics_error:
logger.warning(f"Analytics recording for failed streaming request failed: {analytics_error}")
......@@ -2233,6 +2284,8 @@ class RotationHandler:
import logging
import time
logger = logging.getLogger(__name__)
# Track request start time for latency calculation
request_start_time = time.time()
logger.info(f"=== RotationHandler.handle_rotation_request START ===")
logger.info(f"Rotation ID: {rotation_id}")
logger.info(f"User ID: {self.user_id}")
......@@ -2847,6 +2900,8 @@ class RotationHandler:
if response and isinstance(response, dict):
usage = response.get('usage', {})
total_tokens = usage.get('total_tokens', 0)
prompt_tokens = usage.get('prompt_tokens', 0)
completion_tokens = usage.get('completion_tokens', 0)
# If no token usage provided, estimate it
if total_tokens == 0:
......@@ -2862,21 +2917,32 @@ class RotationHandler:
estimated_completion = max(estimated_prompt_tokens, 50)
total_tokens = estimated_prompt_tokens + estimated_completion
prompt_tokens = estimated_prompt_tokens
completion_tokens = estimated_completion
logger.debug(f"Estimated token usage for rotation: {total_tokens}")
except Exception as est_error:
logger.debug(f"Token estimation failed: {est_error}")
total_tokens = 150
prompt_tokens = 0
completion_tokens = 0
# Try to extract actual cost from provider response
from ..cost_extractor import extract_cost_from_response
actual_cost = extract_cost_from_response(response, provider_id)
# Always record analytics
analytics.record_request(
provider_id=provider_id,
model_name=model_name,
tokens_used=total_tokens,
latency_ms=0, # Latency tracking would require more extensive changes
latency_ms=(time.time() - request_start_time) * 1000,
success=True,
rotation_id=rotation_id,
user_id=user_id,
token_id=token_id
token_id=token_id,
prompt_tokens=prompt_tokens if prompt_tokens > 0 else None,
completion_tokens=completion_tokens if completion_tokens > 0 else None,
actual_cost=actual_cost
)
except Exception as analytics_error:
logger.warning(f"Analytics recording failed: {analytics_error}")
......@@ -2912,24 +2978,35 @@ class RotationHandler:
# Record analytics for failed rotation request
try:
analytics = get_analytics()
# Calculate latency
latency_ms = (time.time() - request_start_time) * 1000
logger.info(f"Failed Rotation Analytics: latency_ms={latency_ms:.2f}")
# Estimate tokens for failed request
try:
messages = request_data.get('messages', [])
estimated_tokens = count_messages_tokens(messages, rotation_id)
total_tokens = estimated_tokens
prompt_tokens = estimated_tokens
completion_tokens = 0
except Exception:
total_tokens = 50 # Minimal estimate for failed requests
prompt_tokens = 50
completion_tokens = 0
analytics.record_request(
provider_id='rotation',
model_name=rotation_id,
tokens_used=total_tokens,
latency_ms=0,
latency_ms=latency_ms,
success=False,
error_type='RotationFailure',
rotation_id=rotation_id,
user_id=user_id,
token_id=token_id
token_id=token_id,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
actual_cost=None
)
except Exception as analytics_error:
logger.warning(f"Analytics recording for failed rotation failed: {analytics_error}")
......@@ -4112,7 +4189,10 @@ class AutoselectHandler:
async def handle_autoselect_request(self, autoselect_id: str, request_data: Dict, user_id: Optional[int] = None, token_id: Optional[int] = None) -> Dict:
"""Handle an autoselect request"""
import logging
import time
logger = logging.getLogger(__name__)
# Track request start time for latency calculation
request_start_time = time.time()
logger.info(f"=== AUTOSELECT REQUEST START ===")
logger.info(f"Autoselect ID: {autoselect_id}")
logger.info(f"User ID: {self.user_id}")
......@@ -4232,24 +4312,35 @@ class AutoselectHandler:
logger.error(f"Autoselect request failed: {str(e)}")
try:
analytics = get_analytics()
# Calculate latency
latency_ms = (time.time() - request_start_time) * 1000
logger.info(f"Failed Autoselect Analytics: latency_ms={latency_ms:.2f}")
# Estimate tokens for failed request
try:
messages = request_data.get('messages', [])
estimated_tokens = count_messages_tokens(messages, autoselect_id)
total_tokens = estimated_tokens
prompt_tokens = estimated_tokens
completion_tokens = 0
except Exception:
total_tokens = 50 # Minimal estimate for failed requests
prompt_tokens = 50
completion_tokens = 0
analytics.record_request(
provider_id='autoselect',
model_name=autoselect_id,
tokens_used=total_tokens,
latency_ms=0,
latency_ms=latency_ms,
success=False,
error_type=type(e).__name__,
autoselect_id=autoselect_id,
user_id=user_id,
token_id=token_id
token_id=token_id,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
actual_cost=None
)
except Exception as analytics_error:
logger.warning(f"Analytics recording for failed autoselect failed: {analytics_error}")
......@@ -4276,6 +4367,8 @@ class AutoselectHandler:
if response and isinstance(response, dict):
usage = response.get('usage', {})
total_tokens = usage.get('total_tokens', 0)
prompt_tokens = usage.get('prompt_tokens', 0)
completion_tokens = usage.get('completion_tokens', 0)
# If no token usage provided, estimate it
if total_tokens == 0:
......@@ -4292,23 +4385,38 @@ class AutoselectHandler:
estimated_completion = max(estimated_prompt_tokens, 50)
total_tokens = estimated_prompt_tokens + estimated_completion
prompt_tokens = estimated_prompt_tokens
completion_tokens = estimated_completion
logger.debug(f"Estimated token usage for autoselect: {total_tokens}")
except Exception as est_error:
logger.debug(f"Token estimation failed: {est_error}")
total_tokens = 150
prompt_tokens = 0
completion_tokens = 0
# Try to extract actual cost from provider response
from ..cost_extractor import extract_cost_from_response
actual_cost = extract_cost_from_response(response, 'autoselect')
# Always record analytics
# The actual provider/model info is in the response model field
model_name = response.get('model', 'unknown')
# Calculate latency
latency_ms = (time.time() - request_start_time) * 1000
logger.info(f"Autoselect Analytics: latency_ms={latency_ms:.2f}")
analytics.record_request(
provider_id='autoselect',
model_name=model_name,
tokens_used=total_tokens,
latency_ms=0,
latency_ms=latency_ms,
success=True,
autoselect_id=autoselect_id,
user_id=user_id,
token_id=token_id
token_id=token_id,
prompt_tokens=prompt_tokens if prompt_tokens > 0 else None,
completion_tokens=completion_tokens if completion_tokens > 0 else None,
actual_cost=actual_cost
)
except Exception as analytics_error:
logger.warning(f"Analytics recording failed: {analytics_error}")
......
......@@ -855,12 +855,12 @@ class MCPServer:
if stream:
return {"error": "Streaming not supported in MCP, use SSE endpoint instead"}
else:
return await handler.handle_autoselect_request(actual_model, request_data)
return await handler.handle_autoselect_request(actual_model, request_data, user_id, None)
elif provider_id == "rotation":
handler = get_user_handler('rotation', user_id)
if actual_model not in self.config.rotations and (not user_id or actual_model not in handler.user_rotations):
raise HTTPException(status_code=400, detail=f"Rotation '{actual_model}' not found")
return await handler.handle_rotation_request(actual_model, request_data)
return await handler.handle_rotation_request(actual_model, request_data, user_id, None)
else:
handler = get_user_handler('request', user_id)
if provider_id not in self.config.providers and (not user_id or provider_id not in handler.user_providers):
......
......@@ -159,7 +159,7 @@ class ClaudeProviderHandler(BaseProviderHandler):
logger.info("ClaudeProviderHandler: Initializing session for quota tracking")
try:
headers = self._get_auth_headers(stream=False)
headers = await self._get_auth_headers(stream=False)
payload = {
'model': 'claude-haiku-4-5-20251001',
......@@ -257,12 +257,12 @@ class ClaudeProviderHandler(BaseProviderHandler):
if old_util != new_util:
logger.debug(f"ClaudeProviderHandler: Quota utilization updated: {old_util} -> {new_util}")
def _get_sdk_client(self):
async def _get_sdk_client(self):
"""Get or create an Anthropic SDK client configured with OAuth2 auth token."""
import logging
logger = logging.getLogger(__name__)
access_token = self.auth.get_valid_token()
access_token = await self.auth.get_valid_token()
if not access_token:
logger.error("ClaudeProviderHandler: No OAuth2 access token available")
......@@ -277,14 +277,14 @@ class ClaudeProviderHandler(BaseProviderHandler):
logger.info("ClaudeProviderHandler: Created SDK client with OAuth2 auth token")
return self._sdk_client
def _get_auth_headers(self, stream: bool = False):
async def _get_auth_headers(self, stream: bool = False):
"""Get HTTP headers with OAuth2 Bearer token."""
import logging
import uuid
import platform
logger = logging.getLogger(__name__)
access_token = self.auth.get_valid_token()
access_token = await self.auth.get_valid_token()
if not self.session_state.get('session_id'):
self.session_state['session_id'] = str(uuid.uuid4())
......@@ -849,7 +849,7 @@ class ClaudeProviderHandler(BaseProviderHandler):
if anthropic_tool_choice:
payload['tool_choice'] = anthropic_tool_choice
headers = self._get_auth_headers(stream=stream)
headers = await self._get_auth_headers(stream=stream)
api_url = 'https://api.anthropic.com/v1/messages?beta=true'
logger.info(f"ClaudeProviderHandler: Request payload keys: {list(payload.keys())}")
......@@ -1640,7 +1640,7 @@ class ClaudeProviderHandler(BaseProviderHandler):
try:
logging.info("ClaudeProviderHandler: [1/3] Attempting primary API endpoint...")
headers = self._get_auth_headers(stream=False)
headers = await self._get_auth_headers(stream=False)
api_endpoint = 'https://api.anthropic.com/v1/models'
logging.info(f"ClaudeProviderHandler: Calling API endpoint: {api_endpoint}")
......
......@@ -170,7 +170,7 @@ class KiloProviderHandler(BaseProviderHandler):
"token": self.api_key
}
token = self.oauth2.get_valid_token()
token = await self.oauth2.get_valid_token()
if token:
logger.info("KiloProviderHandler: Using existing OAuth2 token")
......@@ -182,7 +182,7 @@ class KiloProviderHandler(BaseProviderHandler):
# Try to reload credentials one more time - this handles the case where credentials
# were saved by another process/handler instance after this handler was created
self.oauth2._load_credentials()
token = self.oauth2.get_valid_token()
token = await self.oauth2.get_valid_token()
if token:
logger.info("KiloProviderHandler: Found OAuth2 token after reloading credentials")
......
......@@ -103,7 +103,7 @@ class QwenProviderHandler(BaseProviderHandler):
logging.getLogger(__name__).info(f"QwenProviderHandler: Falling back to file-based credentials for user {self.user_id}")
return QwenOAuth2(credentials_file=credentials_file)
def _get_sdk_client(self):
async def _get_sdk_client(self):
"""Get or create an OpenAI SDK client configured with authentication (OAuth2 or API key)."""
import logging
logger = logging.getLogger(__name__)
......@@ -122,7 +122,7 @@ class QwenProviderHandler(BaseProviderHandler):
base_url = self._get_region_endpoint(qwen_config)
else:
# Use OAuth2 authentication
access_token = self.auth.get_valid_token()
access_token = await self.auth.get_valid_token()
if not access_token:
logger.error("QwenProviderHandler: No OAuth2 access token available")
......@@ -221,7 +221,7 @@ class QwenProviderHandler(BaseProviderHandler):
await self.apply_rate_limit()
# Get SDK client with current OAuth token
client = self._get_sdk_client()
client = await self._get_sdk_client()
# Build request parameters
request_params = {
......@@ -308,7 +308,7 @@ class QwenProviderHandler(BaseProviderHandler):
if refresh_success:
logger.info("QwenProviderHandler: Token refreshed, retrying request")
# Retry with new token
client = self._get_sdk_client()
client = await self._get_sdk_client()
if stream:
return self._handle_streaming_request(client, request_params, model)
......@@ -472,7 +472,7 @@ class QwenProviderHandler(BaseProviderHandler):
try:
# Get SDK client with API key authentication
client = self._get_sdk_client()
client = await self._get_sdk_client()
# List models using OpenAI SDK
models_response = await client.models.list()
......
......@@ -647,6 +647,13 @@ def initialize_app(custom_config_dir=None):
'password': '8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918'
}
# Initialize analytics with the config database
from aisbf.analytics import initialize_analytics
from aisbf.database import DatabaseRegistry
db = DatabaseRegistry.get_config_database()
initialize_analytics(db)
logger.info("Analytics module initialized")
_initialized = True
logger.info("App initialization complete")
......@@ -1314,11 +1321,6 @@ async def auth_middleware(request: Request, call_next):
request.state.is_global_token = False
# Store user role - admin users get full access
request.state.is_admin = (user_auth.get('role') == 'admin')
# Record token usage for analytics
# We'll do this asynchronously to avoid blocking the request
import asyncio
asyncio.create_task(record_token_usage_async(user_auth['user_id'], user_auth['token_id']))
else:
return JSONResponse(
status_code=403,
......@@ -1901,6 +1903,42 @@ async def get_subscription_status(request: Request):
status = await payment_service.get_subscription_status(current_user['id'])
return {'subscription': status}
# User search API endpoint for autocomplete
@app.get("/api/users/search")
async def search_users(request: Request, q: str = Query("", min_length=0)):
"""Search users by username for autocomplete (admin only)"""
auth_check = require_dashboard_auth(request)
if auth_check:
raise HTTPException(status_code=401, detail="Unauthorized")
# Check if user is admin
is_admin = request.session.get('role') == 'admin'
if not is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
db = DatabaseRegistry.get_config_database()
if not db:
return {"users": []}
# Get all users
all_users = db.get_users()
# Filter by query string (case-insensitive)
if q:
filtered_users = [
{"id": user['id'], "username": user['username'], "role": user.get('role', 'user')}
for user in all_users
if q.lower() in user['username'].lower()
]
else:
filtered_users = [
{"id": user['id'], "username": user['username'], "role": user.get('role', 'user')}
for user in all_users
]
# Limit to 50 results
return {"users": filtered_users[:50]}
# Dashboard routes
@app.get("/dashboard/analytics", response_class=HTMLResponse)
async def dashboard_analytics(
......@@ -1912,7 +1950,8 @@ async def dashboard_analytics(
model_filter: Optional[str] = Query(None),
rotation_filter: Optional[str] = Query(None),
autoselect_filter: Optional[str] = Query(None),
user_filter: Optional[int] = Query(None)
user_filter: Optional[str] = Query(None),
global_only: Optional[str] = Query(None)
):
"""Token usage analytics dashboard"""
auth_check = require_dashboard_auth(request)
......@@ -1950,9 +1989,21 @@ async def dashboard_analytics(
is_admin = request.session.get('role') == 'admin'
current_user_id = request.session.get('user_id')
# Parse user_filter from string to int, handling empty strings
user_filter_int = None
if user_filter:
try:
user_filter_int = int(user_filter)
except (ValueError, TypeError):
user_filter_int = None
# Handle global_only filter - if checked, set user_filter to -1 (special value for global requests)
if global_only == '1':
user_filter_int = -1 # Special value to indicate "only global requests"
# For non-admin users, force user filter to current user
if not is_admin and current_user_id is not None:
user_filter = current_user_id
user_filter_int = current_user_id
# Get all users for filter dropdown (only for admins)
all_users = db.get_users() if db and is_admin else []
......@@ -1972,9 +2023,9 @@ async def dashboard_analytics(
# Get provider statistics (with optional filter)
if provider_filter:
provider_stats = [analytics.get_provider_stats(provider_filter, from_datetime, to_datetime, user_filter=user_filter)]
provider_stats = [analytics.get_provider_stats(provider_filter, from_datetime, to_datetime, user_filter=user_filter_int)]
else:
provider_stats = analytics.get_all_providers_stats(from_datetime, to_datetime, user_filter=user_filter)
provider_stats = analytics.get_all_providers_stats(from_datetime, to_datetime, user_filter=user_filter_int)
# Get token usage over time (with optional filters)
token_over_time = analytics.get_token_usage_over_time(
......@@ -1982,7 +2033,7 @@ async def dashboard_analytics(
time_range=time_range,
from_datetime=from_datetime,
to_datetime=to_datetime,
user_filter=user_filter
user_filter=user_filter_int
)
# Get model performance (with optional filters)
......@@ -1991,21 +2042,21 @@ async def dashboard_analytics(
model_filter=model_filter,
rotation_filter=rotation_filter,
autoselect_filter=autoselect_filter,
user_filter=user_filter
user_filter=user_filter_int
)
# Get cost overview
cost_overview = analytics.get_cost_overview(from_datetime, to_datetime, user_filter=user_filter)
cost_overview = analytics.get_cost_overview(from_datetime, to_datetime, user_filter=user_filter_int)
# Get optimization recommendations
recommendations = analytics.get_optimization_recommendations(user_filter=user_filter)
recommendations = analytics.get_optimization_recommendations(user_filter=user_filter_int)
# Get date range usage summary
date_range_usage = None
if from_datetime or to_datetime:
start = from_datetime or (datetime.now() - timedelta(days=1))
end = to_datetime or datetime.now()
date_range_usage = analytics.get_token_usage_by_date_range(provider_filter, start, end, user_filter=user_filter)
date_range_usage = analytics.get_token_usage_by_date_range(provider_filter, start, end, user_filter=user_filter_int)
return templates.TemplateResponse(
request=request,
......@@ -2032,7 +2083,8 @@ async def dashboard_analytics(
"selected_model": model_filter,
"selected_rotation": rotation_filter,
"selected_autoselect": autoselect_filter,
"selected_user": user_filter
"selected_user": user_filter,
"global_only": global_only
}
)
......@@ -2151,6 +2203,13 @@ async def dashboard_login(request: Request, username: str = Form(...), password:
# For non-remember-me sessions, set expiry to 2 weeks (default session length)
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
# Update last login timestamp
with db._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if db.db_type == 'sqlite' else '%s'
cursor.execute(f'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = {placeholder}', (user['id'],))
conn.commit()
# Check if email is verified
if not user['email_verified']:
# Check if account is expired (24 hours old and unverified)
......@@ -3236,6 +3295,13 @@ async def oauth2_google_callback(request: Request, code: str = Query(...), state
request.session['user_id'] = existing_user['id']
request.session['email_verified'] = True # OAuth2 users have verified emails
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
# Update last login timestamp
with db._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if db.db_type == 'sqlite' else '%s'
cursor.execute(f'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = {placeholder}', (existing_user['id'],))
conn.commit()
else:
# New user - create account automatically (no password required)
if not email_verified:
......@@ -3253,6 +3319,7 @@ async def oauth2_google_callback(request: Request, code: str = Query(...), state
google_username = db.generate_username_from_display_name(display_name, email)
final_username = db.find_unique_username(google_username)
# Create user with verified email (no verification required)
user_id = db.create_user(final_username, password_hash, 'user', None, email, True, display_name)
......@@ -3265,6 +3332,13 @@ async def oauth2_google_callback(request: Request, code: str = Query(...), state
request.session['email_verified'] = True # OAuth2 users have verified emails
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
# Update last login timestamp for new user
with db._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if db.db_type == 'sqlite' else '%s'
cursor.execute(f'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = {placeholder}', (user_id,))
conn.commit()
# Cleanup session data
request.session.pop('oauth2_google', None)
......@@ -3383,6 +3457,13 @@ async def oauth2_github_callback(request: Request, code: str = Query(...), state
request.session['user_id'] = existing_user['id']
request.session['email_verified'] = True # OAuth2 users have verified emails
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
# Update last login timestamp
with db._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if db.db_type == 'sqlite' else '%s'
cursor.execute(f'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = {placeholder}', (existing_user['id'],))
conn.commit()
else:
# New user - create account automatically (no password required)
# Generate secure random password for OAuth users (never used for login)
......@@ -3405,6 +3486,13 @@ async def oauth2_github_callback(request: Request, code: str = Query(...), state
request.session['email_verified'] = True # OAuth2 users have verified emails
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
# Update last login timestamp for new user
with db._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if db.db_type == 'sqlite' else '%s'
cursor.execute(f'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = {placeholder}', (user_id,))
conn.commit()
# Cleanup session data
request.session.pop('oauth2_github', None)
......@@ -3639,7 +3727,7 @@ async def _auto_detect_provider_models(provider_key: str, provider: dict) -> lis
kilo_config = provider.get('kilo_config', {})
credentials_file = kilo_config.get('credentials_file', '~/.kilo_credentials.json')
oauth2 = KiloOAuth2(credentials_file=credentials_file, api_base=endpoint)
token = oauth2.get_valid_token()
token = await oauth2.get_valid_token()
if token:
api_key = token
logger.info(f"Using OAuth2 token for Kilo provider '{provider_key}'")
......@@ -6992,7 +7080,7 @@ async def dashboard_pricing(request: Request):
from aisbf.database import get_database
db = DatabaseRegistry.get_config_database()
tiers = db.get_all_tiers()
tiers = db.get_visible_tiers()
current_tier = db.get_user_tier(request.session.get('user_id'))
# Get enabled payment gateways
......@@ -7874,12 +7962,13 @@ async def v1_chat_completions(request: Request, body: ChatCompletionRequest):
body_dict['model'] = actual_model
# Get user-specific handler
user_id = getattr(request.state, 'user_id', None)
token_id = getattr(request.state, 'token_id', None)
handler = get_user_handler('autoselect', user_id)
if body.stream:
return await handler.handle_autoselect_streaming_request(actual_model, body_dict)
else:
return await handler.handle_autoselect_request(actual_model, body_dict)
return await handler.handle_autoselect_request(actual_model, body_dict, user_id, token_id)
# PATH 2: Check if it's a rotation (format: rotation/{name})
if provider_id == "rotation":
......@@ -7891,8 +7980,9 @@ async def v1_chat_completions(request: Request, body: ChatCompletionRequest):
body_dict['model'] = actual_model
# Get user-specific handler
user_id = getattr(request.state, 'user_id', None)
token_id = getattr(request.state, 'token_id', None)
handler = get_user_handler('rotation', user_id)
return await handler.handle_rotation_request(actual_model, body_dict)
return await handler.handle_rotation_request(actual_model, body_dict, user_id, token_id)
# PATH 1: Direct provider model (format: {provider}/{model})
if provider_id not in config.providers:
......@@ -8405,11 +8495,12 @@ async def rotation_chat_completions(request: Request, body: ChatCompletionReques
try:
# Get user-specific handler
user_id = getattr(request.state, 'user_id', None)
token_id = getattr(request.state, 'token_id', None)
handler = get_user_handler('rotation', user_id)
# The rotation handler handles streaming internally and returns
# a StreamingResponse for streaming requests or a dict for non-streaming
result = await handler.handle_rotation_request(body.model, body_dict)
result = await handler.handle_rotation_request(body.model, body_dict, user_id, token_id)
logger.debug(f"Rotation response result type: {type(result)}")
return result
except Exception as e:
......@@ -8482,6 +8573,7 @@ async def autoselect_chat_completions(request: Request, body: ChatCompletionRequ
# Get user-specific handler
user_id = getattr(request.state, 'user_id', None)
token_id = getattr(request.state, 'token_id', None)
handler = get_user_handler('autoselect', user_id)
# Check if the model name corresponds to an autoselect configuration
......@@ -8502,7 +8594,7 @@ async def autoselect_chat_completions(request: Request, body: ChatCompletionRequ
return await handler.handle_autoselect_streaming_request(body.model, body_dict)
else:
logger.debug("Handling non-streaming autoselect request")
result = await handler.handle_autoselect_request(body.model, body_dict)
result = await handler.handle_autoselect_request(body.model, body_dict, user_id, token_id)
logger.debug(f"Autoselect response result: {result}")
return result
except Exception as e:
......@@ -8550,6 +8642,7 @@ async def chat_completions(provider_id: str, request: Request, body: ChatComplet
# Check if it's an autoselect
if provider_id in config.autoselect or (user_id and provider_id in get_user_handler('autoselect', user_id).user_autoselects):
logger.debug("Handling autoselect request")
token_id = getattr(request.state, 'token_id', None)
handler = get_user_handler('autoselect', user_id)
try:
if body.stream:
......@@ -8557,7 +8650,7 @@ async def chat_completions(provider_id: str, request: Request, body: ChatComplet
return await handler.handle_autoselect_streaming_request(provider_id, body_dict)
else:
logger.debug("Handling non-streaming autoselect request")
result = await handler.handle_autoselect_request(provider_id, body_dict)
result = await handler.handle_autoselect_request(provider_id, body_dict, user_id, token_id)
logger.debug(f"Autoselect response result: {result}")
return result
except Exception as e:
......@@ -8568,8 +8661,9 @@ async def chat_completions(provider_id: str, request: Request, body: ChatComplet
if provider_id in config.rotations or (user_id and provider_id in get_user_handler('rotation', user_id).user_rotations):
logger.info(f"Provider ID '{provider_id}' found in rotations")
logger.debug("Handling rotation request")
token_id = getattr(request.state, 'token_id', None)
handler = get_user_handler('rotation', user_id)
return await handler.handle_rotation_request(provider_id, body_dict)
return await handler.handle_rotation_request(provider_id, body_dict, user_id, token_id)
# Check if it's a provider
handler = get_user_handler('request', user_id)
......@@ -10600,7 +10694,8 @@ async def user_chat_completions_by_username(request: Request, username: str, bod
if body.stream:
return await handler.handle_autoselect_streaming_request(actual_model, body_dict)
else:
return await handler.handle_autoselect_request(actual_model, body_dict)
token_id = getattr(request.state, 'token_id', None)
return await handler.handle_autoselect_request(actual_model, body_dict, authenticated_user_id, token_id)
if provider_id == "user-rotation":
handler = get_user_handler('rotation', target_user_id)
......@@ -10610,7 +10705,8 @@ async def user_chat_completions_by_username(request: Request, username: str, bod
detail=f"User rotation '{actual_model}' not found. Available: {list(handler.user_rotations.keys())}"
)
body_dict['model'] = actual_model
return await handler.handle_rotation_request(actual_model, body_dict)
token_id = getattr(request.state, 'token_id', None)
return await handler.handle_rotation_request(actual_model, body_dict, authenticated_user_id, token_id)
if provider_id == "user-provider":
handler = get_user_handler('request', target_user_id)
......@@ -10648,7 +10744,8 @@ async def user_chat_completions_by_username(request: Request, username: str, bod
if body.stream:
return await handler.handle_autoselect_streaming_request(actual_model, body_dict)
else:
return await handler.handle_autoselect_request(actual_model, body_dict)
token_id = getattr(request.state, 'token_id', None)
return await handler.handle_autoselect_request(actual_model, body_dict, authenticated_user_id, token_id)
if provider_id == "rotation":
if actual_model not in config.rotations:
......@@ -10658,7 +10755,8 @@ async def user_chat_completions_by_username(request: Request, username: str, bod
)
handler = get_user_handler('rotation', None)
body_dict['model'] = actual_model
return await handler.handle_rotation_request(actual_model, body_dict)
token_id = getattr(request.state, 'token_id', None)
return await handler.handle_rotation_request(actual_model, body_dict, authenticated_user_id, token_id)
if provider_id in config.providers:
provider_config = config.get_provider(provider_id)
......@@ -10858,7 +10956,8 @@ async def user_chat_completions(request: Request, username: str, body: ChatCompl
if body.stream:
return await handler.handle_autoselect_streaming_request(actual_model, body_dict)
else:
return await handler.handle_autoselect_request(actual_model, body_dict)
token_id = getattr(request.state, 'token_id', None)
return await handler.handle_autoselect_request(actual_model, body_dict, user_id, token_id)
# Handle user rotation (format: user-rotation/{name})
if provider_id == "user-rotation":
......@@ -10869,7 +10968,8 @@ async def user_chat_completions(request: Request, username: str, body: ChatCompl
detail=f"User rotation '{actual_model}' not found. Available: {list(handler.user_rotations.keys())}"
)
body_dict['model'] = actual_model
return await handler.handle_rotation_request(actual_model, body_dict)
token_id = getattr(request.state, 'token_id', None)
return await handler.handle_rotation_request(actual_model, body_dict, user_id, token_id)
# Handle user provider (format: user-provider/{name})
if provider_id == "user-provider":
......@@ -10911,7 +11011,8 @@ async def user_chat_completions(request: Request, username: str, body: ChatCompl
if body.stream:
return await handler.handle_autoselect_streaming_request(actual_model, body_dict)
else:
return await handler.handle_autoselect_request(actual_model, body_dict)
token_id = getattr(request.state, 'token_id', None)
return await handler.handle_autoselect_request(actual_model, body_dict, user_id, token_id)
# Handle global rotation
if provider_id == "rotation":
......@@ -10922,7 +11023,8 @@ async def user_chat_completions(request: Request, username: str, body: ChatCompl
)
handler = get_user_handler('rotation', None)
body_dict['model'] = actual_model
return await handler.handle_rotation_request(actual_model, body_dict)
token_id = getattr(request.state, 'token_id', None)
return await handler.handle_rotation_request(actual_model, body_dict, user_id, token_id)
# Handle global provider
if provider_id in config.providers:
......@@ -11924,7 +12026,7 @@ async def dashboard_kilo_auth_status(request: Request):
# Check if authenticated
if auth.is_authenticated():
# Try to get a valid token (will refresh if needed)
token = auth.get_valid_token()
token = await auth.get_valid_token()
if token:
# Get token expiration info
expires_at = auth.credentials.get('expires', 0)
......@@ -12240,7 +12342,7 @@ async def dashboard_codex_auth_status(request: Request):
# Check if authenticated
if auth.is_authenticated():
# Try to get a valid token (will refresh if needed)
token = auth.get_valid_token()
token = await auth.get_valid_token_with_refresh()
if token:
# Get user email from ID token
email = auth.get_user_email()
......
......@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "aisbf"
version = "0.99.28"
version = "0.99.29"
description = "AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations"
readme = "README.md"
license = "GPL-3.0-or-later"
......
......@@ -49,7 +49,7 @@ class InstallCommand(_install):
setup(
name="aisbf",
version="0.99.28",
version="0.99.29",
author="AISBF Contributors",
author_email="stefy@nexlab.net",
description="AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations",
......
......@@ -25,7 +25,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; background: #1a1a2e; color: #e0e0e0; }
.container { max-width: 1320px; margin: 0 auto; padding: 20px; }
.container { max-width: 1452px; margin: 0 auto; padding: 20px; }
.header { background: #16213e; color: white; padding: 20px 0; margin-bottom: 30px; border-bottom: 2px solid #0f3460; }
.header h1 { font-size: 24px; font-weight: 600; display: inline-block; }
.header-actions { float: right; }
......
......@@ -18,6 +18,21 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% block title %}Analytics - AISBF Dashboard{% endblock %}
{% macro format_tokens(value) %}
{% if value is none or value == 0 %}0{% else %}
{% set val = value | float %}
{% if val >= 1000000000 %}
{{ "%.2f"|format(val / 1000000000) }}B
{% elif val >= 1000000 %}
{{ "%.2f"|format(val / 1000000) }}M
{% elif val >= 1000 %}
{{ "%.2f"|format(val / 1000) }}K
{% else %}
{{ value }}
{% endif %}
{% endif %}
{% endmacro %}
{% block content %}
<h2 style="margin-bottom: 30px;">Token Usage Analytics</h2>
......@@ -68,7 +83,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</span>
{% if date_range_usage %}
<span style="margin-left: 20px; color: #a0a0a0;">
| Total: {{ date_range_usage.total_tokens }} tokens | Estimated Cost: ${{ "%.2f"|format(date_range_usage.estimated_cost) }}
| Total: {{ format_tokens(date_range_usage.total_tokens) }} tokens | Estimated Cost: ${{ "%.2f"|format(date_range_usage.estimated_cost) }}
</span>
{% endif %}
</div>
......@@ -127,12 +142,30 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% if is_admin %}
<div style="flex: 1; min-width: 150px;">
<label style="display: block; margin-bottom: 5px; color: #a0a0a0; font-size: 14px;">User</label>
<select name="user_filter" style="width: 100%; padding: 10px; border-radius: 4px; background: #0f3460; color: white; border: 1px solid #2a4a7a;">
{% if available_users|length < 25 %}
<select name="user_filter" id="userFilterSelect" style="width: 100%; padding: 10px; border-radius: 4px; background: #0f3460; color: white; border: 1px solid #2a4a7a;">
<option value="">All Users</option>
{% for user in available_users %}
<option value="{{ user.id }}" {% if selected_user == user.id %}selected{% endif %}>{{ user.username }}{% if user.role == 'admin' %} (admin){% endif %}</option>
{% endfor %}
</select>
{% else %}
<div style="position: relative;">
<input type="text" id="userSearchInput" placeholder="Search users..." autocomplete="off"
style="width: 100%; padding: 10px; border-radius: 4px; background: #0f3460; color: white; border: 1px solid #2a4a7a;">
<input type="hidden" name="user_filter" id="userFilterValue" value="{{ selected_user or '' }}">
<div id="userSearchResults" style="display: none; position: absolute; top: 100%; left: 0; right: 0; background: #0f3460; border: 1px solid #2a4a7a; border-top: none; border-radius: 0 0 4px 4px; max-height: 300px; overflow-y: auto; z-index: 1000;">
</div>
</div>
{% endif %}
</div>
<div style="flex: 1; min-width: 150px; display: flex; align-items: center;">
<label style="display: flex; align-items: center; color: #e0e0e0; cursor: pointer; user-select: none;">
<input type="checkbox" name="global_only" value="1" id="globalOnlyCheckbox" {% if global_only == '1' %}checked{% endif %}
style="margin-right: 8px; width: 18px; height: 18px; cursor: pointer;">
<span style="font-size: 14px;">Global requests only</span>
</label>
</div>
{% endif %}
......@@ -174,6 +207,195 @@ document.getElementById('timeRangeSelect').addEventListener('change', function()
customRange.style.display = 'none';
}
});
// Handle global_only checkbox for select dropdown (when < 25 users)
{% if is_admin and available_users|length < 25 %}
(function() {
const userSelect = document.getElementById('userFilterSelect');
const globalOnlyCheckbox = document.getElementById('globalOnlyCheckbox');
if (globalOnlyCheckbox && userSelect) {
globalOnlyCheckbox.addEventListener('change', function() {
if (this.checked) {
// When global_only is checked, clear user filter and disable select
userSelect.value = '';
userSelect.disabled = true;
userSelect.style.opacity = '0.5';
userSelect.style.cursor = 'not-allowed';
} else {
// Re-enable user select
userSelect.disabled = false;
userSelect.style.opacity = '1';
userSelect.style.cursor = 'pointer';
}
});
// Initialize state on page load
if (globalOnlyCheckbox.checked) {
userSelect.disabled = true;
userSelect.style.opacity = '0.5';
userSelect.style.cursor = 'not-allowed';
}
}
})();
{% endif %}
// User search autocomplete functionality
{% if is_admin and available_users|length >= 25 %}
(function() {
const searchInput = document.getElementById('userSearchInput');
const resultsDiv = document.getElementById('userSearchResults');
const hiddenInput = document.getElementById('userFilterValue');
let debounceTimer;
let selectedUserId = {{ selected_user or 'null' }};
// Set initial display value if a user is selected
{% if selected_user %}
{% for user in available_users %}
{% if user.id == selected_user %}
searchInput.value = '{{ user.username }}{% if user.role == "admin" %} (admin){% endif %}';
{% endif %}
{% endfor %}
{% endif %}
// Search users via API
async function searchUsers(query) {
try {
const response = await fetch(`/api/users/search?q=${encodeURIComponent(query)}`);
const data = await response.json();
return data.users || [];
} catch (error) {
console.error('Error searching users:', error);
return [];
}
}
// Display search results
function displayResults(users) {
if (users.length === 0) {
resultsDiv.innerHTML = '<div style="padding: 10px; color: #a0a0a0;">No users found</div>';
resultsDiv.style.display = 'block';
return;
}
resultsDiv.innerHTML = users.map(user => `
<div class="user-result-item" data-user-id="${user.id}" data-username="${user.username}" data-role="${user.role}"
style="padding: 10px; cursor: pointer; border-bottom: 1px solid #2a4a7a;">
${user.username}${user.role === 'admin' ? ' <span style="color: #60a5fa;">(admin)</span>' : ''}
</div>
`).join('');
resultsDiv.style.display = 'block';
// Add click handlers
document.querySelectorAll('.user-result-item').forEach(item => {
item.addEventListener('mouseenter', function() {
this.style.background = '#1a4d7a';
});
item.addEventListener('mouseleave', function() {
this.style.background = '';
});
item.addEventListener('click', function() {
const userId = this.dataset.userId;
const username = this.dataset.username;
const role = this.dataset.role;
searchInput.value = username + (role === 'admin' ? ' (admin)' : '');
hiddenInput.value = userId;
selectedUserId = userId;
resultsDiv.style.display = 'none';
});
});
}
// Handle input
searchInput.addEventListener('input', function() {
const query = this.value.trim();
clearTimeout(debounceTimer);
if (query.length === 0) {
// Show all users option
resultsDiv.innerHTML = '<div class="user-result-item" data-user-id="" data-username="All Users" style="padding: 10px; cursor: pointer; border-bottom: 1px solid #2a4a7a;">All Users</div>';
resultsDiv.style.display = 'block';
document.querySelector('.user-result-item').addEventListener('mouseenter', function() {
this.style.background = '#1a4d7a';
});
document.querySelector('.user-result-item').addEventListener('mouseleave', function() {
this.style.background = '';
});
document.querySelector('.user-result-item').addEventListener('click', function() {
searchInput.value = '';
hiddenInput.value = '';
selectedUserId = null;
resultsDiv.style.display = 'none';
});
return;
}
debounceTimer = setTimeout(async () => {
const users = await searchUsers(query);
displayResults(users);
}, 300);
});
// Handle focus
searchInput.addEventListener('focus', function() {
if (this.value.trim().length === 0) {
resultsDiv.innerHTML = '<div class="user-result-item" data-user-id="" data-username="All Users" style="padding: 10px; cursor: pointer; border-bottom: 1px solid #2a4a7a;">All Users</div>';
resultsDiv.style.display = 'block';
document.querySelector('.user-result-item').addEventListener('mouseenter', function() {
this.style.background = '#1a4d7a';
});
document.querySelector('.user-result-item').addEventListener('mouseleave', function() {
this.style.background = '';
});
document.querySelector('.user-result-item').addEventListener('click', function() {
searchInput.value = '';
hiddenInput.value = '';
selectedUserId = null;
resultsDiv.style.display = 'none';
});
}
});
// Close results when clicking outside
document.addEventListener('click', function(e) {
if (!searchInput.contains(e.target) && !resultsDiv.contains(e.target)) {
resultsDiv.style.display = 'none';
}
});
// Handle global_only checkbox interaction
const globalOnlyCheckbox = document.getElementById('globalOnlyCheckbox');
if (globalOnlyCheckbox) {
globalOnlyCheckbox.addEventListener('change', function() {
if (this.checked) {
// When global_only is checked, clear user filter
searchInput.value = '';
hiddenInput.value = '';
selectedUserId = null;
searchInput.disabled = true;
searchInput.style.opacity = '0.5';
searchInput.style.cursor = 'not-allowed';
} else {
// Re-enable user search
searchInput.disabled = false;
searchInput.style.opacity = '1';
searchInput.style.cursor = 'text';
}
});
// Initialize state on page load
if (globalOnlyCheckbox.checked) {
searchInput.disabled = true;
searchInput.style.opacity = '0.5';
searchInput.style.cursor = 'not-allowed';
}
}
})();
{% endif %}
</script>
{% if recommendations %}
......@@ -203,6 +425,9 @@ document.getElementById('timeRangeSelect').addEventListener('change', function()
<th>Errors</th>
<th>Error Rate</th>
<th>Avg Latency</th>
<th>Input Tokens</th>
<th>Output Tokens</th>
<th>Total Tokens</th>
<th>Tokens/Min</th>
<th>Tokens/Hour</th>
<th>Tokens/Day</th>
......@@ -219,11 +444,50 @@ document.getElementById('timeRangeSelect').addEventListener('change', function()
<td {% if provider.avg_latency_ms > 5000 %}style="color: #fcd34d;"{% endif %}>
{% if provider.avg_latency_ms > 1000 %}{{ "%.1f"|format(provider.avg_latency_ms / 1000) }}s{% else %}{{ "%.0f"|format(provider.avg_latency_ms) }}ms{% endif %}
</td>
<td>{{ provider.tokens.TPM }}</td>
<td>{{ provider.tokens.TPH }}</td>
<td>{{ provider.tokens.TPD }}</td>
<td><strong>{{ format_tokens(provider.tokens.prompt or 0) }}</strong></td>
<td><strong>{{ format_tokens(provider.tokens.completion or 0) }}</strong></td>
<td><strong>{{ format_tokens(provider.tokens.total or 0) }}</strong></td>
<td>{{ format_tokens(provider.tokens.TPM) }}</td>
<td>{{ format_tokens(provider.tokens.TPH) }}</td>
<td>{{ format_tokens(provider.tokens.TPD) }}</td>
</tr>
{% endfor %}
{% if provider_stats %}
<tr style="background: #0f3460; font-weight: bold;">
<td><strong>Total</strong></td>
<td>{{ provider_stats | sum(attribute='requests.total') }}</td>
<td>{{ provider_stats | sum(attribute='requests.success') }}</td>
<td>{{ provider_stats | sum(attribute='requests.error') }}</td>
<td>
{% set total_requests = provider_stats | sum(attribute='requests.total') %}
{% set total_errors = provider_stats | sum(attribute='requests.error') %}
{% if total_requests > 0 %}
{{ "%.1f"|format((total_errors / total_requests) * 100) }}%
{% else %}
0.0%
{% endif %}
</td>
<td>
{% set total_requests = provider_stats | sum(attribute='requests.total') %}
{% if total_requests > 0 %}
{% set weighted_sum = namespace(value=0) %}
{% for provider in provider_stats %}
{% set weighted_sum.value = weighted_sum.value + (provider.avg_latency_ms * provider.requests.total) %}
{% endfor %}
{% set avg_latency = weighted_sum.value / total_requests %}
{% if avg_latency > 1000 %}{{ "%.1f"|format(avg_latency / 1000) }}s{% else %}{{ "%.0f"|format(avg_latency) }}ms{% endif %}
{% else %}
N/A
{% endif %}
</td>
<td><strong>{{ format_tokens(provider_stats | sum(attribute='tokens.prompt') or 0) }}</strong></td>
<td><strong>{{ format_tokens(provider_stats | sum(attribute='tokens.completion') or 0) }}</strong></td>
<td><strong>{{ format_tokens(provider_stats | sum(attribute='tokens.total') or 0) }}</strong></td>
<td>{{ format_tokens(provider_stats | sum(attribute='tokens.TPM')) }}</td>
<td>{{ format_tokens(provider_stats | sum(attribute='tokens.TPH')) }}</td>
<td>{{ format_tokens(provider_stats | sum(attribute='tokens.TPD')) }}</td>
</tr>
{% endif %}
</table>
{% else %}
<p style="color: #a0a0a0;">No provider statistics available yet. Make API requests to see analytics.</p>
......@@ -246,7 +510,7 @@ document.getElementById('timeRangeSelect').addEventListener('change', function()
<div style="background: #0f3460; padding: 15px; border-radius: 8px;">
<h4 style="font-size: 14px; margin-bottom: 5px;">{{ pc.provider_id }}</h4>
<p style="font-size: 20px; font-weight: bold;">${{ "%.2f"|format(pc.estimated_cost) }}</p>
<small style="color: #a0a0a0;">{{ pc.tokens_today }} tokens</small>
<small style="color: #a0a0a0;">{{ format_tokens(pc.tokens_today) }} tokens</small>
</div>
{% endfor %}
</div>
......@@ -281,7 +545,7 @@ document.getElementById('timeRangeSelect').addEventListener('change', function()
<td>{{ model.context_size|default('N/A') }}</td>
<td>{{ model.condense_context|default('N/A') }}%</td>
<td>{{ model.condense_method|default('None') }}</td>
<td>{{ model.tokens_per_day }}</td>
<td>{{ format_tokens(model.tokens_per_day) }}</td>
<td {% if model.error_rate > 0.1 %}style="color: #f87171;"{% endif %}>
{{ "%.1f"|format(model.error_rate * 100) }}%
</td>
......
......@@ -21,7 +21,7 @@
<!-- Paid Tiers -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; margin-bottom: 40px;">
{% for tier in tiers %}
{% if tier.is_active and not tier.is_default %}
{% if not tier.is_default %}
<div class="pricing-card {% if tier.is_recommended %}recommended{% endif %}">
{% if tier.is_recommended %}
<div style="background: #4a9eff; color: white; padding: 8px 15px; border-radius: 20px; text-align: center; margin-bottom: 20px; font-weight: bold; text-transform: uppercase; font-size: 12px;">
......
......@@ -2,6 +2,21 @@
{% block title %}User Dashboard - AISBF{% endblock %}
{% macro format_tokens(value) %}
{% if value is none or value == 0 %}0{% else %}
{% set val = value | float %}
{% if val >= 1000000000 %}
{{ "%.2f"|format(val / 1000000000) }}B
{% elif val >= 1000000 %}
{{ "%.2f"|format(val / 1000000) }}M
{% elif val >= 1000 %}
{{ "%.2f"|format(val / 1000) }}K
{% else %}
{{ value }}
{% endif %}
{% endif %}
{% endmacro %}
{% block content %}
<div class="container">
<h1>User Dashboard</h1>
......@@ -98,7 +113,7 @@
<div class="stats-grid">
<div class="stat-item">
<h3>Total Tokens Used</h3>
<p class="stat-value">{{ usage_stats.total_tokens|default(0) }}</p>
<p class="stat-value">{{ format_tokens(usage_stats.total_tokens|default(0)) }}</p>
</div>
<div class="stat-item">
<h3>Requests Today</h3>
......
......@@ -229,23 +229,60 @@ function updateUserTier(userId, tierId) {
.then(response => response.json())
.then(data => {
if (data.success) {
// Show success message briefly
const msg = document.createElement('div');
msg.style.cssText = 'position: fixed; top: 20px; right: 20px; background: #4ade80; color: #000; padding: 15px 20px; border-radius: 5px; z-index: 9999;';
msg.textContent = 'Tier updated successfully';
document.body.appendChild(msg);
setTimeout(() => msg.remove(), 2000);
// Show success notification
showNotification('Tier updated successfully', 'success');
} else {
alert(data.error || 'Failed to update tier');
// Show error notification
showNotification(data.error || 'Failed to update tier', 'error');
location.reload(); // Reload to reset dropdown
}
})
.catch(error => {
alert('Error: ' + error);
showNotification('Error: ' + error, 'error');
location.reload(); // Reload to reset dropdown
});
}
function showNotification(message, type) {
// Remove any existing notifications
const existingNotifications = document.querySelectorAll('.notification-toast');
existingNotifications.forEach(notification => notification.remove());
// Create new notification
const notification = document.createElement('div');
notification.className = `notification-toast alert alert-${type === 'success' ? 'success' : 'error'}`;
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
min-width: 300px;
max-width: 500px;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
border: none;
animation: slideIn 0.3s ease-out;
`;
notification.innerHTML = `
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fas fa-${type === 'success' ? 'check-circle' : 'exclamation-triangle'}" style="font-size: 18px;"></i>
<span>${message}</span>
</div>
`;
document.body.appendChild(notification);
// Auto-remove after 3 seconds
setTimeout(() => {
notification.style.animation = 'slideOut 0.3s ease-in';
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 300);
}, 3000);
}
// Close modal when clicking outside
window.onclick = function(event) {
const modal = document.getElementById('edit-modal');
......@@ -269,6 +306,43 @@ th {
background: #0f3460;
font-weight: 600;
}
/* Notification animations */
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
/* Custom alert styles for dark theme */
.alert-success {
background-color: #10b981 !important;
color: #ffffff !important;
border-color: #059669 !important;
}
.alert-error {
background-color: #ef4444 !important;
color: #ffffff !important;
border-color: #dc2626 !important;
}
/* Prevent browser autofill from overriding dark theme */
input:-webkit-autofill,
input:-webkit-autofill:hover,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment