Commit fe8b625a authored by Your Name's avatar Your Name

Analytics system

parent 823291c7
...@@ -54,7 +54,7 @@ from .auth.qwen import QwenOAuth2 ...@@ -54,7 +54,7 @@ from .auth.qwen import QwenOAuth2
from .handlers import RequestHandler, RotationHandler, AutoselectHandler from .handlers import RequestHandler, RotationHandler, AutoselectHandler
from .utils import count_messages_tokens, split_messages_into_chunks, get_max_request_tokens_for_model 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__ = [ __all__ = [
# Config # Config
"config", "config",
......
...@@ -121,7 +121,10 @@ class Analytics: ...@@ -121,7 +121,10 @@ class Analytics:
rotation_id: Optional[str] = None, rotation_id: Optional[str] = None,
autoselect_id: Optional[str] = None, autoselect_id: Optional[str] = None,
user_id: Optional[int] = 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. Record a request for analytics.
...@@ -137,6 +140,9 @@ class Analytics: ...@@ -137,6 +140,9 @@ class Analytics:
autoselect_id: Optional autoselect identifier if request went through autoselect autoselect_id: Optional autoselect identifier if request went through autoselect
user_id: Optional user ID if request was made with API token 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 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 # Initialize provider tracking if needed
if provider_id not in self._request_counts: if provider_id not in self._request_counts:
...@@ -161,7 +167,8 @@ class Analytics: ...@@ -161,7 +167,8 @@ class Analytics:
# Persist to database # Persist to database
if tokens_used > 0: 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 # Also record user token usage if this was an API token request
if user_id is not None and token_id is not None: if user_id is not None and token_id is not None:
...@@ -231,16 +238,32 @@ class Analytics: ...@@ -231,16 +238,32 @@ class Analytics:
placeholder = '?' if self.db.db_type == 'sqlite' else '%s' placeholder = '?' if self.db.db_type == 'sqlite' else '%s'
# Build query with optional user filter # 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)
params = [provider_id, from_datetime.isoformat(), to_datetime.isoformat()] # user_filter = None means "all requests"
if user_filter is not None: # user_filter = <id> means "only requests from that user"
params.append(user_filter) 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()]
# Get token usage and actual time range of requests # Get token usage and actual time range of requests
cursor.execute(f''' cursor.execute(f'''
SELECT SELECT
COUNT(*) as total_requests, 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(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, MIN(timestamp) as first_request,
MAX(timestamp) as last_request MAX(timestamp) as last_request
FROM token_usage FROM token_usage
...@@ -252,9 +275,17 @@ class Analytics: ...@@ -252,9 +275,17 @@ class Analytics:
result = cursor.fetchone() result = cursor.fetchone()
total_requests = result[0] if result else 0 total_requests = result[0] if result else 0
total_tokens = result[1] if result else 0 success_count = result[1] if result else 0
first_request = result[2] if result and result[2] else None error_count = result[2] if result else 0
last_request = result[3] if result and result[3] else None 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 # Calculate time-based rates using ACTUAL usage window, not query window
# This gives more accurate rate limiting metrics # This gives more accurate rate limiting metrics
...@@ -289,15 +320,18 @@ class Analytics: ...@@ -289,15 +320,18 @@ class Analytics:
return { return {
'provider_id': provider_id, 'provider_id': provider_id,
'requests': {'total': total_requests, 'success': total_requests, 'error': 0}, 'requests': {'total': total_requests, 'success': success_count, 'error': error_count},
'latency': {'count': 0, 'total_ms': 0.0, 'min_ms': 0, 'max_ms': 0}, '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': {}, 'errors': {}, # Could be populated from error_type column if needed
'tokens': { 'tokens': {
'total': total_tokens, 'total': total_tokens,
'prompt': total_prompt_tokens,
'completion': total_completion_tokens,
'TPM': tpm, 'TPM': tpm,
'TPH': tph, 'TPH': tph,
'TPD': tpd 'TPD': tpd
} },
'actual_cost': total_actual_cost # Actual cost from provider responses
} }
def get_token_usage_by_date_range( def get_token_usage_by_date_range(
...@@ -374,7 +408,7 @@ class Analytics: ...@@ -374,7 +408,7 @@ class Analytics:
Args: Args:
from_datetime: Optional start datetime for filtering from_datetime: Optional start datetime for filtering
to_datetime: Optional end 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: Returns:
List of provider statistics dictionaries List of provider statistics dictionaries
...@@ -388,10 +422,15 @@ class Analytics: ...@@ -388,10 +422,15 @@ class Analytics:
placeholder = '?' if self.db.db_type == 'sqlite' else '%s' placeholder = '?' if self.db.db_type == 'sqlite' else '%s'
# Build query with optional user filter # Build query with optional user filter
user_condition = f" AND user_id = {placeholder}" if user_filter is not None else "" if user_filter == -1:
params = [start.isoformat(), end.isoformat()] user_condition = " AND user_id IS NULL"
if user_filter is not None: params = [start.isoformat(), end.isoformat()]
params.append(user_filter) 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()]
cursor.execute(f''' cursor.execute(f'''
SELECT DISTINCT provider_id SELECT DISTINCT provider_id
...@@ -503,7 +542,18 @@ class Analytics: ...@@ -503,7 +542,18 @@ class Analytics:
time_bucket_expr = f"DATE_FORMAT(timestamp, '{date_format}')" time_bucket_expr = f"DATE_FORMAT(timestamp, '{date_format}')"
if provider_id: 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''' cursor.execute(f'''
SELECT SELECT
{time_bucket_expr} as time_bucket, {time_bucket_expr} as time_bucket,
...@@ -524,7 +574,19 @@ class Analytics: ...@@ -524,7 +574,19 @@ class Analytics:
ORDER BY time_bucket ORDER BY time_bucket
''', (provider_id, cutoff.isoformat(), end_time.isoformat())) ''', (provider_id, cutoff.isoformat(), end_time.isoformat()))
else: 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''' cursor.execute(f'''
SELECT SELECT
{time_bucket_expr} as time_bucket, {time_bucket_expr} as time_bucket,
...@@ -706,7 +768,8 @@ class Analytics: ...@@ -706,7 +768,8 @@ class Analytics:
self, self,
provider_id: str, provider_id: str,
tokens_used: int, tokens_used: int,
prompt_tokens: Optional[int] = None prompt_tokens: Optional[int] = None,
completion_tokens: Optional[int] = None
) -> float: ) -> float:
""" """
Estimate cost for token usage. Estimate cost for token usage.
...@@ -715,6 +778,7 @@ class Analytics: ...@@ -715,6 +778,7 @@ class Analytics:
provider_id: Provider identifier provider_id: Provider identifier
tokens_used: Total tokens used tokens_used: Total tokens used
prompt_tokens: Optional breakdown of prompt tokens prompt_tokens: Optional breakdown of prompt tokens
completion_tokens: Optional breakdown of completion tokens
Returns: Returns:
Estimated cost in USD Estimated cost in USD
...@@ -722,14 +786,19 @@ class Analytics: ...@@ -722,14 +786,19 @@ class Analytics:
# Get provider-specific pricing (checks subscription status and custom pricing) # Get provider-specific pricing (checks subscription status and custom pricing)
provider_pricing = self._get_provider_pricing(provider_id) provider_pricing = self._get_provider_pricing(provider_id)
# Calculate cost # Calculate cost with actual token breakdown if available
if prompt_tokens is not None: if prompt_tokens is not None and completion_tokens is not None:
completion_tokens = tokens_used - prompt_tokens
prompt_cost = (prompt_tokens / 1_000_000) * provider_pricing.get('prompt', 0) prompt_cost = (prompt_tokens / 1_000_000) * provider_pricing.get('prompt', 0)
completion_cost = (completion_tokens / 1_000_000) * provider_pricing.get('completion', 0) completion_cost = (completion_tokens / 1_000_000) * provider_pricing.get('completion', 0)
return prompt_cost + completion_cost 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: 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 prompt_tokens_est = tokens_used * 0.25
completion_tokens_est = tokens_used * 0.75 completion_tokens_est = tokens_used * 0.75
prompt_cost = (prompt_tokens_est / 1_000_000) * provider_pricing.get('prompt', 0) prompt_cost = (prompt_tokens_est / 1_000_000) * provider_pricing.get('prompt', 0)
...@@ -776,13 +845,24 @@ class Analytics: ...@@ -776,13 +845,24 @@ class Analytics:
tokens = provider['tokens'] tokens = provider['tokens']
total_tokens = tokens['TPD'] # Use daily tokens for cost estimation 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 total_cost += cost
provider_costs.append({ provider_costs.append({
'provider_id': provider_id, 'provider_id': provider_id,
'tokens_today': total_tokens, 'tokens_today': total_tokens,
'estimated_cost': cost 'estimated_cost': cost,
'is_actual': actual_cost > 0
}) })
return { return {
......
...@@ -27,6 +27,7 @@ import hashlib ...@@ -27,6 +27,7 @@ import hashlib
import base64 import base64
import webbrowser import webbrowser
import time import time
import asyncio
import httpx import httpx
from pathlib import Path from pathlib import Path
from typing import Optional, Dict from typing import Optional, Dict
...@@ -223,7 +224,7 @@ class ClaudeAuth: ...@@ -223,7 +224,7 @@ class ClaudeAuth:
) )
return response 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. Use the refresh token to get a new access token without logging in.
...@@ -270,7 +271,7 @@ class ClaudeAuth: ...@@ -270,7 +271,7 @@ class ClaudeAuth:
# Rate limited - wait and retry with exponential backoff # Rate limited - wait and retry with exponential backoff
wait_time = (2 ** attempt) * 5 # 5, 10, 20 seconds 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}") 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 continue
else: else:
logger.error(f"Token refresh failed: {response.status_code} - {response.text}") logger.error(f"Token refresh failed: {response.status_code} - {response.text}")
...@@ -280,14 +281,14 @@ class ClaudeAuth: ...@@ -280,14 +281,14 @@ class ClaudeAuth:
if attempt < max_retries - 1: if attempt < max_retries - 1:
wait_time = (2 ** attempt) * 5 wait_time = (2 ** attempt) * 5
logger.info(f"Retrying in {wait_time} seconds...") logger.info(f"Retrying in {wait_time} seconds...")
time.sleep(wait_time) await asyncio.sleep(wait_time)
continue continue
return False return False
logger.error(f"Token refresh failed after {max_retries} attempts") logger.error(f"Token refresh failed after {max_retries} attempts")
return False 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. Get a valid access token, refreshing it if necessary.
...@@ -311,7 +312,7 @@ class ClaudeAuth: ...@@ -311,7 +312,7 @@ class ClaudeAuth:
# Refresh if less than 5 minutes remain # Refresh if less than 5 minutes remain
if time.time() > (self.tokens.get('expires_at', 0) - 300): if time.time() > (self.tokens.get('expires_at', 0) - 300):
logger.info("Token expiring soon, refreshing...") logger.info("Token expiring soon, refreshing...")
if not self.refresh_token(): if not await self.refresh_token():
if not auto_login: if not auto_login:
logger.error("Token refresh failed and auto_login is disabled") 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.") raise Exception("Claude token refresh failed. Please re-authenticate via /dashboard/claude/auth/start or MCP tool.")
...@@ -540,7 +541,7 @@ class ClaudeAuth: ...@@ -540,7 +541,7 @@ class ClaudeAuth:
logger.info("OAuth2 login flow completed successfully") 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. Exchange authorization code for access tokens.
Matches CLIProxyAPI implementation exactly. Matches CLIProxyAPI implementation exactly.
...@@ -621,7 +622,7 @@ class ClaudeAuth: ...@@ -621,7 +622,7 @@ class ClaudeAuth:
# Rate limited - wait and retry with exponential backoff # Rate limited - wait and retry with exponential backoff
wait_time = (2 ** attempt) * 5 # 5, 10, 20 seconds 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}") 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 continue
else: else:
logger.error(f"Token exchange failed: {response.status_code} - {response.text}") logger.error(f"Token exchange failed: {response.status_code} - {response.text}")
...@@ -631,7 +632,7 @@ class ClaudeAuth: ...@@ -631,7 +632,7 @@ class ClaudeAuth:
if attempt < max_retries - 1: if attempt < max_retries - 1:
wait_time = (2 ** attempt) * 5 wait_time = (2 ** attempt) * 5
logger.info(f"Retrying in {wait_time} seconds...") logger.info(f"Retrying in {wait_time} seconds...")
time.sleep(wait_time) await asyncio.sleep(wait_time)
continue continue
return False return False
...@@ -652,25 +653,29 @@ class ClaudeAuth: ...@@ -652,25 +653,29 @@ class ClaudeAuth:
# Example usage # Example usage
if __name__ == "__main__": if __name__ == "__main__":
import asyncio
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
auth = ClaudeAuth() async def main():
token = auth.get_valid_token() auth = ClaudeAuth()
token = auth.get_valid_token()
# Use the token for an API call
client = httpx.Client() # Use the token for an API call
response = client.post( async with httpx.AsyncClient() as client:
"https://api.anthropic.com/v1/messages", response = await client.post(
headers={ "https://api.anthropic.com/v1/messages",
"Authorization": f"Bearer {token}", headers={
"anthropic-version": "2023-06-01", "Authorization": f"Bearer {token}",
"anthropic-beta": "claude-code-20250219", # Required for subscription usage "anthropic-version": "2023-06-01",
"Content-Type": "application/json" "anthropic-beta": "claude-code-20250219", # Required for subscription usage
}, "Content-Type": "application/json"
json={ },
"model": "claude-3-7-sonnet-20250219", json={
"max_tokens": 1024, "model": "claude-3-7-sonnet-20250219",
"messages": [{"role": "user", "content": "How's the weather in the CLI today?"}] "max_tokens": 1024,
} "messages": [{"role": "user", "content": "How's the weather in the CLI today?"}]
) }
print(response.json()) )
print(response.json())
asyncio.run(main())
...@@ -152,16 +152,17 @@ class QwenOAuth2: ...@@ -152,16 +152,17 @@ class QwenOAuth2:
return code_verifier, code_challenge 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. Acquire a file lock to prevent concurrent token refreshes.
Returns: Returns:
True if lock acquired, False otherwise. True if lock acquired, False otherwise.
""" """
import asyncio
lock_id = str(uuid.uuid4()) lock_id = str(uuid.uuid4())
interval = 0.1 interval = 0.1
for _ in range(max_attempts): for _ in range(max_attempts):
try: try:
# Try to create lock file atomically (exclusive mode) # Try to create lock file atomically (exclusive mode)
...@@ -173,7 +174,7 @@ class QwenOAuth2: ...@@ -173,7 +174,7 @@ class QwenOAuth2:
try: try:
stat = os.stat(self.lock_file) stat = os.stat(self.lock_file)
lock_age = time.time() - stat.st_mtime lock_age = time.time() - stat.st_mtime
if lock_age > LOCK_TIMEOUT_MS / 1000: if lock_age > LOCK_TIMEOUT_MS / 1000:
# Remove stale lock # Remove stale lock
os.unlink(self.lock_file) os.unlink(self.lock_file)
...@@ -181,10 +182,10 @@ class QwenOAuth2: ...@@ -181,10 +182,10 @@ class QwenOAuth2:
except (OSError, FileNotFoundError): except (OSError, FileNotFoundError):
# Lock might have been removed by another process # Lock might have been removed by another process
continue continue
time.sleep(interval) await asyncio.sleep(interval)
interval = min(interval * 1.5, 2.0) # Exponential backoff interval = min(interval * 1.5, 2.0) # Exponential backoff
return False return False
def _release_lock(self) -> None: def _release_lock(self) -> None:
...@@ -461,7 +462,7 @@ class QwenOAuth2: ...@@ -461,7 +462,7 @@ class QwenOAuth2:
logger.info("QwenOAuth2: Refreshing access token...") logger.info("QwenOAuth2: Refreshing access token...")
# Acquire lock to prevent concurrent refreshes # 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") logger.error("QwenOAuth2: Failed to acquire lock for token refresh")
return False return False
......
...@@ -534,7 +534,7 @@ class ContextManager: ...@@ -534,7 +534,7 @@ class ContextManager:
"max_tokens": 1000, "max_tokens": 1000,
"stream": False "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): if isinstance(response, dict):
summary_content = response.get('choices', [{}])[0].get('message', {}).get('content', '') summary_content = response.get('choices', [{}])[0].get('message', {}).get('content', '')
else: else:
...@@ -642,7 +642,7 @@ Provide only the relevant information in a concise format.""" ...@@ -642,7 +642,7 @@ Provide only the relevant information in a concise format."""
"max_tokens": 2000, "max_tokens": 2000,
"stream": False "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): if isinstance(response, dict):
pruned_content = response.get('choices', [{}])[0].get('message', {}).get('content', '') pruned_content = response.get('choices', [{}])[0].get('message', {}).get('content', '')
else: 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 ...@@ -27,6 +27,8 @@ from pathlib import Path
from typing import Dict, List, Optional, Tuple, Any from typing import Dict, List, Optional, Tuple, Any
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
import asyncio
from concurrent.futures import ThreadPoolExecutor
try: try:
import mysql.connector as _mysql_connector import mysql.connector as _mysql_connector
...@@ -37,12 +39,23 @@ except ImportError: ...@@ -37,12 +39,23 @@ except ImportError:
logger = logging.getLogger(__name__) 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: class DatabaseManager:
""" """
Manages database for persistent tracking of context dimensions and rate limiting. Manages database for persistent tracking of context dimensions and rate limiting.
Supports both SQLite and MySQL databases. 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): def __init__(self, db_config: Optional[Dict[str, Any]] = None):
...@@ -69,7 +82,7 @@ class DatabaseManager: ...@@ -69,7 +82,7 @@ class DatabaseManager:
self.db_config = db_config self.db_config = db_config
self.db_type = self.db_config.get('type', 'sqlite').lower() self.db_type = self.db_config.get('type', 'sqlite').lower()
self.executor = get_db_executor()
if self.db_type == 'mysql': if self.db_type == 'mysql':
if not MYSQL_AVAILABLE: if not MYSQL_AVAILABLE:
...@@ -98,8 +111,59 @@ class DatabaseManager: ...@@ -98,8 +111,59 @@ class DatabaseManager:
raise raise
else: else:
raise ValueError(f"Unsupported database type: {self.db_type}") 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( def record_context_dimension(
self, self,
...@@ -217,27 +281,44 @@ class DatabaseManager: ...@@ -217,27 +281,44 @@ class DatabaseManager:
provider_id: str, provider_id: str,
model_name: str, model_name: str,
tokens_used: int, 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: Args:
provider_id: The provider identifier provider_id: The provider identifier
model_name: The model name 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 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: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s' 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''' cursor.execute(f'''
INSERT INTO token_usage (user_id, provider_id, model_name, tokens_used, timestamp) 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}, CURRENT_TIMESTAMP) VALUES ({placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, CURRENT_TIMESTAMP)
''', (user_id, provider_id, model_name, tokens_used)) ''', (user_id, provider_id, model_name, tokens_used, prompt_tokens, completion_tokens, actual_cost, success, latency_int, error_type, token_id))
conn.commit() 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( def get_token_usage(
self, self,
...@@ -2849,20 +2930,20 @@ def DatabaseManager__initialize_database(self): ...@@ -2849,20 +2930,20 @@ def DatabaseManager__initialize_database(self):
if self.database_type == DatabaseRegistry.TYPE_CONFIG: if self.database_type == DatabaseRegistry.TYPE_CONFIG:
# ONLY CREATE CONFIG TABLES IN CONFIG DATABASE # ONLY CREATE CONFIG TABLES IN CONFIG DATABASE
# Create context_dimensions table for tracking context usage # Create context_dimensions table for tracking context usage
cursor.execute(f''' # cursor.execute(f'''
CREATE TABLE IF NOT EXISTS context_dimensions ( # CREATE TABLE IF NOT EXISTS context_dimensions (
id INTEGER PRIMARY KEY {auto_increment}, # id INTEGER PRIMARY KEY {auto_increment},
provider_id VARCHAR(255) NOT NULL, # provider_id VARCHAR(255) NOT NULL,
model_name VARCHAR(255) NOT NULL, # model_name VARCHAR(255) NOT NULL,
context_size INTEGER, # context_size INTEGER,
condense_context INTEGER, # condense_context INTEGER,
condense_method TEXT, # condense_method TEXT,
effective_context INTEGER DEFAULT 0, # effective_context INTEGER DEFAULT 0,
last_updated TIMESTAMP DEFAULT {timestamp_default}, # last_updated TIMESTAMP DEFAULT {timestamp_default},
UNIQUE(provider_id, model_name) # UNIQUE(provider_id, model_name)
) # )
''') # ''')
#
# Create token_usage table for tracking rate limiting # Create token_usage table for tracking rate limiting
cursor.execute(f''' cursor.execute(f'''
CREATE TABLE IF NOT EXISTS token_usage ( CREATE TABLE IF NOT EXISTS token_usage (
...@@ -2871,714 +2952,513 @@ def DatabaseManager__initialize_database(self): ...@@ -2871,714 +2952,513 @@ def DatabaseManager__initialize_database(self):
provider_id VARCHAR(255) NOT NULL, provider_id VARCHAR(255) NOT NULL,
model_name VARCHAR(255) NOT NULL, model_name VARCHAR(255) NOT NULL,
tokens_used INTEGER NOT NULL, tokens_used INTEGER NOT NULL,
prompt_tokens INTEGER,
completion_tokens INTEGER,
actual_cost DECIMAL(10,6),
timestamp TIMESTAMP DEFAULT {timestamp_default} timestamp TIMESTAMP DEFAULT {timestamp_default}
) )
''') ''')
#
#
#
# Create indexes for better query performance # Create indexes for better query performance
try: # try:
cursor.execute(''' # cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_context_provider_model # CREATE INDEX IF NOT EXISTS idx_context_provider_model
ON context_dimensions(provider_id, model_name) # ON context_dimensions(provider_id, model_name)
''') # ''')
except: # except:
pass # Index might already exist # pass # Index might already exist
#
try: # try:
cursor.execute(''' # cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_token_provider_model # CREATE INDEX IF NOT EXISTS idx_token_provider_model
ON token_usage(provider_id, model_name) # ON token_usage(provider_id, model_name)
''') # ''')
except: # except:
pass # pass
#
try: # try:
cursor.execute(''' # cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_token_timestamp # CREATE INDEX IF NOT EXISTS idx_token_timestamp
ON token_usage(timestamp) # ON token_usage(timestamp)
''') # ''')
except: # except:
pass # pass
#
# Create model_embeddings table for caching vectorized model descriptions # Create model_embeddings table for caching vectorized model descriptions
cursor.execute(f''' # cursor.execute(f'''
CREATE TABLE IF NOT EXISTS model_embeddings ( # CREATE TABLE IF NOT EXISTS model_embeddings (
id INTEGER PRIMARY KEY {auto_increment}, # id INTEGER PRIMARY KEY {auto_increment},
provider_id VARCHAR(255) NOT NULL, # provider_id VARCHAR(255) NOT NULL,
model_name VARCHAR(255) NOT NULL, # model_name VARCHAR(255) NOT NULL,
description TEXT, # description TEXT,
embedding TEXT, # embedding TEXT,
last_updated TIMESTAMP DEFAULT {timestamp_default}, # last_updated TIMESTAMP DEFAULT {timestamp_default},
UNIQUE(provider_id, model_name) # UNIQUE(provider_id, model_name)
) # )
''') # ''')
#
try: # try:
cursor.execute(''' # cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_model_embeddings_provider_model # CREATE INDEX IF NOT EXISTS idx_model_embeddings_provider_model
ON model_embeddings(provider_id, model_name) # ON model_embeddings(provider_id, model_name)
''') # ''')
except: # except:
pass # pass
#
# Create users table for multi-user management # Create users table for multi-user management
cursor.execute(f''' # cursor.execute(f'''
CREATE TABLE IF NOT EXISTS users ( # CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY {auto_increment}, # id INTEGER PRIMARY KEY {auto_increment},
username VARCHAR(255) UNIQUE NOT NULL, # username VARCHAR(255) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE, # email VARCHAR(255) UNIQUE,
display_name VARCHAR(255), # display_name VARCHAR(255),
password_hash VARCHAR(255) NOT NULL, # password_hash VARCHAR(255) NOT NULL,
role VARCHAR(50) DEFAULT 'user', # role VARCHAR(50) DEFAULT 'user',
created_by VARCHAR(255), # created_by VARCHAR(255),
created_at TIMESTAMP DEFAULT {timestamp_default}, # created_at TIMESTAMP DEFAULT {timestamp_default},
last_login TIMESTAMP NULL, # last_login TIMESTAMP NULL,
is_active {boolean_type} DEFAULT 1, # is_active {boolean_type} DEFAULT 1,
email_verified {boolean_type} DEFAULT 0, # email_verified {boolean_type} DEFAULT 0,
verification_token VARCHAR(255), # verification_token VARCHAR(255),
verification_token_expires TIMESTAMP NULL, # verification_token_expires TIMESTAMP NULL,
last_verification_email_sent TIMESTAMP NULL # last_verification_email_sent TIMESTAMP NULL
) # )
''') # ''')
#
# Migration: Add display_name column if it doesn't exist # Migration: Add display_name column if it doesn't exist
try: # try:
# Check if display_name column exists # Check if display_name column exists
if self.db_type == 'sqlite': # if self.db_type == 'sqlite':
cursor.execute("PRAGMA table_info(users)") # cursor.execute("PRAGMA table_info(users)")
columns = [row[1] for row in cursor.fetchall()] # columns = [row[1] for row in cursor.fetchall()]
else: # else:
cursor.execute(""" # cursor.execute("""
SELECT COLUMN_NAME # SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS # FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'users' # WHERE TABLE_NAME = 'users'
""") # """)
columns = [row[0] for row in cursor.fetchall()] # columns = [row[0] for row in cursor.fetchall()]
#
if 'display_name' not in columns: # if 'display_name' not in columns:
logger.info("Adding display_name column to users table") # logger.info("Adding display_name column to users table")
cursor.execute("ALTER TABLE users ADD COLUMN display_name VARCHAR(255)") # cursor.execute("ALTER TABLE users ADD COLUMN display_name VARCHAR(255)")
conn.commit() # conn.commit()
#
# Populate display_name for existing users # Populate display_name for existing users
cursor.execute("UPDATE users SET display_name = username WHERE display_name IS NULL") # cursor.execute("UPDATE users SET display_name = username WHERE display_name IS NULL")
conn.commit() # conn.commit()
logger.info("Migration complete: display_name column added and populated") # logger.info("Migration complete: display_name column added and populated")
except Exception as e: # except Exception as e:
logger.warning(f"Migration warning (display_name): {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 - commented out to fix import
# User-specific configuration tables for multi-user isolation # cursor.execute(f'''
cursor.execute(f''' # CREATE TABLE IF NOT EXISTS user_providers (
CREATE TABLE IF NOT EXISTS user_providers ( # id INTEGER PRIMARY KEY {auto_increment},
id INTEGER PRIMARY KEY {auto_increment}, # user_id INTEGER NOT NULL,
user_id INTEGER NOT NULL, # provider_id VARCHAR(255) NOT NULL,
provider_id VARCHAR(255) NOT NULL, # config TEXT NOT NULL,
config TEXT NOT NULL, # created_at TIMESTAMP DEFAULT {timestamp_default},
created_at TIMESTAMP DEFAULT {timestamp_default}, # updated_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default}, # FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (user_id) REFERENCES users(id), # UNIQUE(user_id, provider_id)
UNIQUE(user_id, provider_id) # )
) # ''')
''') #
# cursor.execute(f'''
cursor.execute(f''' # CREATE TABLE IF NOT EXISTS user_rotations (
CREATE TABLE IF NOT EXISTS user_rotations ( # id INTEGER PRIMARY KEY {auto_increment},
id INTEGER PRIMARY KEY {auto_increment}, # user_id INTEGER NOT NULL,
user_id INTEGER NOT NULL, # rotation_id VARCHAR(255) NOT NULL,
rotation_id VARCHAR(255) NOT NULL, # config TEXT NOT NULL,
config TEXT NOT NULL, # created_at TIMESTAMP DEFAULT {timestamp_default},
created_at TIMESTAMP DEFAULT {timestamp_default}, # updated_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default}, # FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (user_id) REFERENCES users(id), # UNIQUE(user_id, rotation_id)
UNIQUE(user_id, rotation_id) # )
) # ''')
''') #
# cursor.execute(f'''
cursor.execute(f''' # CREATE TABLE IF NOT EXISTS user_autoselects (
CREATE TABLE IF NOT EXISTS user_autoselects ( # id INTEGER PRIMARY KEY {auto_increment},
id INTEGER PRIMARY KEY {auto_increment}, # user_id INTEGER NOT NULL,
user_id INTEGER NOT NULL, # autoselect_id VARCHAR(255) NOT NULL,
autoselect_id VARCHAR(255) NOT NULL, # config TEXT NOT NULL,
config TEXT NOT NULL, # created_at TIMESTAMP DEFAULT {timestamp_default},
created_at TIMESTAMP DEFAULT {timestamp_default}, # updated_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default}, # FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (user_id) REFERENCES users(id), # UNIQUE(user_id, autoselect_id)
UNIQUE(user_id, autoselect_id) # )
) # ''')
''') #
# cursor.execute(f'''
cursor.execute(f''' # CREATE TABLE IF NOT EXISTS user_prompts (
CREATE TABLE IF NOT EXISTS user_prompts ( # id INTEGER PRIMARY KEY {auto_increment},
id INTEGER PRIMARY KEY {auto_increment}, # user_id INTEGER NOT NULL,
user_id INTEGER NOT NULL, # prompt_key VARCHAR(255) NOT NULL,
prompt_key VARCHAR(255) NOT NULL, # content TEXT NOT NULL,
content TEXT NOT NULL, # created_at TIMESTAMP DEFAULT {timestamp_default},
created_at TIMESTAMP DEFAULT {timestamp_default}, # updated_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default}, # FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (user_id) REFERENCES users(id), # UNIQUE(user_id, prompt_key)
UNIQUE(user_id, prompt_key) # )
) # ''')
''') #
# cursor.execute(f'''
cursor.execute(f''' # CREATE TABLE IF NOT EXISTS user_api_tokens (
CREATE TABLE IF NOT EXISTS user_api_tokens ( # id INTEGER PRIMARY KEY {auto_increment},
id INTEGER PRIMARY KEY {auto_increment}, # user_id INTEGER NOT NULL,
user_id INTEGER NOT NULL, # token VARCHAR(255) UNIQUE NOT NULL,
token VARCHAR(255) UNIQUE NOT NULL, # description TEXT,
description TEXT, # created_at TIMESTAMP DEFAULT {timestamp_default},
created_at TIMESTAMP DEFAULT {timestamp_default}, # last_used TIMESTAMP NULL,
last_used TIMESTAMP NULL, # is_active {boolean_type} DEFAULT 1,
is_active {boolean_type} DEFAULT 1, # FOREIGN KEY (user_id) REFERENCES users(id)
FOREIGN KEY (user_id) REFERENCES users(id) # )
) # ''')
''') #
# cursor.execute(f'''
cursor.execute(f''' # CREATE TABLE IF NOT EXISTS user_token_usage (
CREATE TABLE IF NOT EXISTS user_token_usage ( # id INTEGER PRIMARY KEY {auto_increment},
id INTEGER PRIMARY KEY {auto_increment}, # user_id INTEGER NOT NULL,
user_id INTEGER NOT NULL, # token_id INTEGER,
token_id INTEGER, # provider_id VARCHAR(255) NOT NULL,
provider_id VARCHAR(255) NOT NULL, # model_name VARCHAR(255) NOT NULL,
model_name VARCHAR(255) NOT NULL, # tokens_used INTEGER NOT NULL,
tokens_used INTEGER NOT NULL, # timestamp TIMESTAMP DEFAULT {timestamp_default},
timestamp TIMESTAMP DEFAULT {timestamp_default}, # FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (user_id) REFERENCES users(id), # FOREIGN KEY (token_id) REFERENCES user_api_tokens(id)
FOREIGN KEY (token_id) REFERENCES user_api_tokens(id) # )
) # ''')
''') #
# Create user_auth_files table for storing authentication file metadata # Create user_auth_files table for storing authentication file metadata
cursor.execute(f''' # cursor.execute(f'''
CREATE TABLE IF NOT EXISTS user_auth_files ( # CREATE TABLE IF NOT EXISTS user_auth_files (
id INTEGER PRIMARY KEY {auto_increment}, # id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL, # user_id INTEGER NOT NULL,
provider_id VARCHAR(255) NOT NULL, # provider_id VARCHAR(255) NOT NULL,
file_type VARCHAR(50) NOT NULL, # file_type VARCHAR(50) NOT NULL,
original_filename VARCHAR(255) NOT NULL, # original_filename VARCHAR(255) NOT NULL,
stored_filename VARCHAR(255) NOT NULL, # stored_filename VARCHAR(255) NOT NULL,
file_path TEXT NOT NULL, # file_path TEXT NOT NULL,
file_size INTEGER, # file_size INTEGER,
mime_type VARCHAR(100), # mime_type VARCHAR(100),
created_at TIMESTAMP DEFAULT {timestamp_default}, # created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default}, # updated_at TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id), # FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(user_id, provider_id, file_type) # UNIQUE(user_id, provider_id, file_type)
) # )
''') # ''')
#
# Create user_oauth2_credentials table for storing OAuth2 tokens per user/provider # Create user_oauth2_credentials table for storing OAuth2 tokens per user/provider
cursor.execute(f''' # cursor.execute(f'''
CREATE TABLE IF NOT EXISTS user_oauth2_credentials ( # CREATE TABLE IF NOT EXISTS user_oauth2_credentials (
id INTEGER PRIMARY KEY {auto_increment}, # id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL, # user_id INTEGER NOT NULL,
provider_id VARCHAR(255) NOT NULL, # provider_id VARCHAR(255) NOT NULL,
auth_type VARCHAR(50) NOT NULL, # auth_type VARCHAR(50) NOT NULL,
credentials TEXT NOT NULL, # credentials TEXT NOT NULL,
created_at TIMESTAMP DEFAULT {timestamp_default}, # created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default}, # updated_at TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id), # FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(user_id, provider_id, auth_type) # UNIQUE(user_id, provider_id, auth_type)
) # )
''') # ''')
#
# ============================================== # ==============================================
# UNIVERSAL MIGRATIONS - RUN ON EVERY STARTUP # UNIVERSAL MIGRATIONS - RUN ON EVERY STARTUP
# ============================================== # ==============================================
logger.info("Running database migrations...") # logger.info("Running database migrations...")
#
# Migration: Create account_tiers table if missing # Migration: Create account_tiers table if missing
try: # try:
if self.db_type == 'sqlite': # if self.db_type == 'sqlite':
cursor.execute("PRAGMA table_info(account_tiers)") # cursor.execute("PRAGMA table_info(account_tiers)")
if not cursor.fetchall(): # if not cursor.fetchall():
cursor.execute(f''' # cursor.execute(f'''
CREATE TABLE account_tiers ( # CREATE TABLE account_tiers (
id INTEGER PRIMARY KEY {auto_increment}, # id INTEGER PRIMARY KEY {auto_increment},
name VARCHAR(255) UNIQUE NOT NULL, # name VARCHAR(255) UNIQUE NOT NULL,
description TEXT, # description TEXT,
price_monthly DECIMAL(10,2) DEFAULT 0.00, # price_monthly DECIMAL(10,2) DEFAULT 0.00,
price_yearly DECIMAL(10,2) DEFAULT 0.00, # price_yearly DECIMAL(10,2) DEFAULT 0.00,
is_default {boolean_type} DEFAULT 0, # is_default {boolean_type} DEFAULT 0,
is_active {boolean_type} DEFAULT 1, # is_active {boolean_type} DEFAULT 1,
max_requests_per_day INTEGER DEFAULT -1, # max_requests_per_day INTEGER DEFAULT -1,
max_requests_per_month INTEGER DEFAULT -1, # max_requests_per_month INTEGER DEFAULT -1,
max_providers INTEGER DEFAULT -1, # max_providers INTEGER DEFAULT -1,
max_rotations INTEGER DEFAULT -1, # max_rotations INTEGER DEFAULT -1,
max_autoselections INTEGER DEFAULT -1, # max_autoselections INTEGER DEFAULT -1,
max_rotation_models INTEGER DEFAULT -1, # max_rotation_models INTEGER DEFAULT -1,
max_autoselection_models INTEGER DEFAULT -1, # max_autoselection_models INTEGER DEFAULT -1,
created_at TIMESTAMP DEFAULT {timestamp_default}, # created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default} # updated_at TIMESTAMP DEFAULT {timestamp_default}
) # )
''') # ''')
conn.commit() # conn.commit()
logger.info("✅ Migration: Created missing account_tiers table") # logger.info("✅ Migration: Created missing account_tiers table")
except Exception as e: # except Exception as e:
logger.warning(f"Migration check for account_tiers table: {e}") # logger.warning(f"Migration check for account_tiers table: {e}")
#
# Migration: Add missing columns to account_tiers # Migration: Add missing columns to account_tiers
try: # try:
if self.db_type == 'sqlite': # if self.db_type == 'sqlite':
cursor.execute("PRAGMA table_info(account_tiers)") # cursor.execute("PRAGMA table_info(account_tiers)")
existing_columns = [row[1] for row in cursor.fetchall()] # existing_columns = [row[1] for row in cursor.fetchall()]
tier_columns = [ # tier_columns = [
('max_requests_per_day', 'INTEGER DEFAULT -1'), # ('max_requests_per_day', 'INTEGER DEFAULT -1'),
('max_requests_per_month', 'INTEGER DEFAULT -1'), # ('max_requests_per_month', 'INTEGER DEFAULT -1'),
('max_providers', 'INTEGER DEFAULT -1'), # ('max_providers', 'INTEGER DEFAULT -1'),
('max_rotations', 'INTEGER DEFAULT -1'), # ('max_rotations', 'INTEGER DEFAULT -1'),
('max_autoselections', 'INTEGER DEFAULT -1'), # ('max_autoselections', 'INTEGER DEFAULT -1'),
('max_rotation_models', 'INTEGER DEFAULT -1'), # ('max_rotation_models', 'INTEGER DEFAULT -1'),
('max_autoselection_models', 'INTEGER DEFAULT -1'), # ('max_autoselection_models', 'INTEGER DEFAULT -1'),
('is_default', f'{boolean_type} DEFAULT 0'), # ('is_default', f'{boolean_type} DEFAULT 0'),
('is_active', f'{boolean_type} DEFAULT 1'), # ('is_active', f'{boolean_type} DEFAULT 1'),
('is_visible', f'{boolean_type} DEFAULT 1') # ('is_visible', f'{boolean_type} DEFAULT 1')
] # ]
col_count = 0 # col_count = 0
for col_name, col_def in tier_columns: # for col_name, col_def in tier_columns:
if col_name not in existing_columns: # if col_name not in existing_columns:
cursor.execute(f'ALTER TABLE account_tiers ADD COLUMN {col_name} {col_def}') # cursor.execute(f'ALTER TABLE account_tiers ADD COLUMN {col_name} {col_def}')
col_count += 1 # col_count += 1
if col_count > 0: # if col_count > 0:
logger.info(f"✅ Migration: Added {col_count} missing columns to account_tiers") # logger.info(f"✅ Migration: Added {col_count} missing columns to account_tiers")
else: # else:
# MySQL/MariaDB # MySQL/MariaDB
cursor.execute(""" # cursor.execute("""
SELECT COLUMN_NAME # SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS # FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'account_tiers' # WHERE TABLE_NAME = 'account_tiers'
AND TABLE_SCHEMA = DATABASE() # AND TABLE_SCHEMA = DATABASE()
""") # """)
existing_columns = [row[0] for row in cursor.fetchall()] # existing_columns = [row[0] for row in cursor.fetchall()]
tier_columns = [ # tier_columns = [
('max_requests_per_day', 'INTEGER DEFAULT -1'), # ('max_requests_per_day', 'INTEGER DEFAULT -1'),
('max_requests_per_month', 'INTEGER DEFAULT -1'), # ('max_requests_per_month', 'INTEGER DEFAULT -1'),
('max_providers', 'INTEGER DEFAULT -1'), # ('max_providers', 'INTEGER DEFAULT -1'),
('max_rotations', 'INTEGER DEFAULT -1'), # ('max_rotations', 'INTEGER DEFAULT -1'),
('max_autoselections', 'INTEGER DEFAULT -1'), # ('max_autoselections', 'INTEGER DEFAULT -1'),
('max_rotation_models', 'INTEGER DEFAULT -1'), # ('max_rotation_models', 'INTEGER DEFAULT -1'),
('max_autoselection_models', 'INTEGER DEFAULT -1'), # ('max_autoselection_models', 'INTEGER DEFAULT -1'),
('is_default', f'{boolean_type} DEFAULT 0'), # ('is_default', f'{boolean_type} DEFAULT 0'),
('is_active', f'{boolean_type} DEFAULT 1'), # ('is_active', f'{boolean_type} DEFAULT 1'),
('is_visible', f'{boolean_type} DEFAULT 1') # ('is_visible', f'{boolean_type} DEFAULT 1')
] # ]
col_count = 0 # col_count = 0
for col_name, col_def in tier_columns: # for col_name, col_def in tier_columns:
if col_name not in existing_columns: # if col_name not in existing_columns:
cursor.execute(f'ALTER TABLE account_tiers ADD COLUMN {col_name} {col_def}') # cursor.execute(f'ALTER TABLE account_tiers ADD COLUMN {col_name} {col_def}')
col_count += 1 # col_count += 1
if col_count > 0: # if col_count > 0:
conn.commit() # conn.commit()
logger.info(f"✅ Migration: Added {col_count} missing columns to account_tiers") # logger.info(f"✅ Migration: Added {col_count} missing columns to account_tiers")
except Exception as e: # except Exception as e:
logger.warning(f"Migration check for account_tiers columns: {e}") # logger.warning(f"Migration check for account_tiers columns: {e}")
#
# Migration: Ensure default free tier exists # Migration: Ensure default free tier exists
try: # try:
cursor.execute(f'SELECT COUNT(*) FROM account_tiers WHERE is_default = 1') # cursor.execute(f'SELECT COUNT(*) FROM account_tiers WHERE is_default = 1')
free_tier_count = cursor.fetchone()[0] # free_tier_count = cursor.fetchone()[0]
if free_tier_count == 0: # if free_tier_count == 0:
cursor.execute(f''' # cursor.execute(f'''
INSERT INTO account_tiers # INSERT INTO account_tiers
(name, description, price_monthly, price_yearly, is_default, is_active, # (name, description, price_monthly, price_yearly, is_default, is_active,
max_requests_per_day, max_requests_per_month, max_providers, max_rotations, # max_requests_per_day, max_requests_per_month, max_providers, max_rotations,
max_autoselections, max_rotation_models, max_autoselection_models) # max_autoselections, max_rotation_models, max_autoselection_models)
VALUES # VALUES
('Free Tier', 'Default free account tier with unlimited access', 0.00, 0.00, 1, 1, # ('Free Tier', 'Default free account tier with unlimited access', 0.00, 0.00, 1, 1,
-1, -1, -1, -1, -1, -1, -1) # -1, -1, -1, -1, -1, -1, -1)
''') # ''')
logger.info("✅ Migration: Inserted default free tier") # logger.info("✅ Migration: Inserted default free tier")
except Exception as e: # except Exception as e:
logger.warning(f"Migration check for default free tier: {e}") # logger.warning(f"Migration check for default free tier: {e}")
#
# Migration: Add tier_id column to users table # Migration: Add tier_id column to users table
try: # try:
if self.db_type == 'sqlite': # if self.db_type == 'sqlite':
cursor.execute("PRAGMA table_info(users)") # cursor.execute("PRAGMA table_info(users)")
columns = [row[1] for row in cursor.fetchall()] # columns = [row[1] for row in cursor.fetchall()]
if 'tier_id' not in columns: # 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 tier_id INTEGER DEFAULT 1')
cursor.execute('ALTER TABLE users ADD COLUMN subscription_expires TIMESTAMP NULL') # cursor.execute('ALTER TABLE users ADD COLUMN subscription_expires TIMESTAMP NULL')
logger.info("✅ Migration: Added tier_id and subscription_expires columns to users") # logger.info("✅ Migration: Added tier_id and subscription_expires columns to users")
else: # else:
cursor.execute(""" # cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS # SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'users' AND COLUMN_NAME = 'tier_id' # WHERE TABLE_NAME = 'users' AND COLUMN_NAME = 'tier_id'
""") # """)
if not cursor.fetchone(): # if not cursor.fetchone():
cursor.execute('ALTER TABLE users ADD COLUMN tier_id INTEGER DEFAULT 1') # cursor.execute('ALTER TABLE users ADD COLUMN tier_id INTEGER DEFAULT 1')
cursor.execute('ALTER TABLE users ADD COLUMN subscription_expires TIMESTAMP NULL') # cursor.execute('ALTER TABLE users ADD COLUMN subscription_expires TIMESTAMP NULL')
logger.info("✅ Migration: Added tier_id and subscription_expires columns to users") # logger.info("✅ Migration: Added tier_id and subscription_expires columns to users")
except Exception as e: # except Exception as e:
logger.warning(f"Migration check for users.tier_id: {e}") # logger.warning(f"Migration check for users.tier_id: {e}")
#
# Migration: Add password reset token columns to users table # Migration: Add password reset token columns to users table
try: # try:
if self.db_type == 'sqlite': # if self.db_type == 'sqlite':
cursor.execute("PRAGMA table_info(users)") # cursor.execute("PRAGMA table_info(users)")
columns = [row[1] for row in cursor.fetchall()] # columns = [row[1] for row in cursor.fetchall()]
if 'reset_password_token' not in columns: # 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 VARCHAR(255)')
cursor.execute('ALTER TABLE users ADD COLUMN reset_password_token_expires TIMESTAMP NULL') # cursor.execute('ALTER TABLE users ADD COLUMN reset_password_token_expires TIMESTAMP NULL')
logger.info("✅ Migration: Added password reset token columns to users") # logger.info("✅ Migration: Added password reset token columns to users")
else: # else:
cursor.execute(""" # cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS # SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'users' AND COLUMN_NAME = 'reset_password_token' # WHERE TABLE_NAME = 'users' AND COLUMN_NAME = 'reset_password_token'
""") # """)
if not cursor.fetchone(): # 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 VARCHAR(255)')
cursor.execute('ALTER TABLE users ADD COLUMN reset_password_token_expires TIMESTAMP NULL') # cursor.execute('ALTER TABLE users ADD COLUMN reset_password_token_expires TIMESTAMP NULL')
logger.info("✅ Migration: Added password reset token columns to users") # logger.info("✅ Migration: Added password reset token columns to users")
except Exception as e: # except Exception as e:
logger.warning(f"Migration check for users.reset_password_token: {e}") # logger.warning(f"Migration check for users.reset_password_token: {e}")
#
# Migration: Add last_verification_email_sent column to users table # Migration: Add last_verification_email_sent column to users table
try: # try:
if self.db_type == 'sqlite': # if self.db_type == 'sqlite':
cursor.execute("PRAGMA table_info(users)") # cursor.execute("PRAGMA table_info(users)")
columns = [row[1] for row in cursor.fetchall()] # columns = [row[1] for row in cursor.fetchall()]
if 'last_verification_email_sent' not in columns: # if 'last_verification_email_sent' not in columns:
cursor.execute('ALTER TABLE users ADD COLUMN last_verification_email_sent TIMESTAMP NULL') # cursor.execute('ALTER TABLE users ADD COLUMN last_verification_email_sent TIMESTAMP NULL')
logger.info("✅ Migration: Added last_verification_email_sent column to users") # logger.info("✅ Migration: Added last_verification_email_sent column to users")
else: # else:
cursor.execute(""" # cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS # SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'users' AND COLUMN_NAME = 'last_verification_email_sent' # WHERE TABLE_NAME = 'users' AND COLUMN_NAME = 'last_verification_email_sent'
""") # """)
if not cursor.fetchone(): # if not cursor.fetchone():
cursor.execute('ALTER TABLE users ADD COLUMN last_verification_email_sent TIMESTAMP NULL') # cursor.execute('ALTER TABLE users ADD COLUMN last_verification_email_sent TIMESTAMP NULL')
logger.info("✅ Migration: Added last_verification_email_sent column to users") # logger.info("✅ Migration: Added last_verification_email_sent column to users")
except Exception as e: # except Exception as e:
logger.warning(f"Migration check for users.last_verification_email_sent: {e}") # logger.warning(f"Migration check for users.last_verification_email_sent: {e}")
#
# Migration: Create payment_methods, user_subscriptions, payment_transactions tables # Migration: Create payment_methods, user_subscriptions, payment_transactions tables
for table_name, create_sql in [ # for table_name, create_sql in [
('payment_methods', f''' # ('payment_methods', f'''
CREATE TABLE payment_methods ( # CREATE TABLE payment_methods (
id INTEGER PRIMARY KEY {auto_increment}, # id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL, # user_id INTEGER NOT NULL,
type VARCHAR(50) NOT NULL, # type VARCHAR(50) NOT NULL,
identifier VARCHAR(255) NOT NULL, # identifier VARCHAR(255) NOT NULL,
is_default {boolean_type} DEFAULT 0, # is_default {boolean_type} DEFAULT 0,
is_active {boolean_type} DEFAULT 1, # is_active {boolean_type} DEFAULT 1,
metadata TEXT, # metadata TEXT,
created_at TIMESTAMP DEFAULT {timestamp_default}, # created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default}, # updated_at TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id) # FOREIGN KEY (user_id) REFERENCES users(id)
) # )
'''), # '''),
('admin_settings', f''' # ('admin_settings', f'''
CREATE TABLE admin_settings ( # CREATE TABLE admin_settings (
id INTEGER PRIMARY KEY {auto_increment}, # id INTEGER PRIMARY KEY {auto_increment},
setting_key VARCHAR(255) UNIQUE NOT NULL, # setting_key VARCHAR(255) UNIQUE NOT NULL,
setting_value TEXT, # setting_value TEXT,
updated_at TIMESTAMP DEFAULT {timestamp_default} # updated_at TIMESTAMP DEFAULT {timestamp_default}
) # )
'''), # '''),
('user_subscriptions', f''' # ('user_subscriptions', f'''
CREATE TABLE user_subscriptions ( # CREATE TABLE user_subscriptions (
id INTEGER PRIMARY KEY {auto_increment}, # id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL, # user_id INTEGER NOT NULL,
tier_id INTEGER NOT NULL, # tier_id INTEGER NOT NULL,
status VARCHAR(50) DEFAULT 'active', # status VARCHAR(50) DEFAULT 'active',
start_date TIMESTAMP DEFAULT {timestamp_default}, # start_date TIMESTAMP DEFAULT {timestamp_default},
end_date TIMESTAMP NULL, # end_date TIMESTAMP NULL,
next_billing_date TIMESTAMP NULL, # next_billing_date TIMESTAMP NULL,
trial_end_date TIMESTAMP NULL, # trial_end_date TIMESTAMP NULL,
payment_method_id INTEGER, # payment_method_id INTEGER,
auto_renew {boolean_type} DEFAULT 1, # auto_renew {boolean_type} DEFAULT 1,
created_at TIMESTAMP DEFAULT {timestamp_default}, # created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default}, # updated_at TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id), # FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (tier_id) REFERENCES account_tiers(id), # FOREIGN KEY (tier_id) REFERENCES account_tiers(id),
FOREIGN KEY (payment_method_id) REFERENCES payment_methods(id), # FOREIGN KEY (payment_method_id) REFERENCES payment_methods(id),
UNIQUE(user_id, tier_id) # UNIQUE(user_id, tier_id)
) # )
'''), # '''),
('payment_transactions', f''' # ('payment_transactions', f'''
CREATE TABLE payment_transactions ( # CREATE TABLE payment_transactions (
id INTEGER PRIMARY KEY {auto_increment}, # id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL, # user_id INTEGER NOT NULL,
tier_id INTEGER, # tier_id INTEGER,
subscription_id INTEGER, # subscription_id INTEGER,
payment_method_id INTEGER, # payment_method_id INTEGER,
amount DECIMAL(10,2) NOT NULL, # amount DECIMAL(10,2) NOT NULL,
currency VARCHAR(10) DEFAULT 'USD', # currency VARCHAR(10) DEFAULT 'USD',
status VARCHAR(50) NOT NULL, # status VARCHAR(50) NOT NULL,
transaction_type VARCHAR(50) NOT NULL, # transaction_type VARCHAR(50) NOT NULL,
external_transaction_id VARCHAR(255), # external_transaction_id VARCHAR(255),
metadata TEXT, # metadata TEXT,
created_at TIMESTAMP DEFAULT {timestamp_default}, # created_at TIMESTAMP DEFAULT {timestamp_default},
completed_at TIMESTAMP NULL, # completed_at TIMESTAMP NULL,
FOREIGN KEY (user_id) REFERENCES users(id), # FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (tier_id) REFERENCES account_tiers(id), # FOREIGN KEY (tier_id) REFERENCES account_tiers(id),
FOREIGN KEY (subscription_id) REFERENCES user_subscriptions(id), # FOREIGN KEY (subscription_id) REFERENCES user_subscriptions(id),
FOREIGN KEY (payment_method_id) REFERENCES payment_methods(id) # FOREIGN KEY (payment_method_id) REFERENCES payment_methods(id)
) # )
''') # ''')
]: # ]:
try: # try:
if self.db_type == 'sqlite': # if self.db_type == 'sqlite':
cursor.execute(f"PRAGMA table_info({table_name})") # cursor.execute(f"PRAGMA table_info({table_name})")
if not cursor.fetchall(): # if not cursor.fetchall():
cursor.execute(create_sql) # cursor.execute(create_sql)
logger.info(f"✅ Migration: Created missing {table_name} table") # logger.info(f"✅ Migration: Created missing {table_name} table")
else: # else:
cursor.execute(f""" # cursor.execute(f"""
SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES # SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_NAME = '{table_name}' # WHERE TABLE_NAME = '{table_name}'
""") # """)
if not cursor.fetchone(): # if not cursor.fetchone():
cursor.execute(create_sql) # cursor.execute(create_sql)
logger.info(f"✅ Migration: Created missing {table_name} table") # logger.info(f"✅ Migration: Created missing {table_name} table")
except Exception as e: # except Exception as e:
logger.warning(f"Migration check for {table_name} table: {e}") # logger.warning(f"Migration check for {table_name} table: {e}")
#
conn.commit() # conn.commit()
logger.info("✅ All database migrations completed") # logger.info("✅ All database migrations completed")
#
else: # else:
# CACHE DATABASE GETS MINIMAL TABLES ONLY # CACHE DATABASE GETS MINIMAL TABLES ONLY
cursor.execute(f''' # cursor.execute(f'''
CREATE TABLE IF NOT EXISTS token_usage ( # CREATE TABLE IF NOT EXISTS token_usage (
id INTEGER PRIMARY KEY {auto_increment}, # id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER, # user_id INTEGER,
provider_id VARCHAR(255) NOT NULL, # provider_id VARCHAR(255) NOT NULL,
model_name VARCHAR(255) NOT NULL, # model_name VARCHAR(255) NOT NULL,
tokens_used INTEGER NOT NULL, # tokens_used INTEGER NOT NULL,
timestamp TIMESTAMP DEFAULT {timestamp_default} # timestamp TIMESTAMP DEFAULT {timestamp_default}
) # )
''') # ''')
#
cursor.execute(f''' # cursor.execute(f'''
CREATE TABLE IF NOT EXISTS context_dimensions ( # CREATE TABLE IF NOT EXISTS context_dimensions (
id INTEGER PRIMARY KEY {auto_increment}, # id INTEGER PRIMARY KEY {auto_increment},
provider_id VARCHAR(255) NOT NULL, # provider_id VARCHAR(255) NOT NULL,
model_name VARCHAR(255) NOT NULL, # model_name VARCHAR(255) NOT NULL,
context_size INTEGER, # context_size INTEGER,
condense_context INTEGER, # condense_context INTEGER,
condense_method TEXT, # condense_method TEXT,
effective_context INTEGER DEFAULT 0, # effective_context INTEGER DEFAULT 0,
last_updated TIMESTAMP DEFAULT {timestamp_default}, # last_updated TIMESTAMP DEFAULT {timestamp_default},
UNIQUE(provider_id, model_name) # UNIQUE(provider_id, model_name)
) # )
''') # ''')
#
logger.info("⚠️ CACHE DATABASE: Only minimal cache tables created - NO USER TABLES") # logger.info("⚠️ CACHE DATABASE: Only minimal cache tables created - NO USER TABLES")
conn.commit() conn.commit()
logger.info(f"Database tables initialized successfully for {self.database_type} database") 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): def DatabaseManager__create_config_tables(self, cursor, auto_increment, timestamp_default, boolean_type):
"""Create all permanent configuration tables (CONFIG DB ONLY)""" """Create all permanent configuration tables (CONFIG DB ONLY) - UNUSED METHOD"""
pass # Method disabled
# 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): def DatabaseManager__create_cache_tables(self, cursor, auto_increment, timestamp_default, boolean_type):
"""Create only temporary cache tables (CACHE DB ONLY)""" """Create only temporary cache tables (CACHE DB ONLY)"""
...@@ -3591,6 +3471,9 @@ def DatabaseManager__create_cache_tables(self, cursor, auto_increment, timestamp ...@@ -3591,6 +3471,9 @@ def DatabaseManager__create_cache_tables(self, cursor, auto_increment, timestamp
provider_id VARCHAR(255) NOT NULL, provider_id VARCHAR(255) NOT NULL,
model_name VARCHAR(255) NOT NULL, model_name VARCHAR(255) NOT NULL,
tokens_used INTEGER NOT NULL, tokens_used INTEGER NOT NULL,
prompt_tokens INTEGER,
completion_tokens INTEGER,
actual_cost DECIMAL(10,6),
timestamp TIMESTAMP DEFAULT {timestamp_default} timestamp TIMESTAMP DEFAULT {timestamp_default}
) )
''') ''')
...@@ -3818,11 +3701,51 @@ def DatabaseManager__run_config_migrations(self, cursor, auto_increment, timesta ...@@ -3818,11 +3701,51 @@ def DatabaseManager__run_config_migrations(self, cursor, auto_increment, timesta
except Exception as e: except Exception as e:
logger.warning(f"Migration check for {table_name} table: {e}") logger.warning(f"Migration check for {table_name} table: {e}")
# Migration: Add prompt_tokens and completion_tokens columns to token_usage table
try:
if self.db_type == 'sqlite':
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 COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'token_usage' AND COLUMN_NAME = 'prompt_tokens'
""")
if not cursor.fetchone():
cursor.execute('ALTER TABLE token_usage ADD COLUMN prompt_tokens INTEGER')
logger.info("✅ Migration: Added prompt_tokens column to token_usage")
cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'token_usage' AND COLUMN_NAME = 'completion_tokens'
""")
if not cursor.fetchone():
cursor.execute('ALTER TABLE token_usage ADD COLUMN completion_tokens INTEGER')
logger.info("✅ Migration: Added completion_tokens column to token_usage")
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('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 token_usage columns: {e}")
conn.commit() conn.commit()
logger.info("✅ All database migrations completed") logger.info("✅ All database migrations completed")
# Patch the methods # Patch the methods
DatabaseManager._initialize_database = DatabaseManager__initialize_database DatabaseManager._initialize_database = DatabaseManager__initialize_database
DatabaseManager._create_config_tables = DatabaseManager__create_config_tables
DatabaseManager._create_cache_tables = DatabaseManager__create_cache_tables DatabaseManager._create_cache_tables = DatabaseManager__create_cache_tables
DatabaseManager._run_config_migrations = DatabaseManager__run_config_migrations DatabaseManager._run_config_migrations = DatabaseManager__run_config_migrations
...@@ -366,6 +366,9 @@ class RequestHandler: ...@@ -366,6 +366,9 @@ class RequestHandler:
# Record analytics for authentication failure # Record analytics for authentication failure
try: try:
analytics = get_analytics() analytics = get_analytics()
# Calculate latency for auth failure
latency_ms = (time.time() - request_start_time) * 1000
# Estimate tokens for the request # Estimate tokens for the request
try: try:
messages = request_data.get('messages', []) messages = request_data.get('messages', [])
...@@ -378,11 +381,14 @@ class RequestHandler: ...@@ -378,11 +381,14 @@ class RequestHandler:
provider_id=provider_id, provider_id=provider_id,
model_name=request_data.get('model', 'unknown'), model_name=request_data.get('model', 'unknown'),
tokens_used=estimated_tokens, tokens_used=estimated_tokens,
latency_ms=0, latency_ms=latency_ms,
success=False, success=False,
error_type='AuthenticationError', error_type='AuthenticationError',
user_id=getattr(request.state, 'user_id', None), 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: except Exception as analytics_error:
logger.warning(f"Analytics recording for auth failure failed: {analytics_error}") logger.warning(f"Analytics recording for auth failure failed: {analytics_error}")
...@@ -526,33 +532,48 @@ class RequestHandler: ...@@ -526,33 +532,48 @@ class RequestHandler:
try: try:
analytics = get_analytics() analytics = get_analytics()
latency_ms = (time.time() - request_start_time) * 1000 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): if response and isinstance(response, dict):
usage = response.get('usage', {}) usage = response.get('usage', {})
total_tokens = usage.get('total_tokens', 0) 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: if total_tokens == 0:
try: try:
messages = request_data.get('messages', []) messages = request_data.get('messages', [])
estimated_prompt_tokens = count_messages_tokens(messages, model_name) 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
max_tokens = request_data.get('max_tokens', 0) response_content = response.get('choices', [{}])[0].get('message', {}).get('content', '')
if max_tokens > 0: if response_content:
# Use max_tokens as upper bound for completion completion_tokens = count_messages_tokens([{
estimated_completion = min(max_tokens, estimated_prompt_tokens * 2) "role": "assistant",
"content": response_content
}], model_name)
else: else:
# No max_tokens specified, assume completion is similar to prompt # Fallback to estimation if no content
# but at least 50 tokens for typical responses max_tokens = request_data.get('max_tokens', 0)
estimated_completion = max(estimated_prompt_tokens, 50) if max_tokens > 0:
completion_tokens = min(max_tokens, estimated_prompt_tokens * 2)
else:
completion_tokens = max(estimated_prompt_tokens, 50)
total_tokens = estimated_prompt_tokens + estimated_completion total_tokens = estimated_prompt_tokens + completion_tokens
logger.debug(f"Estimated token usage: {total_tokens} (prompt: {estimated_prompt_tokens}, completion: {estimated_completion})") 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: except Exception as est_error:
logger.debug(f"Token estimation failed: {est_error}") logger.debug(f"Token counting failed: {est_error}")
# Use a more realistic default if estimation fails # Use a more realistic default if counting fails
total_tokens = 150 total_tokens = 150
prompt_tokens = 0
completion_tokens = 0
# Always record analytics, even with estimated tokens # Always record analytics, even with estimated tokens
analytics.record_request( analytics.record_request(
...@@ -562,7 +583,10 @@ class RequestHandler: ...@@ -562,7 +583,10 @@ class RequestHandler:
latency_ms=latency_ms, latency_ms=latency_ms,
success=True, success=True,
user_id=getattr(request.state, 'user_id', None), 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: except Exception as analytics_error:
logger.warning(f"Analytics recording failed: {analytics_error}") logger.warning(f"Analytics recording failed: {analytics_error}")
...@@ -582,8 +606,12 @@ class RequestHandler: ...@@ -582,8 +606,12 @@ class RequestHandler:
messages = request_data.get('messages', []) messages = request_data.get('messages', [])
estimated_tokens = count_messages_tokens(messages, model_name) estimated_tokens = count_messages_tokens(messages, model_name)
total_tokens = estimated_tokens total_tokens = estimated_tokens
prompt_tokens = estimated_tokens
completion_tokens = 0 # No completion for failed requests
except Exception: except Exception:
total_tokens = 50 # Minimal estimate for failed requests total_tokens = 50 # Minimal estimate for failed requests
prompt_tokens = 50
completion_tokens = 0
analytics.record_request( analytics.record_request(
provider_id=provider_id, provider_id=provider_id,
...@@ -593,7 +621,10 @@ class RequestHandler: ...@@ -593,7 +621,10 @@ class RequestHandler:
success=False, success=False,
error_type=type(e).__name__, error_type=type(e).__name__,
user_id=getattr(request.state, 'user_id', None), 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: except Exception as analytics_error:
logger.warning(f"Analytics recording for failed request failed: {analytics_error}") logger.warning(f"Analytics recording for failed request failed: {analytics_error}")
...@@ -667,6 +698,8 @@ class RequestHandler: ...@@ -667,6 +698,8 @@ class RequestHandler:
import time import time
import json import json
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Track request start time for latency calculation
request_start_time = time.time()
try: try:
# Apply rate limiting # Apply rate limiting
await handler.apply_rate_limit() await handler.apply_rate_limit()
...@@ -1200,6 +1233,10 @@ class RequestHandler: ...@@ -1200,6 +1233,10 @@ class RequestHandler:
# Record analytics for streaming request # Record analytics for streaming request
try: try:
analytics = get_analytics() 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 # Calculate total tokens from accumulated response
if accumulated_response_text: if accumulated_response_text:
completion_tokens = count_messages_tokens([{"role": "assistant", "content": accumulated_response_text}], request_data['model']) completion_tokens = count_messages_tokens([{"role": "assistant", "content": accumulated_response_text}], request_data['model'])
...@@ -1211,10 +1248,13 @@ class RequestHandler: ...@@ -1211,10 +1248,13 @@ class RequestHandler:
provider_id=provider_id, provider_id=provider_id,
model_name=request_data['model'], model_name=request_data['model'],
tokens_used=total_tokens, tokens_used=total_tokens,
latency_ms=0, latency_ms=latency_ms,
success=True, success=True,
user_id=getattr(request.state, 'user_id', None), 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: except Exception as analytics_error:
logger.warning(f"Analytics recording for streaming request failed: {analytics_error}") logger.warning(f"Analytics recording for streaming request failed: {analytics_error}")
...@@ -1225,23 +1265,34 @@ class RequestHandler: ...@@ -1225,23 +1265,34 @@ class RequestHandler:
# Record analytics for failed streaming request # Record analytics for failed streaming request
try: try:
analytics = get_analytics() 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 # Estimate tokens for failed request
try: try:
messages = request_data.get('messages', []) messages = request_data.get('messages', [])
estimated_tokens = count_messages_tokens(messages, request_data['model']) estimated_tokens = count_messages_tokens(messages, request_data['model'])
total_tokens = estimated_tokens total_tokens = estimated_tokens
prompt_tokens = estimated_tokens
completion_tokens = 0
except Exception: except Exception:
total_tokens = 50 # Minimal estimate for failed requests total_tokens = 50 # Minimal estimate for failed requests
prompt_tokens = 50
completion_tokens = 0
analytics.record_request( analytics.record_request(
provider_id=provider_id, provider_id=provider_id,
model_name=request_data['model'], model_name=request_data['model'],
tokens_used=total_tokens, tokens_used=total_tokens,
latency_ms=0, latency_ms=latency_ms,
success=False, success=False,
error_type=type(e).__name__, error_type=type(e).__name__,
user_id=getattr(request.state, 'user_id', None), 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: except Exception as analytics_error:
logger.warning(f"Analytics recording for failed streaming request failed: {analytics_error}") logger.warning(f"Analytics recording for failed streaming request failed: {analytics_error}")
...@@ -2233,6 +2284,8 @@ class RotationHandler: ...@@ -2233,6 +2284,8 @@ class RotationHandler:
import logging import logging
import time import time
logger = logging.getLogger(__name__) 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"=== RotationHandler.handle_rotation_request START ===")
logger.info(f"Rotation ID: {rotation_id}") logger.info(f"Rotation ID: {rotation_id}")
logger.info(f"User ID: {self.user_id}") logger.info(f"User ID: {self.user_id}")
...@@ -2847,6 +2900,8 @@ class RotationHandler: ...@@ -2847,6 +2900,8 @@ class RotationHandler:
if response and isinstance(response, dict): if response and isinstance(response, dict):
usage = response.get('usage', {}) usage = response.get('usage', {})
total_tokens = usage.get('total_tokens', 0) 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 no token usage provided, estimate it
if total_tokens == 0: if total_tokens == 0:
...@@ -2862,21 +2917,32 @@ class RotationHandler: ...@@ -2862,21 +2917,32 @@ class RotationHandler:
estimated_completion = max(estimated_prompt_tokens, 50) estimated_completion = max(estimated_prompt_tokens, 50)
total_tokens = estimated_prompt_tokens + estimated_completion 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}") logger.debug(f"Estimated token usage for rotation: {total_tokens}")
except Exception as est_error: except Exception as est_error:
logger.debug(f"Token estimation failed: {est_error}") logger.debug(f"Token estimation failed: {est_error}")
total_tokens = 150 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 # Always record analytics
analytics.record_request( analytics.record_request(
provider_id=provider_id, provider_id=provider_id,
model_name=model_name, model_name=model_name,
tokens_used=total_tokens, tokens_used=total_tokens,
latency_ms=0, # Latency tracking would require more extensive changes latency_ms=(time.time() - request_start_time) * 1000,
success=True, success=True,
rotation_id=rotation_id, rotation_id=rotation_id,
user_id=user_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: except Exception as analytics_error:
logger.warning(f"Analytics recording failed: {analytics_error}") logger.warning(f"Analytics recording failed: {analytics_error}")
...@@ -2912,24 +2978,35 @@ class RotationHandler: ...@@ -2912,24 +2978,35 @@ class RotationHandler:
# Record analytics for failed rotation request # Record analytics for failed rotation request
try: try:
analytics = get_analytics() 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 # Estimate tokens for failed request
try: try:
messages = request_data.get('messages', []) messages = request_data.get('messages', [])
estimated_tokens = count_messages_tokens(messages, rotation_id) estimated_tokens = count_messages_tokens(messages, rotation_id)
total_tokens = estimated_tokens total_tokens = estimated_tokens
prompt_tokens = estimated_tokens
completion_tokens = 0
except Exception: except Exception:
total_tokens = 50 # Minimal estimate for failed requests total_tokens = 50 # Minimal estimate for failed requests
prompt_tokens = 50
completion_tokens = 0
analytics.record_request( analytics.record_request(
provider_id='rotation', provider_id='rotation',
model_name=rotation_id, model_name=rotation_id,
tokens_used=total_tokens, tokens_used=total_tokens,
latency_ms=0, latency_ms=latency_ms,
success=False, success=False,
error_type='RotationFailure', error_type='RotationFailure',
rotation_id=rotation_id, rotation_id=rotation_id,
user_id=user_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: except Exception as analytics_error:
logger.warning(f"Analytics recording for failed rotation failed: {analytics_error}") logger.warning(f"Analytics recording for failed rotation failed: {analytics_error}")
...@@ -4112,7 +4189,10 @@ class AutoselectHandler: ...@@ -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: 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""" """Handle an autoselect request"""
import logging import logging
import time
logger = logging.getLogger(__name__) 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 REQUEST START ===")
logger.info(f"Autoselect ID: {autoselect_id}") logger.info(f"Autoselect ID: {autoselect_id}")
logger.info(f"User ID: {self.user_id}") logger.info(f"User ID: {self.user_id}")
...@@ -4232,24 +4312,35 @@ class AutoselectHandler: ...@@ -4232,24 +4312,35 @@ class AutoselectHandler:
logger.error(f"Autoselect request failed: {str(e)}") logger.error(f"Autoselect request failed: {str(e)}")
try: try:
analytics = get_analytics() 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 # Estimate tokens for failed request
try: try:
messages = request_data.get('messages', []) messages = request_data.get('messages', [])
estimated_tokens = count_messages_tokens(messages, autoselect_id) estimated_tokens = count_messages_tokens(messages, autoselect_id)
total_tokens = estimated_tokens total_tokens = estimated_tokens
prompt_tokens = estimated_tokens
completion_tokens = 0
except Exception: except Exception:
total_tokens = 50 # Minimal estimate for failed requests total_tokens = 50 # Minimal estimate for failed requests
prompt_tokens = 50
completion_tokens = 0
analytics.record_request( analytics.record_request(
provider_id='autoselect', provider_id='autoselect',
model_name=autoselect_id, model_name=autoselect_id,
tokens_used=total_tokens, tokens_used=total_tokens,
latency_ms=0, latency_ms=latency_ms,
success=False, success=False,
error_type=type(e).__name__, error_type=type(e).__name__,
autoselect_id=autoselect_id, autoselect_id=autoselect_id,
user_id=user_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: except Exception as analytics_error:
logger.warning(f"Analytics recording for failed autoselect failed: {analytics_error}") logger.warning(f"Analytics recording for failed autoselect failed: {analytics_error}")
...@@ -4276,6 +4367,8 @@ class AutoselectHandler: ...@@ -4276,6 +4367,8 @@ class AutoselectHandler:
if response and isinstance(response, dict): if response and isinstance(response, dict):
usage = response.get('usage', {}) usage = response.get('usage', {})
total_tokens = usage.get('total_tokens', 0) 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 no token usage provided, estimate it
if total_tokens == 0: if total_tokens == 0:
...@@ -4292,23 +4385,38 @@ class AutoselectHandler: ...@@ -4292,23 +4385,38 @@ class AutoselectHandler:
estimated_completion = max(estimated_prompt_tokens, 50) estimated_completion = max(estimated_prompt_tokens, 50)
total_tokens = estimated_prompt_tokens + estimated_completion 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}") logger.debug(f"Estimated token usage for autoselect: {total_tokens}")
except Exception as est_error: except Exception as est_error:
logger.debug(f"Token estimation failed: {est_error}") logger.debug(f"Token estimation failed: {est_error}")
total_tokens = 150 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 # Always record analytics
# The actual provider/model info is in the response model field # The actual provider/model info is in the response model field
model_name = response.get('model', 'unknown') 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( analytics.record_request(
provider_id='autoselect', provider_id='autoselect',
model_name=model_name, model_name=model_name,
tokens_used=total_tokens, tokens_used=total_tokens,
latency_ms=0, latency_ms=latency_ms,
success=True, success=True,
autoselect_id=autoselect_id, autoselect_id=autoselect_id,
user_id=user_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: except Exception as analytics_error:
logger.warning(f"Analytics recording failed: {analytics_error}") logger.warning(f"Analytics recording failed: {analytics_error}")
......
...@@ -855,12 +855,12 @@ class MCPServer: ...@@ -855,12 +855,12 @@ class MCPServer:
if stream: if stream:
return {"error": "Streaming not supported in MCP, use SSE endpoint instead"} return {"error": "Streaming not supported in MCP, use SSE endpoint instead"}
else: 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": elif provider_id == "rotation":
handler = get_user_handler('rotation', user_id) 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): 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") 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: else:
handler = get_user_handler('request', user_id) 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): 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): ...@@ -159,7 +159,7 @@ class ClaudeProviderHandler(BaseProviderHandler):
logger.info("ClaudeProviderHandler: Initializing session for quota tracking") logger.info("ClaudeProviderHandler: Initializing session for quota tracking")
try: try:
headers = self._get_auth_headers(stream=False) headers = await self._get_auth_headers(stream=False)
payload = { payload = {
'model': 'claude-haiku-4-5-20251001', 'model': 'claude-haiku-4-5-20251001',
...@@ -257,12 +257,12 @@ class ClaudeProviderHandler(BaseProviderHandler): ...@@ -257,12 +257,12 @@ class ClaudeProviderHandler(BaseProviderHandler):
if old_util != new_util: if old_util != new_util:
logger.debug(f"ClaudeProviderHandler: Quota utilization updated: {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.""" """Get or create an Anthropic SDK client configured with OAuth2 auth token."""
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
access_token = self.auth.get_valid_token() access_token = await self.auth.get_valid_token()
if not access_token: if not access_token:
logger.error("ClaudeProviderHandler: No OAuth2 access token available") logger.error("ClaudeProviderHandler: No OAuth2 access token available")
...@@ -277,14 +277,14 @@ class ClaudeProviderHandler(BaseProviderHandler): ...@@ -277,14 +277,14 @@ class ClaudeProviderHandler(BaseProviderHandler):
logger.info("ClaudeProviderHandler: Created SDK client with OAuth2 auth token") logger.info("ClaudeProviderHandler: Created SDK client with OAuth2 auth token")
return self._sdk_client 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.""" """Get HTTP headers with OAuth2 Bearer token."""
import logging import logging
import uuid import uuid
import platform import platform
logger = logging.getLogger(__name__) 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'): if not self.session_state.get('session_id'):
self.session_state['session_id'] = str(uuid.uuid4()) self.session_state['session_id'] = str(uuid.uuid4())
...@@ -849,7 +849,7 @@ class ClaudeProviderHandler(BaseProviderHandler): ...@@ -849,7 +849,7 @@ class ClaudeProviderHandler(BaseProviderHandler):
if anthropic_tool_choice: if anthropic_tool_choice:
payload['tool_choice'] = 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' api_url = 'https://api.anthropic.com/v1/messages?beta=true'
logger.info(f"ClaudeProviderHandler: Request payload keys: {list(payload.keys())}") logger.info(f"ClaudeProviderHandler: Request payload keys: {list(payload.keys())}")
...@@ -1639,8 +1639,8 @@ class ClaudeProviderHandler(BaseProviderHandler): ...@@ -1639,8 +1639,8 @@ class ClaudeProviderHandler(BaseProviderHandler):
try: try:
logging.info("ClaudeProviderHandler: [1/3] Attempting primary API endpoint...") 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' api_endpoint = 'https://api.anthropic.com/v1/models'
logging.info(f"ClaudeProviderHandler: Calling API endpoint: {api_endpoint}") logging.info(f"ClaudeProviderHandler: Calling API endpoint: {api_endpoint}")
......
...@@ -169,9 +169,9 @@ class KiloProviderHandler(BaseProviderHandler): ...@@ -169,9 +169,9 @@ class KiloProviderHandler(BaseProviderHandler):
"status": "authenticated", "status": "authenticated",
"token": self.api_key "token": self.api_key
} }
token = self.oauth2.get_valid_token() token = await self.oauth2.get_valid_token()
if token: if token:
logger.info("KiloProviderHandler: Using existing OAuth2 token") logger.info("KiloProviderHandler: Using existing OAuth2 token")
return { return {
...@@ -182,7 +182,7 @@ class KiloProviderHandler(BaseProviderHandler): ...@@ -182,7 +182,7 @@ class KiloProviderHandler(BaseProviderHandler):
# Try to reload credentials one more time - this handles the case where credentials # 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 # were saved by another process/handler instance after this handler was created
self.oauth2._load_credentials() self.oauth2._load_credentials()
token = self.oauth2.get_valid_token() token = await self.oauth2.get_valid_token()
if token: if token:
logger.info("KiloProviderHandler: Found OAuth2 token after reloading credentials") logger.info("KiloProviderHandler: Found OAuth2 token after reloading credentials")
......
...@@ -103,7 +103,7 @@ class QwenProviderHandler(BaseProviderHandler): ...@@ -103,7 +103,7 @@ class QwenProviderHandler(BaseProviderHandler):
logging.getLogger(__name__).info(f"QwenProviderHandler: Falling back to file-based credentials for user {self.user_id}") logging.getLogger(__name__).info(f"QwenProviderHandler: Falling back to file-based credentials for user {self.user_id}")
return QwenOAuth2(credentials_file=credentials_file) 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).""" """Get or create an OpenAI SDK client configured with authentication (OAuth2 or API key)."""
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -122,7 +122,7 @@ class QwenProviderHandler(BaseProviderHandler): ...@@ -122,7 +122,7 @@ class QwenProviderHandler(BaseProviderHandler):
base_url = self._get_region_endpoint(qwen_config) base_url = self._get_region_endpoint(qwen_config)
else: else:
# Use OAuth2 authentication # Use OAuth2 authentication
access_token = self.auth.get_valid_token() access_token = await self.auth.get_valid_token()
if not access_token: if not access_token:
logger.error("QwenProviderHandler: No OAuth2 access token available") logger.error("QwenProviderHandler: No OAuth2 access token available")
...@@ -221,7 +221,7 @@ class QwenProviderHandler(BaseProviderHandler): ...@@ -221,7 +221,7 @@ class QwenProviderHandler(BaseProviderHandler):
await self.apply_rate_limit() await self.apply_rate_limit()
# Get SDK client with current OAuth token # Get SDK client with current OAuth token
client = self._get_sdk_client() client = await self._get_sdk_client()
# Build request parameters # Build request parameters
request_params = { request_params = {
...@@ -308,7 +308,7 @@ class QwenProviderHandler(BaseProviderHandler): ...@@ -308,7 +308,7 @@ class QwenProviderHandler(BaseProviderHandler):
if refresh_success: if refresh_success:
logger.info("QwenProviderHandler: Token refreshed, retrying request") logger.info("QwenProviderHandler: Token refreshed, retrying request")
# Retry with new token # Retry with new token
client = self._get_sdk_client() client = await self._get_sdk_client()
if stream: if stream:
return self._handle_streaming_request(client, request_params, model) return self._handle_streaming_request(client, request_params, model)
...@@ -472,7 +472,7 @@ class QwenProviderHandler(BaseProviderHandler): ...@@ -472,7 +472,7 @@ class QwenProviderHandler(BaseProviderHandler):
try: try:
# Get SDK client with API key authentication # Get SDK client with API key authentication
client = self._get_sdk_client() client = await self._get_sdk_client()
# List models using OpenAI SDK # List models using OpenAI SDK
models_response = await client.models.list() models_response = await client.models.list()
......
...@@ -647,6 +647,13 @@ def initialize_app(custom_config_dir=None): ...@@ -647,6 +647,13 @@ def initialize_app(custom_config_dir=None):
'password': '8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918' '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 _initialized = True
logger.info("App initialization complete") logger.info("App initialization complete")
...@@ -1314,11 +1321,6 @@ async def auth_middleware(request: Request, call_next): ...@@ -1314,11 +1321,6 @@ async def auth_middleware(request: Request, call_next):
request.state.is_global_token = False request.state.is_global_token = False
# Store user role - admin users get full access # Store user role - admin users get full access
request.state.is_admin = (user_auth.get('role') == 'admin') 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: else:
return JSONResponse( return JSONResponse(
status_code=403, status_code=403,
...@@ -1901,6 +1903,42 @@ async def get_subscription_status(request: Request): ...@@ -1901,6 +1903,42 @@ async def get_subscription_status(request: Request):
status = await payment_service.get_subscription_status(current_user['id']) status = await payment_service.get_subscription_status(current_user['id'])
return {'subscription': status} 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 # Dashboard routes
@app.get("/dashboard/analytics", response_class=HTMLResponse) @app.get("/dashboard/analytics", response_class=HTMLResponse)
async def dashboard_analytics( async def dashboard_analytics(
...@@ -1912,7 +1950,8 @@ async def dashboard_analytics( ...@@ -1912,7 +1950,8 @@ async def dashboard_analytics(
model_filter: Optional[str] = Query(None), model_filter: Optional[str] = Query(None),
rotation_filter: Optional[str] = Query(None), rotation_filter: Optional[str] = Query(None),
autoselect_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""" """Token usage analytics dashboard"""
auth_check = require_dashboard_auth(request) auth_check = require_dashboard_auth(request)
...@@ -1950,9 +1989,21 @@ async def dashboard_analytics( ...@@ -1950,9 +1989,21 @@ async def dashboard_analytics(
is_admin = request.session.get('role') == 'admin' is_admin = request.session.get('role') == 'admin'
current_user_id = request.session.get('user_id') 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 # For non-admin users, force user filter to current user
if not is_admin and current_user_id is not None: 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) # Get all users for filter dropdown (only for admins)
all_users = db.get_users() if db and is_admin else [] all_users = db.get_users() if db and is_admin else []
...@@ -1972,9 +2023,9 @@ async def dashboard_analytics( ...@@ -1972,9 +2023,9 @@ async def dashboard_analytics(
# Get provider statistics (with optional filter) # Get provider statistics (with optional filter)
if provider_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: 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) # Get token usage over time (with optional filters)
token_over_time = analytics.get_token_usage_over_time( token_over_time = analytics.get_token_usage_over_time(
...@@ -1982,7 +2033,7 @@ async def dashboard_analytics( ...@@ -1982,7 +2033,7 @@ async def dashboard_analytics(
time_range=time_range, time_range=time_range,
from_datetime=from_datetime, from_datetime=from_datetime,
to_datetime=to_datetime, to_datetime=to_datetime,
user_filter=user_filter user_filter=user_filter_int
) )
# Get model performance (with optional filters) # Get model performance (with optional filters)
...@@ -1991,21 +2042,21 @@ async def dashboard_analytics( ...@@ -1991,21 +2042,21 @@ async def dashboard_analytics(
model_filter=model_filter, model_filter=model_filter,
rotation_filter=rotation_filter, rotation_filter=rotation_filter,
autoselect_filter=autoselect_filter, autoselect_filter=autoselect_filter,
user_filter=user_filter user_filter=user_filter_int
) )
# Get cost overview # 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 # 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 # Get date range usage summary
date_range_usage = None date_range_usage = None
if from_datetime or to_datetime: if from_datetime or to_datetime:
start = from_datetime or (datetime.now() - timedelta(days=1)) start = from_datetime or (datetime.now() - timedelta(days=1))
end = to_datetime or datetime.now() 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( return templates.TemplateResponse(
request=request, request=request,
...@@ -2032,7 +2083,8 @@ async def dashboard_analytics( ...@@ -2032,7 +2083,8 @@ async def dashboard_analytics(
"selected_model": model_filter, "selected_model": model_filter,
"selected_rotation": rotation_filter, "selected_rotation": rotation_filter,
"selected_autoselect": autoselect_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: ...@@ -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) # For non-remember-me sessions, set expiry to 2 weeks (default session length)
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60 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 # Check if email is verified
if not user['email_verified']: if not user['email_verified']:
# Check if account is expired (24 hours old and unverified) # 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 ...@@ -3236,6 +3295,13 @@ async def oauth2_google_callback(request: Request, code: str = Query(...), state
request.session['user_id'] = existing_user['id'] request.session['user_id'] = existing_user['id']
request.session['email_verified'] = True # OAuth2 users have verified emails request.session['email_verified'] = True # OAuth2 users have verified emails
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60 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: else:
# New user - create account automatically (no password required) # New user - create account automatically (no password required)
if not email_verified: if not email_verified:
...@@ -3244,18 +3310,19 @@ async def oauth2_google_callback(request: Request, code: str = Query(...), state ...@@ -3244,18 +3310,19 @@ async def oauth2_google_callback(request: Request, code: str = Query(...), state
name="dashboard/login.html", name="dashboard/login.html",
context={"request": request, "config": config, "error": "Google email must be verified to create an account"} context={"request": request, "config": config, "error": "Google email must be verified to create an account"}
) )
# Generate secure random password for OAuth users (never used for login) # Generate secure random password for OAuth users (never used for login)
random_password = secrets.token_urlsafe(32) random_password = secrets.token_urlsafe(32)
password_hash = hashlib.sha256(random_password.encode()).hexdigest() password_hash = hashlib.sha256(random_password.encode()).hexdigest()
# Generate clean username from display_name with email fallback # Generate clean username from display_name with email fallback
google_username = db.generate_username_from_display_name(display_name, email) google_username = db.generate_username_from_display_name(display_name, email)
final_username = db.find_unique_username(google_username) final_username = db.find_unique_username(google_username)
# Create user with verified email (no verification required) # Create user with verified email (no verification required)
user_id = db.create_user(final_username, password_hash, 'user', None, email, True, display_name) user_id = db.create_user(final_username, password_hash, 'user', None, email, True, display_name)
# Login the new user # Login the new user
request.session['logged_in'] = True request.session['logged_in'] = True
request.session['username'] = final_username request.session['username'] = final_username
...@@ -3264,6 +3331,13 @@ async def oauth2_google_callback(request: Request, code: str = Query(...), state ...@@ -3264,6 +3331,13 @@ async def oauth2_google_callback(request: Request, code: str = Query(...), state
request.session['user_id'] = user_id request.session['user_id'] = user_id
request.session['email_verified'] = True # OAuth2 users have verified emails request.session['email_verified'] = True # OAuth2 users have verified emails
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60 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 # Cleanup session data
request.session.pop('oauth2_google', None) request.session.pop('oauth2_google', None)
...@@ -3383,6 +3457,13 @@ async def oauth2_github_callback(request: Request, code: str = Query(...), state ...@@ -3383,6 +3457,13 @@ async def oauth2_github_callback(request: Request, code: str = Query(...), state
request.session['user_id'] = existing_user['id'] request.session['user_id'] = existing_user['id']
request.session['email_verified'] = True # OAuth2 users have verified emails request.session['email_verified'] = True # OAuth2 users have verified emails
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60 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: else:
# New user - create account automatically (no password required) # New user - create account automatically (no password required)
# Generate secure random password for OAuth users (never used for login) # Generate secure random password for OAuth users (never used for login)
...@@ -3404,7 +3485,14 @@ async def oauth2_github_callback(request: Request, code: str = Query(...), state ...@@ -3404,7 +3485,14 @@ async def oauth2_github_callback(request: Request, code: str = Query(...), state
request.session['user_id'] = user_id request.session['user_id'] = user_id
request.session['email_verified'] = True # OAuth2 users have verified emails request.session['email_verified'] = True # OAuth2 users have verified emails
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60 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 # Cleanup session data
request.session.pop('oauth2_github', None) request.session.pop('oauth2_github', None)
...@@ -3639,7 +3727,7 @@ async def _auto_detect_provider_models(provider_key: str, provider: dict) -> lis ...@@ -3639,7 +3727,7 @@ async def _auto_detect_provider_models(provider_key: str, provider: dict) -> lis
kilo_config = provider.get('kilo_config', {}) kilo_config = provider.get('kilo_config', {})
credentials_file = kilo_config.get('credentials_file', '~/.kilo_credentials.json') credentials_file = kilo_config.get('credentials_file', '~/.kilo_credentials.json')
oauth2 = KiloOAuth2(credentials_file=credentials_file, api_base=endpoint) oauth2 = KiloOAuth2(credentials_file=credentials_file, api_base=endpoint)
token = oauth2.get_valid_token() token = await oauth2.get_valid_token()
if token: if token:
api_key = token api_key = token
logger.info(f"Using OAuth2 token for Kilo provider '{provider_key}'") logger.info(f"Using OAuth2 token for Kilo provider '{provider_key}'")
...@@ -6992,7 +7080,7 @@ async def dashboard_pricing(request: Request): ...@@ -6992,7 +7080,7 @@ async def dashboard_pricing(request: Request):
from aisbf.database import get_database from aisbf.database import get_database
db = DatabaseRegistry.get_config_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')) current_tier = db.get_user_tier(request.session.get('user_id'))
# Get enabled payment gateways # Get enabled payment gateways
...@@ -7874,12 +7962,13 @@ async def v1_chat_completions(request: Request, body: ChatCompletionRequest): ...@@ -7874,12 +7962,13 @@ async def v1_chat_completions(request: Request, body: ChatCompletionRequest):
body_dict['model'] = actual_model body_dict['model'] = actual_model
# Get user-specific handler # Get user-specific handler
user_id = getattr(request.state, 'user_id', None) user_id = getattr(request.state, 'user_id', None)
token_id = getattr(request.state, 'token_id', None)
handler = get_user_handler('autoselect', user_id) handler = get_user_handler('autoselect', user_id)
if body.stream: if body.stream:
return await handler.handle_autoselect_streaming_request(actual_model, body_dict) return await handler.handle_autoselect_streaming_request(actual_model, body_dict)
else: 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}) # PATH 2: Check if it's a rotation (format: rotation/{name})
if provider_id == "rotation": if provider_id == "rotation":
...@@ -7891,8 +7980,9 @@ async def v1_chat_completions(request: Request, body: ChatCompletionRequest): ...@@ -7891,8 +7980,9 @@ async def v1_chat_completions(request: Request, body: ChatCompletionRequest):
body_dict['model'] = actual_model body_dict['model'] = actual_model
# Get user-specific handler # Get user-specific handler
user_id = getattr(request.state, 'user_id', None) user_id = getattr(request.state, 'user_id', None)
token_id = getattr(request.state, 'token_id', None)
handler = get_user_handler('rotation', user_id) 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}) # PATH 1: Direct provider model (format: {provider}/{model})
if provider_id not in config.providers: if provider_id not in config.providers:
...@@ -8405,11 +8495,12 @@ async def rotation_chat_completions(request: Request, body: ChatCompletionReques ...@@ -8405,11 +8495,12 @@ async def rotation_chat_completions(request: Request, body: ChatCompletionReques
try: try:
# Get user-specific handler # Get user-specific handler
user_id = getattr(request.state, 'user_id', None) user_id = getattr(request.state, 'user_id', None)
token_id = getattr(request.state, 'token_id', None)
handler = get_user_handler('rotation', user_id) handler = get_user_handler('rotation', user_id)
# The rotation handler handles streaming internally and returns # The rotation handler handles streaming internally and returns
# a StreamingResponse for streaming requests or a dict for non-streaming # 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)}") logger.debug(f"Rotation response result type: {type(result)}")
return result return result
except Exception as e: except Exception as e:
...@@ -8482,6 +8573,7 @@ async def autoselect_chat_completions(request: Request, body: ChatCompletionRequ ...@@ -8482,6 +8573,7 @@ async def autoselect_chat_completions(request: Request, body: ChatCompletionRequ
# Get user-specific handler # Get user-specific handler
user_id = getattr(request.state, 'user_id', None) user_id = getattr(request.state, 'user_id', None)
token_id = getattr(request.state, 'token_id', None)
handler = get_user_handler('autoselect', user_id) handler = get_user_handler('autoselect', user_id)
# Check if the model name corresponds to an autoselect configuration # Check if the model name corresponds to an autoselect configuration
...@@ -8502,7 +8594,7 @@ async def autoselect_chat_completions(request: Request, body: ChatCompletionRequ ...@@ -8502,7 +8594,7 @@ async def autoselect_chat_completions(request: Request, body: ChatCompletionRequ
return await handler.handle_autoselect_streaming_request(body.model, body_dict) return await handler.handle_autoselect_streaming_request(body.model, body_dict)
else: else:
logger.debug("Handling non-streaming autoselect request") 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}") logger.debug(f"Autoselect response result: {result}")
return result return result
except Exception as e: except Exception as e:
...@@ -8550,6 +8642,7 @@ async def chat_completions(provider_id: str, request: Request, body: ChatComplet ...@@ -8550,6 +8642,7 @@ async def chat_completions(provider_id: str, request: Request, body: ChatComplet
# Check if it's an autoselect # 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): 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") logger.debug("Handling autoselect request")
token_id = getattr(request.state, 'token_id', None)
handler = get_user_handler('autoselect', user_id) handler = get_user_handler('autoselect', user_id)
try: try:
if body.stream: if body.stream:
...@@ -8557,7 +8650,7 @@ async def chat_completions(provider_id: str, request: Request, body: ChatComplet ...@@ -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) return await handler.handle_autoselect_streaming_request(provider_id, body_dict)
else: else:
logger.debug("Handling non-streaming autoselect request") 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}") logger.debug(f"Autoselect response result: {result}")
return result return result
except Exception as e: except Exception as e:
...@@ -8568,8 +8661,9 @@ async def chat_completions(provider_id: str, request: Request, body: ChatComplet ...@@ -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): 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.info(f"Provider ID '{provider_id}' found in rotations")
logger.debug("Handling rotation request") logger.debug("Handling rotation request")
token_id = getattr(request.state, 'token_id', None)
handler = get_user_handler('rotation', user_id) 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 # Check if it's a provider
handler = get_user_handler('request', user_id) handler = get_user_handler('request', user_id)
...@@ -10596,11 +10690,12 @@ async def user_chat_completions_by_username(request: Request, username: str, bod ...@@ -10596,11 +10690,12 @@ async def user_chat_completions_by_username(request: Request, username: str, bod
detail=f"User autoselect '{actual_model}' not found. Available: {list(handler.user_autoselects.keys())}" detail=f"User autoselect '{actual_model}' not found. Available: {list(handler.user_autoselects.keys())}"
) )
body_dict['model'] = actual_model body_dict['model'] = actual_model
if body.stream: if body.stream:
return await handler.handle_autoselect_streaming_request(actual_model, body_dict) return await handler.handle_autoselect_streaming_request(actual_model, body_dict)
else: 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": if provider_id == "user-rotation":
handler = get_user_handler('rotation', target_user_id) handler = get_user_handler('rotation', target_user_id)
...@@ -10610,7 +10705,8 @@ async def user_chat_completions_by_username(request: Request, username: str, bod ...@@ -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())}" detail=f"User rotation '{actual_model}' not found. Available: {list(handler.user_rotations.keys())}"
) )
body_dict['model'] = actual_model 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": if provider_id == "user-provider":
handler = get_user_handler('request', target_user_id) handler = get_user_handler('request', target_user_id)
...@@ -10644,12 +10740,13 @@ async def user_chat_completions_by_username(request: Request, username: str, bod ...@@ -10644,12 +10740,13 @@ async def user_chat_completions_by_username(request: Request, username: str, bod
) )
handler = get_user_handler('autoselect', None) handler = get_user_handler('autoselect', None)
body_dict['model'] = actual_model body_dict['model'] = actual_model
if body.stream: if body.stream:
return await handler.handle_autoselect_streaming_request(actual_model, body_dict) return await handler.handle_autoselect_streaming_request(actual_model, body_dict)
else: 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 provider_id == "rotation":
if actual_model not in config.rotations: if actual_model not in config.rotations:
raise HTTPException( raise HTTPException(
...@@ -10658,7 +10755,8 @@ async def user_chat_completions_by_username(request: Request, username: str, bod ...@@ -10658,7 +10755,8 @@ async def user_chat_completions_by_username(request: Request, username: str, bod
) )
handler = get_user_handler('rotation', None) handler = get_user_handler('rotation', None)
body_dict['model'] = actual_model 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: if provider_id in config.providers:
provider_config = config.get_provider(provider_id) provider_config = config.get_provider(provider_id)
...@@ -10854,11 +10952,12 @@ async def user_chat_completions(request: Request, username: str, body: ChatCompl ...@@ -10854,11 +10952,12 @@ async def user_chat_completions(request: Request, username: str, body: ChatCompl
detail=f"User autoselect '{actual_model}' not found. Available: {list(handler.user_autoselects.keys())}" detail=f"User autoselect '{actual_model}' not found. Available: {list(handler.user_autoselects.keys())}"
) )
body_dict['model'] = actual_model body_dict['model'] = actual_model
if body.stream: if body.stream:
return await handler.handle_autoselect_streaming_request(actual_model, body_dict) return await handler.handle_autoselect_streaming_request(actual_model, body_dict)
else: 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}) # Handle user rotation (format: user-rotation/{name})
if provider_id == "user-rotation": if provider_id == "user-rotation":
...@@ -10869,7 +10968,8 @@ async def user_chat_completions(request: Request, username: str, body: ChatCompl ...@@ -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())}" detail=f"User rotation '{actual_model}' not found. Available: {list(handler.user_rotations.keys())}"
) )
body_dict['model'] = actual_model 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}) # Handle user provider (format: user-provider/{name})
if provider_id == "user-provider": if provider_id == "user-provider":
...@@ -10907,11 +11007,12 @@ async def user_chat_completions(request: Request, username: str, body: ChatCompl ...@@ -10907,11 +11007,12 @@ async def user_chat_completions(request: Request, username: str, body: ChatCompl
) )
handler = get_user_handler('autoselect', None) handler = get_user_handler('autoselect', None)
body_dict['model'] = actual_model body_dict['model'] = actual_model
if body.stream: if body.stream:
return await handler.handle_autoselect_streaming_request(actual_model, body_dict) return await handler.handle_autoselect_streaming_request(actual_model, body_dict)
else: 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 # Handle global rotation
if provider_id == "rotation": if provider_id == "rotation":
...@@ -10922,7 +11023,8 @@ async def user_chat_completions(request: Request, username: str, body: ChatCompl ...@@ -10922,7 +11023,8 @@ async def user_chat_completions(request: Request, username: str, body: ChatCompl
) )
handler = get_user_handler('rotation', None) handler = get_user_handler('rotation', None)
body_dict['model'] = actual_model 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 # Handle global provider
if provider_id in config.providers: if provider_id in config.providers:
...@@ -11920,11 +12022,11 @@ async def dashboard_kilo_auth_status(request: Request): ...@@ -11920,11 +12022,11 @@ async def dashboard_kilo_auth_status(request: Request):
# Config admin or no database credentials: check file # Config admin or no database credentials: check file
auth = KiloOAuth2(credentials_file=credentials_file) auth = KiloOAuth2(credentials_file=credentials_file)
# Check if authenticated # Check if authenticated
if auth.is_authenticated(): if auth.is_authenticated():
# Try to get a valid token (will refresh if needed) # Try to get a valid token (will refresh if needed)
token = auth.get_valid_token() token = await auth.get_valid_token()
if token: if token:
# Get token expiration info # Get token expiration info
expires_at = auth.credentials.get('expires', 0) expires_at = auth.credentials.get('expires', 0)
...@@ -12240,7 +12342,7 @@ async def dashboard_codex_auth_status(request: Request): ...@@ -12240,7 +12342,7 @@ async def dashboard_codex_auth_status(request: Request):
# Check if authenticated # Check if authenticated
if auth.is_authenticated(): if auth.is_authenticated():
# Try to get a valid token (will refresh if needed) # 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: if token:
# Get user email from ID token # Get user email from ID token
email = auth.get_user_email() email = auth.get_user_email()
......
...@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" ...@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "aisbf" 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" description = "AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations"
readme = "README.md" readme = "README.md"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
......
...@@ -49,7 +49,7 @@ class InstallCommand(_install): ...@@ -49,7 +49,7 @@ class InstallCommand(_install):
setup( setup(
name="aisbf", name="aisbf",
version="0.99.28", version="0.99.29",
author="AISBF Contributors", author="AISBF Contributors",
author_email="stefy@nexlab.net", 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", 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/>. ...@@ -25,7 +25,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { 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; } 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 { 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 h1 { font-size: 24px; font-weight: 600; display: inline-block; }
.header-actions { float: right; } .header-actions { float: right; }
......
...@@ -18,6 +18,21 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -18,6 +18,21 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% block title %}Analytics - AISBF Dashboard{% endblock %} {% 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 %} {% block content %}
<h2 style="margin-bottom: 30px;">Token Usage Analytics</h2> <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/>. ...@@ -68,7 +83,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</span> </span>
{% if date_range_usage %} {% if date_range_usage %}
<span style="margin-left: 20px; color: #a0a0a0;"> <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> </span>
{% endif %} {% endif %}
</div> </div>
...@@ -127,12 +142,30 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -127,12 +142,30 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% if is_admin %} {% if is_admin %}
<div style="flex: 1; min-width: 150px;"> <div style="flex: 1; min-width: 150px;">
<label style="display: block; margin-bottom: 5px; color: #a0a0a0; font-size: 14px;">User</label> <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> <option value="">All Users</option>
{% for user in available_users %} {% 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> <option value="{{ user.id }}" {% if selected_user == user.id %}selected{% endif %}>{{ user.username }}{% if user.role == 'admin' %} (admin){% endif %}</option>
{% endfor %} {% endfor %}
</select> </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> </div>
{% endif %} {% endif %}
...@@ -174,6 +207,195 @@ document.getElementById('timeRangeSelect').addEventListener('change', function() ...@@ -174,6 +207,195 @@ document.getElementById('timeRangeSelect').addEventListener('change', function()
customRange.style.display = 'none'; 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> </script>
{% if recommendations %} {% if recommendations %}
...@@ -203,6 +425,9 @@ document.getElementById('timeRangeSelect').addEventListener('change', function() ...@@ -203,6 +425,9 @@ document.getElementById('timeRangeSelect').addEventListener('change', function()
<th>Errors</th> <th>Errors</th>
<th>Error Rate</th> <th>Error Rate</th>
<th>Avg Latency</th> <th>Avg Latency</th>
<th>Input Tokens</th>
<th>Output Tokens</th>
<th>Total Tokens</th>
<th>Tokens/Min</th> <th>Tokens/Min</th>
<th>Tokens/Hour</th> <th>Tokens/Hour</th>
<th>Tokens/Day</th> <th>Tokens/Day</th>
...@@ -219,11 +444,50 @@ document.getElementById('timeRangeSelect').addEventListener('change', function() ...@@ -219,11 +444,50 @@ document.getElementById('timeRangeSelect').addEventListener('change', function()
<td {% if provider.avg_latency_ms > 5000 %}style="color: #fcd34d;"{% endif %}> <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 %} {% 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>
<td>{{ provider.tokens.TPM }}</td> <td><strong>{{ format_tokens(provider.tokens.prompt or 0) }}</strong></td>
<td>{{ provider.tokens.TPH }}</td> <td><strong>{{ format_tokens(provider.tokens.completion or 0) }}</strong></td>
<td>{{ provider.tokens.TPD }}</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> </tr>
{% endfor %} {% 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> </table>
{% else %} {% else %}
<p style="color: #a0a0a0;">No provider statistics available yet. Make API requests to see analytics.</p> <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() ...@@ -246,7 +510,7 @@ document.getElementById('timeRangeSelect').addEventListener('change', function()
<div style="background: #0f3460; padding: 15px; border-radius: 8px;"> <div style="background: #0f3460; padding: 15px; border-radius: 8px;">
<h4 style="font-size: 14px; margin-bottom: 5px;">{{ pc.provider_id }}</h4> <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> <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> </div>
{% endfor %} {% endfor %}
</div> </div>
...@@ -281,7 +545,7 @@ document.getElementById('timeRangeSelect').addEventListener('change', function() ...@@ -281,7 +545,7 @@ document.getElementById('timeRangeSelect').addEventListener('change', function()
<td>{{ model.context_size|default('N/A') }}</td> <td>{{ model.context_size|default('N/A') }}</td>
<td>{{ model.condense_context|default('N/A') }}%</td> <td>{{ model.condense_context|default('N/A') }}%</td>
<td>{{ model.condense_method|default('None') }}</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 %}> <td {% if model.error_rate > 0.1 %}style="color: #f87171;"{% endif %}>
{{ "%.1f"|format(model.error_rate * 100) }}% {{ "%.1f"|format(model.error_rate * 100) }}%
</td> </td>
......
...@@ -21,7 +21,7 @@ ...@@ -21,7 +21,7 @@
<!-- Paid Tiers --> <!-- Paid Tiers -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; margin-bottom: 40px;"> <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; margin-bottom: 40px;">
{% for tier in tiers %} {% 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 %}"> <div class="pricing-card {% if tier.is_recommended %}recommended{% endif %}">
{% if tier.is_recommended %} {% 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;"> <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 @@ ...@@ -2,6 +2,21 @@
{% block title %}User Dashboard - AISBF{% endblock %} {% 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 %} {% block content %}
<div class="container"> <div class="container">
<h1>User Dashboard</h1> <h1>User Dashboard</h1>
...@@ -98,7 +113,7 @@ ...@@ -98,7 +113,7 @@
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-item"> <div class="stat-item">
<h3>Total Tokens Used</h3> <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>
<div class="stat-item"> <div class="stat-item">
<h3>Requests Today</h3> <h3>Requests Today</h3>
......
...@@ -229,23 +229,60 @@ function updateUserTier(userId, tierId) { ...@@ -229,23 +229,60 @@ function updateUserTier(userId, tierId) {
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
// Show success message briefly // Show success notification
const msg = document.createElement('div'); showNotification('Tier updated successfully', 'success');
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);
} else { } 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 location.reload(); // Reload to reset dropdown
} }
}) })
.catch(error => { .catch(error => {
alert('Error: ' + error); showNotification('Error: ' + error, 'error');
location.reload(); // Reload to reset dropdown 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 // Close modal when clicking outside
window.onclick = function(event) { window.onclick = function(event) {
const modal = document.getElementById('edit-modal'); const modal = document.getElementById('edit-modal');
...@@ -269,6 +306,43 @@ th { ...@@ -269,6 +306,43 @@ th {
background: #0f3460; background: #0f3460;
font-weight: 600; 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 */ /* Prevent browser autofill from overriding dark theme */
input:-webkit-autofill, input:-webkit-autofill,
input:-webkit-autofill:hover, 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