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",
......
This diff is collapsed.
...@@ -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
This diff is collapsed.
This diff is collapsed.
...@@ -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()
......
This diff is collapsed.
...@@ -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; }
......
This diff is collapsed.
...@@ -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