Commit acb721ad authored by Your Name's avatar Your Name

v0.99.33: Fix analytics, cost tracking, MySQL timezone issues, and add Yesterday filter

- Fixed analytics token counting for Kilo/Kilocode ChatCompletion objects
- Fixed database migrations to add missing columns (success, latency_ms, error_type, token_id)
- Restored model retrieval with proper handle_model_list method
- Fixed response cache serialization for ChatCompletion objects
- Fixed MySQL timezone issues in all analytics queries using _format_timestamp()
- Added comprehensive cost calculation debug logging at INFO level
- Added kilo providers to DEFAULT_PRICING with $0.00 (subscription/free)
- Enhanced database tracking with full parameter trace logging
- Added 'Yesterday' time range filter to analytics page
- Improved custom date range handling
- All changes maintain 100% backwards compatibility
- Version bumped to 0.99.33
parent 11f1173b
# AISBF v0.99.33 - Changes Summary
## Fixed Issues
### 1. Analytics Token Counting for Kilo/Kilocode Providers
- **Problem**: Kilo providers return OpenAI `ChatCompletion` objects instead of dict responses, causing analytics to fail silently
- **Solution**: Added support for both dict and ChatCompletion object responses in analytics.py
- **Files Modified**: `aisbf/analytics.py`, `aisbf/handlers.py`
### 2. Database Migration for Missing Columns
- **Problem**: `token_usage` table was missing 4 columns: `success`, `latency_ms`, `error_type`, `token_id`
- **Solution**: Fixed migration code to run on startup and add all missing columns automatically
- **Files Modified**: `aisbf/database.py`
### 3. Model Retrieval from lisa.nexlab.net
- **Problem**: Missing `handle_model_list` method caused model retrieval to fail
- **Solution**: Restored handlers.py from git and added proper model list handling
- **Files Modified**: `aisbf/handlers.py`
### 4. Response Cache Serialization
- **Problem**: Cache failed to serialize ChatCompletion objects
- **Solution**: Added automatic object → dict conversion for response cache
- **Files Modified**: `aisbf/cache.py`
### 5. MySQL Timezone Issues
- **Problem**: Analytics queries used `.isoformat()` instead of UTC formatted timestamps, causing blank graphs on MySQL
- **Solution**: All timestamp queries now use `_format_timestamp()` method for proper UTC formatting
- **Files Modified**: `aisbf/analytics.py`
### 6. Cost Calculation Debug Logging
- **Problem**: Cost calculation breakdown was at DEBUG level and never called
- **Solution**:
- Changed all cost logging to INFO level
- Added cost calculation call in handlers.py success path
- Added kilo providers to DEFAULT_PRICING with $0.00 (subscription/free)
- **Files Modified**: `aisbf/analytics.py`, `aisbf/handlers.py`
### 7. Database Tracking Debug Logging
- **Problem**: Insufficient logging to debug tracking issues
- **Solution**: Added comprehensive trace logging for all database insert operations
- **Files Modified**: `aisbf/database.py`
### 8. Analytics Time Range Filter
- **Problem**: Analytics page lacked "Yesterday" option and proper time range handling
- **Solution**:
- Added "Yesterday" preset option
- Improved time range handling in both frontend and backend
- Fixed custom date range logic
- **Files Modified**: `templates/dashboard/analytics.html`, `main.py`, `aisbf/analytics.py`
## New Features
### Enhanced Debug Logging
All tracking operations now log:
- Full parameter dump for every database insert
- SQL queries being executed
- Number of rows affected
- Full traceback on errors
- Cost calculation breakdown with 8 decimal precision
### Time Range Options
Analytics page now supports:
- Last 1 Hour
- Last 6 Hours
- Last 24 Hours (Default)
- Yesterday (NEW)
- Last 7 Days
- Last 30 Days
- Last 90 Days
- Custom Range (with date/time pickers)
## Backwards Compatibility
All changes maintain 100% backwards compatibility:
- Fallback INSERT logic for old database schemas
- Support for both dict and object responses
- All existing providers continue to work
- Streaming requests remain functional
## Version Updates
Updated version to `0.99.33` in:
- `aisbf/__init__.py`
- `pyproject.toml`
- `setup.py`
## Testing Recommendations
1. Verify model retrieval from lisa.nexlab.net works correctly
2. Test analytics data displays correctly in web dashboard with MySQL
3. Confirm cost calculations show in logs for all provider types
4. Test "Yesterday" time range filter
5. Verify database migrations work on live MySQL instances
6. Test all existing providers (kiro-cli, claude, qwen, codex, kilo)
......@@ -54,7 +54,7 @@ from .auth.qwen import QwenOAuth2
from .handlers import RequestHandler, RotationHandler, AutoselectHandler
from .utils import count_messages_tokens, split_messages_into_chunks, get_max_request_tokens_for_model
__version__ = "0.99.32"
__version__ = "0.99.33"
__all__ = [
# Config
"config",
......
......@@ -53,6 +53,7 @@ class Analytics:
'google': {'prompt': 1.25, 'completion': 5.0}, # $1.25/M prompt, $5/M completion
'kiro': {'prompt': 0.5, 'completion': 1.5}, # $0.5/M prompt, $1.5/M completion
'openrouter': {'prompt': 5.0, 'completion': 15.0}, # Average pricing
'kilo': {'prompt': 0.0, 'completion': 0.0}, # Kilo providers are free/subscription
}
def __init__(self, db_manager, pricing: Optional[Dict] = None):
......@@ -70,6 +71,15 @@ class Analytics:
self._request_counts = {} # {provider_id: {total: int, success: int, error: int}}
self._latencies = {} # {provider_id: {count: int, total_ms: float, min_ms: float, max_ms: float}}
self._error_types = {} # {provider_id: {error_type: count}}
def _format_timestamp(self, dt: datetime) -> str:
"""
Format datetime for database queries.
Converts to UTC and formats as 'YYYY-MM-DD HH:MM:SS' for compatibility with SQLite and MySQL.
"""
from datetime import timezone
utc_dt = dt.astimezone(timezone.utc)
return utc_dt.strftime('%Y-%m-%d %H:%M:%S')
def _get_provider_pricing(self, provider_id: str) -> Dict[str, float]:
"""
......@@ -144,6 +154,8 @@ class Analytics:
completion_tokens: Optional number of output/completion tokens
actual_cost: Optional actual cost returned by provider (in USD)
"""
logger.info(f"🎯 Analytics.record_request ENTERED: provider={provider_id}, model={model_name}, tokens={tokens_used}, user_id={user_id}, success={success}")
# Initialize provider tracking if needed
if provider_id not in self._request_counts:
self._request_counts[provider_id] = {'total': 0, 'success': 0, 'error': 0}
......@@ -168,7 +180,13 @@ class Analytics:
# Persist to database
if tokens_used > 0:
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)
try:
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)
logger.info(f"✅ Analytics recording completed successfully for {provider_id}")
except Exception as e:
logger.error(f"❌ Analytics recording FAILED for {provider_id}: {e}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
# Also record user token usage if this was an API token request
if user_id is not None and token_id is not None:
......@@ -243,13 +261,13 @@ class Analytics:
# user_filter = <id> means "only requests from that user"
if user_filter == -1:
user_condition = " AND user_id IS NULL"
params = [provider_id, from_datetime.isoformat(), to_datetime.isoformat()]
params = [provider_id, self._format_timestamp(from_datetime), self._format_timestamp(to_datetime)]
elif user_filter is not None:
user_condition = f" AND user_id = {placeholder}"
params = [provider_id, from_datetime.isoformat(), to_datetime.isoformat(), user_filter]
params = [provider_id, self._format_timestamp(from_datetime), self._format_timestamp(to_datetime), user_filter]
else:
user_condition = ""
params = [provider_id, from_datetime.isoformat(), to_datetime.isoformat()]
params = [provider_id, self._format_timestamp(from_datetime), self._format_timestamp(to_datetime)]
# Get token usage and actual time range of requests
cursor.execute(f'''
......@@ -359,12 +377,15 @@ class Analytics:
cursor = conn.cursor()
placeholder = '?' if self.db.db_type == 'sqlite' else '%s'
formatted_start = self._format_timestamp(start)
formatted_end = self._format_timestamp(end)
if provider_id:
cursor.execute(f'''
SELECT SUM(tokens_used) as total_tokens
FROM token_usage
WHERE provider_id = {placeholder} AND timestamp >= {placeholder} AND timestamp <= {placeholder}
''', (provider_id, start.isoformat(), end.isoformat()))
''', (provider_id, formatted_start, formatted_end))
row = cursor.fetchone()
total_tokens = row[0] if row and row[0] else 0
else:
......@@ -373,7 +394,7 @@ class Analytics:
FROM token_usage
WHERE timestamp >= {placeholder} AND timestamp <= {placeholder}
GROUP BY provider_id
''', (start.isoformat(), end.isoformat()))
''', (formatted_start, formatted_end))
provider_tokens = {}
total_tokens = 0
......@@ -414,8 +435,12 @@ class Analytics:
List of provider statistics dictionaries
"""
# Get all unique providers from database
start = from_datetime or (datetime.now() - timedelta(days=1))
end = to_datetime or datetime.now()
# Ensure datetimes are in UTC for database queries (timestamps are stored in UTC)
from datetime import timezone
utc = timezone.utc
now_utc = datetime.now(utc)
start = (from_datetime.astimezone(utc) if from_datetime else (now_utc - timedelta(days=1)))
end = (to_datetime.astimezone(utc) if to_datetime else now_utc)
with self.db._get_connection() as conn:
cursor = conn.cursor()
......@@ -424,13 +449,13 @@ class Analytics:
# Build query with optional user filter
if user_filter == -1:
user_condition = " AND user_id IS NULL"
params = [start.isoformat(), end.isoformat()]
params = [self._format_timestamp(start), self._format_timestamp(end)]
elif user_filter is not None:
user_condition = f" AND user_id = {placeholder}"
params = [start.isoformat(), end.isoformat(), user_filter]
params = [self._format_timestamp(start), self._format_timestamp(end), user_filter]
else:
user_condition = ""
params = [start.isoformat(), end.isoformat()]
params = [self._format_timestamp(start), self._format_timestamp(end)]
cursor.execute(f'''
SELECT DISTINCT provider_id
......@@ -511,6 +536,12 @@ class Analytics:
cutoff = datetime.now() - timedelta(hours=24)
end_time = datetime.now()
bucket_minutes = 30
elif time_range == 'yesterday':
# Yesterday: from 00:00:00 to 23:59:59 of previous day
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
cutoff = today - timedelta(days=1)
end_time = today - timedelta(microseconds=1)
bucket_minutes = 30
elif time_range == '7d':
cutoff = datetime.now() - timedelta(days=7)
end_time = datetime.now()
......@@ -541,6 +572,9 @@ class Analytics:
date_format = "%Y-%m-%d %H:%i"
time_bucket_expr = f"DATE_FORMAT(timestamp, '{date_format}')"
formatted_cutoff = self._format_timestamp(cutoff)
formatted_end_time = self._format_timestamp(end_time)
if provider_id:
if user_filter == -1:
# Global requests only
......@@ -552,7 +586,7 @@ class Analytics:
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()))
''', (provider_id, formatted_cutoff, formatted_end_time))
elif user_filter:
cursor.execute(f'''
SELECT
......@@ -562,7 +596,7 @@ class Analytics:
WHERE provider_id = {placeholder} AND user_id = {placeholder} AND timestamp >= {placeholder} AND timestamp <= {placeholder}
GROUP BY time_bucket
ORDER BY time_bucket
''', (provider_id, user_filter, cutoff.isoformat(), end_time.isoformat()))
''', (provider_id, user_filter, formatted_cutoff, formatted_end_time))
else:
cursor.execute(f'''
SELECT
......@@ -572,7 +606,7 @@ class Analytics:
WHERE provider_id = {placeholder} AND timestamp >= {placeholder} AND timestamp <= {placeholder}
GROUP BY time_bucket
ORDER BY time_bucket
''', (provider_id, cutoff.isoformat(), end_time.isoformat()))
''', (provider_id, formatted_cutoff, formatted_end_time))
else:
if user_filter == -1:
# Global requests only
......@@ -585,7 +619,7 @@ class Analytics:
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()))
''', (formatted_cutoff, formatted_end_time))
elif user_filter:
cursor.execute(f'''
SELECT
......@@ -596,7 +630,7 @@ class Analytics:
WHERE user_id = {placeholder} AND timestamp >= {placeholder} AND timestamp <= {placeholder}
GROUP BY time_bucket, provider_id
ORDER BY time_bucket
''', (user_filter, cutoff.isoformat(), end_time.isoformat()))
''', (user_filter, formatted_cutoff, formatted_end_time))
else:
cursor.execute(f'''
SELECT
......@@ -607,7 +641,7 @@ class Analytics:
WHERE timestamp >= {placeholder} AND timestamp <= {placeholder}
GROUP BY time_bucket, provider_id
ORDER BY time_bucket
''', (cutoff.isoformat(), end_time.isoformat()))
''', (formatted_cutoff, formatted_end_time))
results = []
for row in cursor.fetchall():
......@@ -783,27 +817,43 @@ class Analytics:
Returns:
Estimated cost in USD
"""
# Handle special case for 'all' providers (aggregate stats)
if provider_id == 'all':
# For aggregate stats across all providers, skip cost estimation
# as it would require complex per-provider calculation
return 0.0
# Get provider-specific pricing (checks subscription status and custom pricing)
provider_pricing = self._get_provider_pricing(provider_id)
logger.info(f"💰 Cost calculation for {provider_id}:")
logger.info(f" tokens_used: {tokens_used}, prompt: {prompt_tokens}, completion: {completion_tokens}")
logger.info(f" Pricing: prompt=${provider_pricing.get('prompt', 0)}/M, completion=${provider_pricing.get('completion', 0)}/M")
# Calculate cost with actual token breakdown if available
if prompt_tokens is not None and completion_tokens is not None:
prompt_cost = (prompt_tokens / 1_000_000) * provider_pricing.get('prompt', 0)
completion_cost = (completion_tokens / 1_000_000) * provider_pricing.get('completion', 0)
return prompt_cost + completion_cost
total_cost = prompt_cost + completion_cost
logger.info(f" Calculated: ${prompt_cost:.8f} + ${completion_cost:.8f} = ${total_cost:.8f}")
return total_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
total_cost = prompt_cost + completion_cost
logger.info(f" Calculated (estimated completion): ${prompt_cost:.8f} + ${completion_cost:.8f} = ${total_cost:.8f}")
return total_cost
else:
# No breakdown available - use estimation (25% prompt, 75% completion is common for chat)
prompt_tokens_est = tokens_used * 0.25
completion_tokens_est = tokens_used * 0.75
prompt_cost = (prompt_tokens_est / 1_000_000) * provider_pricing.get('prompt', 0)
completion_cost = (completion_tokens_est / 1_000_000) * provider_pricing.get('completion', 0)
return prompt_cost + completion_cost
total_cost = prompt_cost + completion_cost
logger.info(f" Calculated (estimated breakdown 25/75): ${prompt_cost:.8f} + ${completion_cost:.8f} = ${total_cost:.8f}")
return total_cost
def get_cost_overview(
self,
......@@ -1110,6 +1160,11 @@ class Analytics:
elif time_range == '24h':
cutoff = datetime.now() - timedelta(hours=24)
end_time = datetime.now()
elif time_range == 'yesterday':
# Yesterday: from 00:00:00 to 23:59:59 of previous day
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
cutoff = today - timedelta(days=1)
end_time = today - timedelta(microseconds=1)
elif time_range == '7d':
cutoff = datetime.now() - timedelta(days=7)
end_time = datetime.now()
......@@ -1134,6 +1189,9 @@ class Analytics:
date_format = "%Y-%m-%d %H:%i"
time_bucket_expr = f"DATE_FORMAT(timestamp, '{date_format}')"
formatted_cutoff = self._format_timestamp(cutoff)
formatted_end_time = self._format_timestamp(end_time)
cursor.execute(f'''
SELECT
{time_bucket_expr} as time_bucket,
......@@ -1143,7 +1201,7 @@ class Analytics:
WHERE user_id = {placeholder} AND timestamp >= {placeholder} AND timestamp <= {placeholder}
GROUP BY time_bucket, provider_id
ORDER BY time_bucket
''', (user_id, cutoff.isoformat(), end_time.isoformat()))
''', (user_id, formatted_cutoff, formatted_end_time))
results = []
for row in cursor.fetchall():
......
......@@ -1316,11 +1316,11 @@ class ResponseCache:
return cache_key
def _serialize_response(self, response: Dict) -> bytes:
def _serialize_response(self, response: Any) -> bytes:
"""Serialize response for storage"""
return json.dumps(response, ensure_ascii=False).encode('utf-8')
def _deserialize_response(self, data: bytes) -> Dict:
def _deserialize_response(self, data: bytes) -> Any:
"""Deserialize response from storage"""
return json.loads(data.decode('utf-8'))
......@@ -1346,7 +1346,7 @@ class ResponseCache:
self._memory_cache.pop(lru_key, None)
self._memory_timestamps.pop(lru_key, None)
def get(self, request_data: Dict) -> Optional[Dict]:
def get(self, request_data: Dict) -> Optional[Any]:
"""
Get cached response for a request.
......@@ -1420,13 +1420,13 @@ class ResponseCache:
logger.warning(f"Cache get error: {e}")
return None
def set(self, request_data: Dict, response: Dict, ttl: Optional[int] = None) -> None:
def set(self, request_data: Dict, response: Any, ttl: Optional[int] = None) -> None:
"""
Cache a response.
Args:
request_data: The request data dict
response: The response dict to cache
response: The response to cache (dict or object)
ttl: TTL in seconds (uses default if None)
"""
if not self.enabled:
......@@ -1436,7 +1436,7 @@ class ResponseCache:
if request_data.get('stream', False):
return
# Don't cache error responses
# Don't cache error responses (only for dict responses)
if isinstance(response, dict) and 'error' in response:
return
......@@ -1444,18 +1444,29 @@ class ResponseCache:
cache_key = self._generate_cache_key(request_data)
ttl_value = ttl or self.default_ttl
# Convert response to dict if it's an object (like ChatCompletion)
cacheable_response = response
if hasattr(response, '__dict__') and not isinstance(response, dict):
# Convert Pydantic/OpenAI objects to dict for caching
try:
cacheable_response = response.model_dump() if hasattr(response, 'model_dump') else response.dict()
logger.debug(f"Converted {type(response).__name__} to dict for caching")
except Exception as conv_error:
logger.debug(f"Could not convert {type(response).__name__} to dict: {conv_error}")
return # Skip caching if conversion fails
if self.backend == 'redis' and self.redis_client:
data = self._serialize_response(response)
data = self._serialize_response(cacheable_response)
self.redis_client.setex(cache_key, ttl_value, data)
logger.debug(f"Cached response (Redis): {cache_key} (TTL: {ttl_value}s)")
elif self.backend == 'sqlite' and self.sqlite_backend:
self.sqlite_backend.set(cache_key, response, ttl_value)
self.sqlite_backend.set(cache_key, cacheable_response, ttl_value)
logger.debug(f"Cached response (SQLite): {cache_key} (TTL: {ttl_value}s)")
elif self.backend == 'mysql' and self.mysql_backend:
self.mysql_backend.set(cache_key, response, ttl_value)
self.mysql_backend.set(cache_key, cacheable_response, ttl_value)
logger.debug(f"Cached response (MySQL): {cache_key} (TTL: {ttl_value}s)")
elif self.backend == 'memory':
self._memory_cache[cache_key] = response
self._memory_cache[cache_key] = cacheable_response
self._memory_timestamps[cache_key] = time.time() + ttl_value
self._memory_access_order.append(cache_key)
self._memory_cache_cleanup()
......
......@@ -306,19 +306,79 @@ class DatabaseManager:
completion_tokens: Optional number of output/completion tokens
actual_cost: Optional actual cost returned by provider (in USD)
"""
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
logger.info(f"💾 DB.record_token_usage ENTERED: provider={provider_id}, tokens={tokens_used}, user_id={user_id}")
try:
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
# Convert latency to int for storage
latency_int = int(latency_ms) if latency_ms else 0
logger.info(f"DB.record_token_usage: provider={provider_id}, latency_ms={latency_ms} -> latency_int={latency_int}, success={success}, prompt={prompt_tokens}, completion={completion_tokens}, cost={actual_cost}")
cursor.execute(f'''
INSERT INTO token_usage (user_id, provider_id, model_name, tokens_used, prompt_tokens, completion_tokens, actual_cost, success, latency_ms, error_type, token_id, timestamp)
VALUES ({placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, CURRENT_TIMESTAMP)
''', (user_id, provider_id, model_name, tokens_used, prompt_tokens, completion_tokens, actual_cost, success, latency_int, error_type, token_id))
logger.info(f"🔍 DB.record_token_usage FULL PARAMETERS:")
logger.info(f" provider_id: {provider_id}")
logger.info(f" model_name: {model_name}")
logger.info(f" tokens_used: {tokens_used}")
logger.info(f" user_id: {user_id}")
logger.info(f" success: {success}")
logger.info(f" latency_ms: {latency_ms} → latency_int: {latency_int}")
logger.info(f" error_type: {error_type}")
logger.info(f" token_id: {token_id}")
logger.info(f" prompt_tokens: {prompt_tokens}")
logger.info(f" completion_tokens: {completion_tokens}")
logger.info(f" actual_cost: {actual_cost}")
logger.info(f" db_type: {self.db_type}")
logger.info(f" placeholder: {placeholder}")
logger.info(f"DB.record_token_usage: About to execute SQL - provider={provider_id}, tokens={tokens_used}, success={success}")
# Build dynamic INSERT based on available columns (for backward compatibility)
base_columns = ['user_id', 'provider_id', 'model_name', 'tokens_used', 'timestamp']
base_params = [user_id, provider_id, model_name, tokens_used]
# Check for additional columns and add them if they exist
try:
# Try to insert with all columns
sql = f'''
INSERT INTO token_usage (user_id, provider_id, model_name, tokens_used, prompt_tokens, completion_tokens, actual_cost, success, latency_ms, error_type, token_id, timestamp)
VALUES ({placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, CURRENT_TIMESTAMP)
'''
params = (user_id, provider_id, model_name, tokens_used, prompt_tokens, completion_tokens, actual_cost, success, latency_int, error_type, token_id)
logger.info(f"🔍 Trying full INSERT with {len(params)} parameters")
logger.debug(f"🔍 SQL: {sql}")
logger.debug(f"🔍 Params: {params}")
cursor.execute(sql, params)
logger.info(f"✅ Inserted with full column set, rows affected: {cursor.rowcount}")
except Exception as full_insert_error:
logger.warning(f"⚠️ Full column insert failed: {full_insert_error}")
logger.warning(f"⚠️ Full insert error type: {type(full_insert_error).__name__}")
import traceback
logger.warning(f"⚠️ Full insert traceback: {traceback.format_exc()}")
logger.info(f"🔍 Falling back to basic insert")
# Fallback to basic columns only
sql = f'''
INSERT INTO token_usage (user_id, provider_id, model_name, tokens_used, timestamp)
VALUES ({placeholder}, {placeholder}, {placeholder}, {placeholder}, CURRENT_TIMESTAMP)
'''
params = (user_id, provider_id, model_name, tokens_used)
logger.info(f"🔍 Trying basic INSERT with {len(params)} parameters")
logger.debug(f"🔍 SQL: {sql}")
logger.debug(f"🔍 Params: {params}")
cursor.execute(sql, params)
logger.info(f"✅ Inserted with basic column set, rows affected: {cursor.rowcount}")
conn.commit()
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)")
logger.info(f"✅ Successfully recorded token usage for {provider_id}/{model_name}: {tokens_used} tokens (user_id={user_id})")
except Exception as e:
logger.error(f"❌ Failed to record token usage for {provider_id}/{model_name}: {e}")
logger.error(f"Error details - user_id={user_id}, tokens={tokens_used}, success={success}")
# Try a simple test insert to see if database works
try:
with self._get_connection() as test_conn:
test_cursor = test_conn.cursor()
test_cursor.execute("INSERT INTO token_usage (provider_id, model_name, tokens_used, success) VALUES (?, 'test', 1, 1)" if self.db_type == 'sqlite' else "INSERT INTO token_usage (provider_id, model_name, tokens_used, success) VALUES (%s, 'test', 1, 1)", (f"test-{provider_id}",))
test_conn.commit()
logger.info("✅ Test database insert succeeded")
except Exception as test_e:
logger.error(f"❌ Even test database insert failed: {test_e}")
raise
def get_token_usage(
self,
......@@ -3087,10 +3147,110 @@ def DatabaseManager__initialize_database(self):
prompt_tokens INTEGER,
completion_tokens INTEGER,
actual_cost DECIMAL(10,6),
success BOOLEAN DEFAULT 1,
latency_ms INTEGER,
error_type VARCHAR(255),
token_id INTEGER,
timestamp TIMESTAMP DEFAULT {timestamp_default}
)
''')
#
# Migration: Add missing 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")
if 'success' not in columns:
cursor.execute('ALTER TABLE token_usage ADD COLUMN success BOOLEAN DEFAULT 1')
logger.info("✅ Migration: Added success column to token_usage")
if 'latency_ms' not in columns:
cursor.execute('ALTER TABLE token_usage ADD COLUMN latency_ms INTEGER')
logger.info("✅ Migration: Added latency_ms column to token_usage")
if 'error_type' not in columns:
cursor.execute('ALTER TABLE token_usage ADD COLUMN error_type VARCHAR(255)')
logger.info("✅ Migration: Added error_type column to token_usage")
if 'token_id' not in columns:
cursor.execute('ALTER TABLE token_usage ADD COLUMN token_id INTEGER')
logger.info("✅ Migration: Added token_id column to token_usage")
else: # mysql
# Check for prompt_tokens column
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")
# Check for completion_tokens column
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")
# Check for actual_cost column
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")
# Check for success column
cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'token_usage' AND COLUMN_NAME = 'success'
""")
if not cursor.fetchone():
cursor.execute('ALTER TABLE token_usage ADD COLUMN success BOOLEAN DEFAULT 1')
logger.info("✅ Migration: Added success column to token_usage")
# Check for latency_ms column
cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'token_usage' AND COLUMN_NAME = 'latency_ms'
""")
if not cursor.fetchone():
cursor.execute('ALTER TABLE token_usage ADD COLUMN latency_ms INTEGER')
logger.info("✅ Migration: Added latency_ms column to token_usage")
# Check for error_type column
cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'token_usage' AND COLUMN_NAME = 'error_type'
""")
if not cursor.fetchone():
cursor.execute('ALTER TABLE token_usage ADD COLUMN error_type VARCHAR(255)')
logger.info("✅ Migration: Added error_type column to token_usage")
# Check for token_id column
cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'token_usage' AND COLUMN_NAME = 'token_id'
""")
if not cursor.fetchone():
cursor.execute('ALTER TABLE token_usage ADD COLUMN token_id INTEGER')
logger.info("✅ Migration: Added token_id column to token_usage")
except Exception as e:
logger.warning(f"Migration check for token_usage columns: {e}")
#
#
#
#
# Create indexes for better query performance
......@@ -3590,6 +3750,7 @@ def DatabaseManager__initialize_database(self):
def DatabaseManager__create_config_tables(self, cursor, auto_increment, timestamp_default, boolean_type):
"""Create all permanent configuration tables (CONFIG DB ONLY) - UNUSED METHOD"""
# Migration code moved to _initialize_database method
pass # Method disabled
def DatabaseManager__create_cache_tables(self, cursor, auto_increment, timestamp_default, boolean_type):
......@@ -3606,6 +3767,10 @@ def DatabaseManager__create_cache_tables(self, cursor, auto_increment, timestamp
prompt_tokens INTEGER,
completion_tokens INTEGER,
actual_cost DECIMAL(10,6),
success BOOLEAN DEFAULT 1,
latency_ms INTEGER,
error_type VARCHAR(255),
token_id INTEGER,
timestamp TIMESTAMP DEFAULT {timestamp_default}
)
''')
......@@ -3833,46 +3998,7 @@ def DatabaseManager__run_config_migrations(self, cursor, auto_increment, timesta
except Exception as 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}")
# Migration code moved to _initialize_database method
conn.commit()
logger.info("✅ All database migrations completed")
......
......@@ -534,15 +534,41 @@ class RequestHandler:
latency_ms = (time.time() - request_start_time) * 1000
logger.info(f"Analytics: latency_ms={latency_ms:.2f}, request_start_time={request_start_time}, current_time={time.time()}")
if response and isinstance(response, dict):
usage = response.get('usage', {})
total_tokens = usage.get('total_tokens', 0)
prompt_tokens = usage.get('prompt_tokens', 0)
completion_tokens = usage.get('completion_tokens', 0)
if response:
# Handle both dict responses and OpenAI objects
usage = None
if isinstance(response, dict):
# Dict response (traditional API format)
usage = response.get('usage', {})
total_tokens = usage.get('total_tokens', 0)
prompt_tokens = usage.get('prompt_tokens', 0)
completion_tokens = usage.get('completion_tokens', 0)
elif hasattr(response, 'usage') and response.usage:
# OpenAI/Pydantic object with usage attribute
usage = response.usage
total_tokens = getattr(usage, 'total_tokens', 0)
prompt_tokens = getattr(usage, 'prompt_tokens', 0)
completion_tokens = getattr(usage, 'completion_tokens', 0)
logger.debug(f"Extracted usage from OpenAI object: total={total_tokens}, prompt={prompt_tokens}, completion={completion_tokens}")
# 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)
# Try to extract actual cost from provider response
try:
from .cost_extractor import extract_cost_from_response
actual_cost = extract_cost_from_response(response, provider_id)
except ImportError:
actual_cost = None
# Calculate estimated cost and log breakdown
try:
estimated_cost = analytics.estimate_cost(provider_id, total_tokens, prompt_tokens, completion_tokens)
logger.info(f"💰 Cost calculation breakdown for {provider_id}:")
logger.info(f" Tokens: total={total_tokens}, prompt={prompt_tokens}, completion={completion_tokens}")
logger.info(f" Estimated cost: ${estimated_cost:.8f} USD")
if actual_cost is not None:
logger.info(f" Actual cost: ${actual_cost:.8f} USD")
except Exception as cost_error:
logger.debug(f"Cost calculation failed: {cost_error}")
estimated_cost = 0.0
# If no token usage provided, estimate it with improved accuracy
if total_tokens == 0:
......
......@@ -21,8 +21,8 @@
"auth": {
"enabled": false,
"tokens": [
"your-secret-token-1",
"your-secret-token-2"
"api-token-1",
"api-token-2"
]
},
"mcp": {
......@@ -133,22 +133,27 @@
"from_email": "noreply@example.com",
"from_name": "AISBF"
},
"oauth2": {
"google": {
"enabled": false,
"client_id": "",
"client_secret": "",
"scopes": ["openid", "email", "profile"]
"scopes": [
"openid",
"email",
"profile"
]
},
"github": {
"enabled": false,
"client_id": "",
"client_secret": "",
"scopes": ["user:email", "read:user"]
"scopes": [
"user:email",
"read:user"
]
}
},
"billing": {
"currency": "USD",
"currency_symbol": "$",
......
......@@ -6,5 +6,18 @@
"max_context": 8000
},
"providers": {
"kilo": {
"id": "kilo",
"name": "Kilocode (OAuth2)",
"endpoint": "https://api.kilo.ai",
"type": "kilo",
"api_key_required": false,
"is_subscription": true,
"rate_limit": 0,
"kilo_config": {
"credentials_file": "~/.kilo_credentials.json"
},
"models": []
}
}
}
......@@ -1277,9 +1277,10 @@ async def shutdown_event():
async def auth_middleware(request: Request, call_next):
"""Check API token authentication if enabled"""
if server_config and server_config.get('auth_enabled', False):
# Skip auth for root endpoint, dashboard routes, favicon, and browser metadata
# Skip auth for root endpoint, dashboard routes, admin API routes, favicon, and browser metadata
if (request.url.path == "/" or
request.url.path.startswith("/dashboard") or
request.url.path.startswith("/api/admin") or
request.url.path == "/favicon.ico" or
request.url.path.startswith("/.well-known/")):
response = await call_next(request)
......@@ -1982,8 +1983,20 @@ async def dashboard_analytics(
except ValueError:
pass
# Handle preset time ranges
if time_range == 'yesterday':
# Yesterday: from 00:00:00 to 23:59:59 of previous day
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
from_datetime = today - timedelta(days=1)
to_datetime = today - timedelta(microseconds=1)
elif time_range == 'custom':
# If custom date range is provided, use it
if not from_datetime or not to_datetime:
# Default to last 24h if custom selected but no dates provided
time_range = '24h'
# If custom date range is provided, use custom mode
if from_datetime and to_datetime:
if from_datetime and to_datetime and time_range not in ['yesterday']:
time_range = 'custom'
# Check user role and apply user restriction
......@@ -3530,8 +3543,48 @@ def require_dashboard_auth(request: Request):
return None
def require_api_auth(request: Request):
"""Check if user is logged in to dashboard (API version - returns JSON)"""
if not request.session.get('logged_in'):
return JSONResponse(
status_code=401,
content={"error": "Authentication required"}
)
# Check if session has expired
expires_at = request.session.get('expires_at')
if expires_at and int(time.time()) > expires_at:
# Session expired
request.session.clear()
return JSONResponse(
status_code=401,
content={"error": "Session expired"}
)
# Extend session expiry for remember me users on each request (sliding expiration)
if request.session.get('remember_me'):
# Refresh expiry to 30 days from now for remember me
request.session['expires_at'] = int(time.time()) + 30 * 24 * 60 * 60
elif expires_at:
# For non-remember-me sessions, refresh to 2 weeks from now (sliding expiration)
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
return None
def require_api_admin(request: Request):
"""Check if user is admin (API version - returns JSON)"""
auth_check = require_api_auth(request)
if auth_check:
return auth_check
if request.session.get('role') != 'admin':
return JSONResponse(
status_code=403,
content={"error": "Admin access required"}
)
return None
def require_admin(request: Request):
"""Check if user is admin"""
"""Check if user is admin (dashboard version - returns redirects)"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
......@@ -6246,7 +6299,7 @@ async def dashboard_admin_tiers(request: Request):
@app.get("/api/admin/tiers")
async def api_list_tiers(request: Request):
"""List all tiers - API endpoint"""
auth_check = require_admin(request)
auth_check = require_api_admin(request)
if auth_check:
return auth_check
......@@ -6257,9 +6310,9 @@ async def api_list_tiers(request: Request):
return JSONResponse(tiers)
@app.get("/api/admin/tiers/{tier_id}")
async def api_get_tier(request: Request, tier_id: int):
"""Get a specific tier by ID - API endpoint"""
auth_check = require_admin(request)
async def api_get_tier(tier_id: int, request: Request):
"""Get specific tier - API endpoint"""
auth_check = require_api_admin(request)
if auth_check:
return auth_check
......@@ -6275,7 +6328,7 @@ async def api_get_tier(request: Request, tier_id: int):
@app.post("/api/admin/tiers")
async def api_create_tier(request: Request):
"""Create a new tier - API endpoint"""
auth_check = require_admin(request)
auth_check = require_api_admin(request)
if auth_check:
return auth_check
......@@ -6309,7 +6362,7 @@ async def api_create_tier(request: Request):
@app.put("/api/admin/tiers/{tier_id}")
async def api_update_tier(request: Request, tier_id: int):
"""Update an existing tier - API endpoint"""
auth_check = require_admin(request)
auth_check = require_api_admin(request)
if auth_check:
return auth_check
......@@ -6361,7 +6414,7 @@ async def api_update_tier(request: Request, tier_id: int):
@app.delete("/api/admin/tiers/{tier_id}")
async def api_delete_tier(request: Request, tier_id: int):
"""Delete a tier - API endpoint"""
auth_check = require_admin(request)
auth_check = require_api_admin(request)
if auth_check:
return auth_check
......@@ -6467,7 +6520,7 @@ async def dashboard_admin_tier_save(request: Request):
@app.get("/api/admin/settings/currency")
async def api_get_currency_settings(request: Request):
"""Get currency settings - API endpoint"""
auth_check = require_admin(request)
auth_check = require_api_admin(request)
if auth_check:
return auth_check
......@@ -6482,7 +6535,7 @@ async def api_get_currency_settings(request: Request):
@app.post("/api/admin/settings/currency")
async def api_save_currency_settings(request: Request):
"""Save currency settings - API endpoint"""
auth_check = require_admin(request)
auth_check = require_api_admin(request)
if auth_check:
return auth_check
......@@ -6504,7 +6557,7 @@ async def api_save_currency_settings(request: Request):
@app.get("/api/admin/settings/payment-gateways")
async def api_get_payment_gateways(request: Request):
"""Get payment gateway settings - API endpoint"""
auth_check = require_admin(request)
auth_check = require_api_admin(request)
if auth_check:
return auth_check
......@@ -6519,7 +6572,7 @@ async def api_get_payment_gateways(request: Request):
@app.post("/api/admin/settings/payment-gateways")
async def api_save_payment_gateways(request: Request):
"""Save payment gateway settings - API endpoint"""
auth_check = require_admin(request)
auth_check = require_api_admin(request)
if auth_check:
return auth_check
......@@ -6540,7 +6593,7 @@ async def api_save_payment_gateways(request: Request):
@app.get("/api/admin/settings/encryption-key")
async def api_get_encryption_key_status(request: Request):
"""Get encryption key status - API endpoint"""
auth_check = require_admin(request)
auth_check = require_api_admin(request)
if auth_check:
return auth_check
......@@ -6572,7 +6625,7 @@ async def api_get_encryption_key_status(request: Request):
@app.get("/api/admin/crypto/prices")
async def api_get_crypto_prices(request: Request):
"""Get crypto prices (BTC, ETH, USDT, USDC) from all enabled sources - API endpoint"""
auth_check = require_admin(request)
auth_check = require_api_admin(request)
if auth_check:
return auth_check
......@@ -6685,7 +6738,7 @@ async def api_get_crypto_prices(request: Request):
@app.get("/api/admin/crypto/btc-prices")
async def api_get_btc_prices(request: Request):
"""Get BTC prices from all enabled sources - API endpoint (legacy, redirects to /prices)"""
auth_check = require_admin(request)
auth_check = require_api_admin(request)
if auth_check:
return auth_check
......@@ -6705,7 +6758,7 @@ async def api_get_btc_prices(request: Request):
@app.post("/api/admin/settings/encryption-key")
async def api_save_encryption_key(request: Request):
"""Save encryption key - API endpoint"""
auth_check = require_admin(request)
auth_check = require_api_admin(request)
if auth_check:
return auth_check
......@@ -6736,7 +6789,7 @@ async def api_save_encryption_key(request: Request):
@app.get("/api/admin/config/price-sources")
async def get_price_sources(request: Request):
"""Get crypto price source configuration"""
auth_check = require_admin(request)
auth_check = require_api_admin(request)
if auth_check:
return auth_check
......@@ -6767,7 +6820,7 @@ async def get_price_sources(request: Request):
@app.put("/api/admin/payment-system/config/price-sources")
async def update_payment_price_sources(request: Request):
"""Update crypto price source configuration"""
auth_check = require_admin(request)
auth_check = require_api_admin(request)
if auth_check:
return auth_check
......@@ -6811,7 +6864,7 @@ async def update_price_sources(request: Request):
@app.get("/api/admin/config/consolidation")
async def get_consolidation_config(request: Request):
"""Get wallet consolidation configuration"""
auth_check = require_admin(request)
auth_check = require_api_admin(request)
if auth_check:
return auth_check
......@@ -6841,7 +6894,7 @@ async def get_consolidation_config(request: Request):
@app.put("/api/admin/payment-system/config/consolidation")
async def update_payment_consolidation_config(request: Request):
"""Update wallet consolidation configuration"""
auth_check = require_admin(request)
auth_check = require_api_admin(request)
if auth_check:
return auth_check
......@@ -6923,7 +6976,7 @@ async def update_consolidation_config(request: Request):
@app.get("/api/admin/config/email")
async def get_email_config(request: Request):
"""Get email notification configuration"""
auth_check = require_admin(request)
auth_check = require_api_admin(request)
if auth_check:
return auth_check
......@@ -6975,7 +7028,7 @@ async def get_email_config(request: Request):
@app.put("/api/admin/payment-system/config/email")
async def update_payment_email_config(request: Request):
"""Update email notification configuration"""
auth_check = require_admin(request)
auth_check = require_api_admin(request)
if auth_check:
return auth_check
......@@ -7059,7 +7112,7 @@ async def update_email_config(request: Request):
@app.put("/api/admin/payment-system/config/blockchain")
async def update_payment_blockchain_config(request: Request):
"""Update blockchain monitoring configuration"""
auth_check = require_admin(request)
auth_check = require_api_admin(request)
if auth_check:
return auth_check
......@@ -7098,7 +7151,7 @@ async def update_payment_blockchain_config(request: Request):
@app.get("/api/admin/scheduler/status")
async def get_scheduler_status(request: Request):
"""Get payment scheduler status"""
auth_check = require_admin(request)
auth_check = require_api_admin(request)
if auth_check:
return auth_check
......@@ -7121,7 +7174,7 @@ async def get_scheduler_status(request: Request):
@app.post("/api/admin/scheduler/run-job")
async def run_scheduler_job(request: Request):
"""Manually trigger a scheduler job"""
auth_check = require_admin(request)
auth_check = require_api_admin(request)
if auth_check:
return auth_check
......@@ -7150,7 +7203,7 @@ async def run_scheduler_job(request: Request):
@app.get("/api/admin/payment-system/status")
async def get_payment_system_status(request: Request):
"""Get payment system status including master keys, balances, and payment counts"""
auth_check = require_admin(request)
auth_check = require_api_admin(request)
if auth_check:
return auth_check
......@@ -7212,7 +7265,7 @@ async def get_payment_system_status(request: Request):
@app.get("/api/admin/payment-system/config")
async def get_payment_system_config(request: Request):
"""Get all payment system configuration"""
auth_check = require_admin(request)
auth_check = require_api_admin(request)
if auth_check:
return auth_check
......
......@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "aisbf"
version = "0.99.32"
version = "0.99.33"
description = "AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations"
readme = "README.md"
license = "GPL-3.0-or-later"
......
......@@ -49,7 +49,7 @@ class InstallCommand(_install):
setup(
name="aisbf",
version="0.99.32",
version="0.99.33",
author="AISBF Contributors",
author_email="stefy@nexlab.net",
description="AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations",
......@@ -110,6 +110,7 @@ setup(
'aisbf/batching.py',
'aisbf/cache.py',
'aisbf/classifier.py',
'aisbf/cost_extractor.py',
'aisbf/streaming_optimization.py',
'aisbf/analytics.py',
'aisbf/email_utils.py',
......
......@@ -60,7 +60,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<select name="time_range" id="timeRangeSelect" style="width: 100%; padding: 10px; border-radius: 4px; background: #0f3460; color: white; border: 1px solid #2a4a7a;">
<option value="1h" {% if selected_time_range == '1h' %}selected{% endif %}>Last 1 Hour</option>
<option value="6h" {% if selected_time_range == '6h' %}selected{% endif %}>Last 6 Hours</option>
<option value="24h" {% if selected_time_range == '24h' %}selected{% endif %}>Last 24 Hours</option>
<option value="24h" {% if selected_time_range == '24h' %}selected{% endif %}>Last 24 Hours (Default)</option>
<option value="yesterday" {% if selected_time_range == 'yesterday' %}selected{% endif %}>Yesterday</option>
<option value="7d" {% if selected_time_range == '7d' %}selected{% endif %}>Last 7 Days</option>
<option value="30d" {% if selected_time_range == '30d' %}selected{% endif %}>Last 30 Days</option>
<option value="90d" {% if selected_time_range == '90d' %}selected{% endif %}>Last 90 Days</option>
......
......@@ -1485,6 +1485,243 @@ async function pollKiloAuth(providerKey, deviceCode) {
// This function is no longer needed as polling is handled in authenticateKilo
}
function removeProvider(key) {
if (confirm(`Remove provider "${key}"?`)) {
delete providersData[key];
expandedProviders.delete(key);
renderProvidersList();
}
}
function updateProvider(key, field, value) {
providersData[key][field] = value;
}
function updateKiroConfig(key, field, value) {
if (!providersData[key].kiro_config) {
providersData[key].kiro_config = {};
}
providersData[key].kiro_config[field] = value;
}
function updateClaudeConfig(key, field, value) {
if (!providersData[key].claude_config) {
providersData[key].claude_config = {};
}
providersData[key].claude_config[field] = value;
}
function updateKiloConfig(key, field, value) {
if (!providersData[key].kilo_config) {
providersData[key].kilo_config = {};
}
providersData[key].kilo_config[field] = value;
}
function updateQwenConfig(key, field, value) {
if (!providersData[key].qwen_config) {
providersData[key].qwen_config = {};
}
providersData[key].qwen_config[field] = value;
}
function updateCodexConfig(key, field, value) {
if (!providersData[key].codex_config) {
providersData[key].codex_config = {};
}
providersData[key].codex_config[field] = value;
}
function updateProviderType(key, value) {
providersData[key].type = value;
// Re-render to update the configuration fields
renderProvidersList();
}
function updateProviderCondenseMethod(key, value) {
const trimmed = value.trim();
if (!trimmed) {
providersData[key].default_condense_method = null;
return;
}
// Check if it's a comma-separated list
if (trimmed.includes(',')) {
providersData[key].default_condense_method = trimmed.split(',').map(s => s.trim()).filter(s => s);
} else {
providersData[key].default_condense_method = trimmed;
}
}
function addModel(key) {
if (!providersData[key].models) {
providersData[key].models = [];
}
providersData[key].models.push({
name: '',
rate_limit: 0
});
renderModels(key);
}
function removeModel(providerKey, index) {
if (confirm('Remove this model?')) {
providersData[providerKey].models.splice(index, 1);
renderModels(providerKey);
}
}
async function confirmAddProvider() {
const key = document.getElementById('new-provider-key').value.trim();
const type = document.getElementById('new-provider-type').value;
if (!key) {
alert('Please enter a provider key');
return;
}
if (providersData[key]) {
alert('Provider key already exists');
return;
}
// Create new provider with defaults
providersData[key] = {
id: key,
name: key.charAt(0).toUpperCase() + key.slice(1),
endpoint: getDefaultEndpoint(type),
type: type,
api_key_required: type !== 'ollama' && type !== 'kiro' && type !== 'claude' && type !== 'kilocode' && type !== 'qwen' && type !== 'codex',
rate_limit: 0,
models: []
};
// Add type-specific config
if (type === 'kiro') {
providersData[key].kiro_config = {
region: 'us-east-1',
creds_file: '',
sqlite_db: '',
refresh_token: '',
profile_arn: '',
client_id: '',
client_secret: ''
};
} else if (type === 'claude') {
providersData[key].claude_config = {
credentials_file: '~/.claude_credentials.json'
};
} else if (type === 'kilocode') {
providersData[key].kilo_config = {
credentials_file: '~/.kilo_credentials.json',
api_base: 'https://api.kilo.ai/api/gateway'
};
} else if (type === 'qwen') {
providersData[key].qwen_config = {
credentials_file: '~/.aisbf/qwen_credentials.json',
api_key: '',
region: 'china-beijing',
workspace_id: 'Default Workspace'
};
} else if (type === 'codex') {
providersData[key].codex_config = {
credentials_file: '~/.aisbf/codex_credentials.json',
issuer: 'https://auth.openai.com'
};
}
cancelAddProvider();
renderProvidersList();
}
function getDefaultEndpoint(type) {
const defaults = {
'openai': 'https://api.openai.com/v1',
'google': 'https://generativelanguage.googleapis.com/v1beta',
'anthropic': 'https://api.anthropic.com/v1',
'ollama': 'http://localhost:11434/api',
'kiro': 'https://q.us-east-1.amazonaws.com',
'claude': 'https://api.anthropic.com/v1',
'kilocode': 'https://api.kilo.ai/api/gateway',
'qwen': 'https://dashscope.aliyuncs.com/compatible-mode/v1',
'codex': 'https://api.openai.com/v1'
};
return defaults[type] || '';
}
async function getModelsFromProvider(key) {
const statusDiv = document.getElementById(`get-models-status-${key}`);
if (!statusDiv) return;
statusDiv.innerHTML = 'Fetching models...';
try {
const response = await fetch('/dashboard/providers/get-models', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
provider_key: key,
provider: providersData[key]
})
});
const result = await response.json();
if (response.ok && result.models) {
providersData[key].models = result.models;
renderModels(key);
statusDiv.innerHTML = `✅ Found ${result.models.length} models`;
} else {
statusDiv.innerHTML = `❌ Error: ${result.error || 'Failed to fetch models'}`;
}
} catch (error) {
statusDiv.innerHTML = `❌ Error: ${error.message}`;
}
}
function updateModel(providerKey, index, field, value) {
providersData[providerKey].models[index][field] = value;
}
function updateModelCondenseMethod(providerKey, index, value) {
const trimmed = value.trim();
if (!trimmed) {
providersData[providerKey].models[index].condense_method = null;
return;
}
// Check if it's a comma-separated list
if (trimmed.includes(',')) {
providersData[providerKey].models[index].condense_method = trimmed.split(',').map(s => s.trim()).filter(s => s);
} else {
providersData[providerKey].models[index].condense_method = trimmed;
}
}
async function saveProviders() {
try {
const response = await fetch('/dashboard/providers', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'config=' + encodeURIComponent(JSON.stringify(providersData, null, 2))
});
if (response.ok) {
window.location.href = '/dashboard/providers?success=1';
} else {
alert('Error saving configuration');
}
} catch (error) {
alert('Error: ' + error.message);
}
}
// Initialize providers list immediately (DOM is already loaded since script is at end of body)
console.log('Initializing providers list...');
console.log('providersData:', Object.keys(providersData));
......
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