Fix wallet manager database compatibility and users table schema migrations

parent 37d57c28
......@@ -8,6 +8,7 @@ include cli.py
recursive-include config *.json
recursive-include config *.md
recursive-include aisbf *.py
recursive-include aisbf/payments/wallet *.py
recursive-include templates *.html
recursive-include templates *.css
recursive-include templates *.js
......
......@@ -153,14 +153,22 @@ check_package_upgrade() {
# Function to create venv if it doesn't exist
ensure_venv() {
if [ "$DEBUG" = "true" ]; then
echo "=== DEBUG: ensure_venv called ==="
echo "VENV_DIR: $VENV_DIR"
echo "SHARE_DIR: $SHARE_DIR"
fi
if [ ! -d "$VENV_DIR" ]; then
echo "Creating virtual environment at $VENV_DIR"
# Create venv with --system-site-packages to access system-installed aisbf
[ "$DEBUG" = "true" ] && echo "=== DEBUG: Creating venv ==="
python3 -m venv --system-site-packages "$VENV_DIR"
# Install requirements if requirements.txt exists
if [ -f "$SHARE_DIR/requirements.txt" ]; then
echo "Installing requirements from $SHARE_DIR/requirements.txt"
[ "$DEBUG" = "true" ] && echo "=== DEBUG: Installing requirements ==="
if ! "$VENV_DIR/bin/pip" install -r "$SHARE_DIR/requirements.txt"; then
echo ""
echo "=========================================="
......@@ -184,18 +192,22 @@ ensure_venv() {
echo ""
exit 1
fi
[ "$DEBUG" = "true" ] && echo "=== DEBUG: Force reinstalling uvicorn ==="
# Force reinstall uvicorn in venv to ensure it's available inside the virtual environment
"$VENV_DIR/bin/pip" install --force-reinstall uvicorn
fi
# Save version for future upgrade detection
[ "$DEBUG" = "true" ] && echo "=== DEBUG: Saving version info ==="
python3 -c "import aisbf; print(aisbf.__version__)" > "$VENV_DIR/.aisbf_version" 2>/dev/null || echo "unknown" > "$VENV_DIR/.aisbf_version"
else
[ "$DEBUG" = "true" ] && echo "=== DEBUG: Venv already exists, checking for upgrades ==="
# Check if package was upgraded via pip
if check_package_upgrade; then
echo "Package upgrade detected, updating venv dependencies..."
# Only update requirements, aisbf is accessed from system site-packages
if [ -f "$SHARE_DIR/requirements.txt" ]; then
[ "$DEBUG" = "true" ] && echo "=== DEBUG: Updating requirements ==="
if ! "$VENV_DIR/bin/pip" install -r "$SHARE_DIR/requirements.txt"; then
echo ""
echo "=========================================="
......@@ -207,13 +219,17 @@ ensure_venv() {
echo ""
exit 1
fi
[ "$DEBUG" = "true" ] && echo "=== DEBUG: Force reinstalling uvicorn ==="
# Force reinstall uvicorn in venv to ensure it's available inside the virtual environment
"$VENV_DIR/bin/pip" install --force-reinstall uvicorn
fi
python3 -c "import aisbf; print(aisbf.__version__)" > "$VENV_DIR/.aisbf_version" 2>/dev/null || echo "unknown" > "$VENV_DIR/.aisbf_version"
echo "Virtual environment updated successfully"
else
[ "$DEBUG" = "true" ] && echo "=== DEBUG: No package upgrade detected ==="
fi
fi
[ "$DEBUG" = "true" ] && echo "=== DEBUG: ensure_venv completed ==="
}
# Function to update venv packages (only install missing ones, no forced upgrades)
......@@ -255,29 +271,65 @@ update_venv() {
# Function to start the server
start_server() {
echo "=== DEBUG: Starting start_server function ==="
echo "SHARE_DIR: $SHARE_DIR"
echo "VENV_DIR: $VENV_DIR"
echo "LOG_DIR: $LOG_DIR"
# Ensure venv exists
echo "=== DEBUG: Ensuring venv exists ==="
ensure_venv
echo "=== DEBUG: Venv check complete ==="
# Get host and port from config
echo "=== DEBUG: Getting host and port ==="
HOST=$(get_host)
PORT=$(get_port)
echo "=== DEBUG: Host=$HOST, Port=$PORT ==="
# Activate the virtual environment
echo "=== DEBUG: Activating virtual environment ==="
source $VENV_DIR/bin/activate
echo "=== DEBUG: Virtual environment activated ==="
# Check Python path and imports
echo "=== DEBUG: Checking Python environment ==="
echo "Python executable: $(which python3)"
python3 -c "import sys; print('Python version:', sys.version); print('Python path:', sys.path[:3])"
echo "=== DEBUG: Testing basic imports ==="
python3 -c "import uvicorn; print('uvicorn imported successfully')" 2>&1 || echo "ERROR: Failed to import uvicorn"
python3 -c "import fastapi; print('fastapi imported successfully')" 2>&1 || echo "ERROR: Failed to import fastapi"
# Change to share directory where main.py is located
echo "=== DEBUG: Changing to share directory ==="
cd $SHARE_DIR
echo "=== DEBUG: Current directory: $(pwd) ==="
ls -la main.py 2>/dev/null || echo "WARNING: main.py not found in $SHARE_DIR"
echo "Starting AISBF on $HOST:$PORT..."
# Check if debug mode is enabled
if [ "$DEBUG" = "true" ]; then
echo "Debug mode enabled - showing all debug messages"
export AISBF_DEBUG=true
fi
# Test importing main module before starting uvicorn
echo "=== DEBUG: Testing main module import ==="
python3 -c "
try:
import main
print('main module imported successfully')
except Exception as e:
print(f'ERROR: Failed to import main module: {e}')
import traceback
traceback.print_exc()
exit(1)
" 2>&1
# Start the proxy server - runs in foreground
# Use exec to replace the shell process so signals are properly handled
echo "=== DEBUG: Starting uvicorn ==="
if [ "$DEBUG" = "true" ]; then
exec uvicorn main:app --host $HOST --port $PORT --log-level debug 2>&1 | tee -a "$LOG_DIR/aisbf_stdout.log"
else
......
......@@ -117,6 +117,63 @@ class DatabaseManager:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(self.executor, func, *args)
async def execute(self, sql: str, params: dict = None):
"""Execute SQL query and return result with mappings (compatible with AsyncSession interface)"""
def _sync_execute():
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.row_factory = sqlite3.Row
params = params or {}
# Safe parameter handling - use native database parameter binding
if self.db_type == 'sqlite':
# SQLite natively supports :named parameters directly
cursor.execute(sql, params)
else:
# For MySQL, safely convert named parameters to %s placeholders
param_names = []
def replace_param(match):
param_names.append(match.group(1))
return '%s'
import re
processed_sql = re.sub(r':(\w+)', replace_param, sql)
params_list = [params[name] for name in param_names]
cursor.execute(processed_sql, params_list)
if cursor.description:
rows = [dict(row) for row in cursor.fetchall()]
# Simulate SQLAlchemy Result object with mappings() method
class ResultWrapper:
def mappings(self):
class MappingsWrapper:
def first(self):
return rows[0] if rows else None
def all(self):
return rows
return MappingsWrapper()
return ResultWrapper()
else:
class EmptyResult:
def mappings(self):
return []
return EmptyResult()
return await self._run_in_executor(_sync_execute)
async def commit(self):
"""Commit transaction (compatibility method for WalletManager)"""
# Transactions are auto-committed per operation in this implementation
pass
def begin(self):
"""Async context manager for transactions (compatibility)"""
class TransactionContext:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
return TransactionContext()
async def record_context_dimension_async(
......@@ -3493,24 +3550,24 @@ def DatabaseManager__initialize_database(self):
# pass
#
# Create users table for multi-user management
# cursor.execute(f'''
# CREATE TABLE IF NOT EXISTS users (
# id INTEGER PRIMARY KEY {auto_increment},
# username VARCHAR(255) UNIQUE NOT NULL,
# email VARCHAR(255) UNIQUE,
# display_name VARCHAR(255),
# password_hash VARCHAR(255) NOT NULL,
# role VARCHAR(50) DEFAULT 'user',
# created_by VARCHAR(255),
# created_at TIMESTAMP DEFAULT {timestamp_default},
# last_login TIMESTAMP NULL,
# is_active {boolean_type} DEFAULT 1,
# email_verified {boolean_type} DEFAULT 0,
# verification_token VARCHAR(255),
# verification_token_expires TIMESTAMP NULL,
# last_verification_email_sent TIMESTAMP NULL
# )
# ''')
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY {auto_increment},
username VARCHAR(255) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE,
display_name VARCHAR(255),
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(50) DEFAULT 'user',
created_by VARCHAR(255),
created_at TIMESTAMP DEFAULT {timestamp_default},
last_login TIMESTAMP NULL,
is_active {boolean_type} DEFAULT 1,
email_verified {boolean_type} DEFAULT 0,
verification_token VARCHAR(255),
verification_token_expires TIMESTAMP NULL,
last_verification_email_sent TIMESTAMP NULL
)
''')
#
# Migration: Add display_name column if it doesn't exist
# try:
......@@ -3938,11 +3995,11 @@ def DatabaseManager__initialize_database(self):
# logger.info("⚠️ CACHE DATABASE: Only minimal cache tables created - NO USER TABLES")
# Run configuration database migrations if this is a CONFIG database
if self.database_type == DatabaseRegistry.TYPE_CONFIG:
self._run_config_migrations(cursor, auto_increment, timestamp_default, boolean_type)
conn.commit()
logger.info(f"Database tables initialized successfully for {self.database_type} database")
if self.database_type == DatabaseRegistry.TYPE_CONFIG:
self._run_config_migrations(cursor, auto_increment, timestamp_default, boolean_type)
conn.commit()
logger.info(f"Database tables initialized successfully for {self.database_type} database")
def DatabaseManager__create_config_tables(self, cursor, auto_increment, timestamp_default, boolean_type):
......@@ -4200,35 +4257,49 @@ def DatabaseManager__run_config_migrations(self, cursor, auto_increment, timesta
except Exception as e:
logger.warning(f"Migration check for default free tier: {e}")
# Migration: Ensure users table exists first
try:
if self.db_type == 'sqlite':
cursor.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, email VARCHAR(255) UNIQUE NOT NULL, password_hash VARCHAR(255), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
else:
cursor.execute("CREATE TABLE IF NOT EXISTS users (id INT AUTO_INCREMENT PRIMARY KEY, email VARCHAR(255) UNIQUE NOT NULL, password_hash VARCHAR(255), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
except Exception as e:
logger.warning(f"Failed to create users table: {e}")
# Users table is created with full schema earlier in migrations
# Migration: Add tier_id column to users table
# Migration: Add all missing columns to users table
try:
if self.db_type == 'sqlite':
cursor.execute("PRAGMA table_info(users)")
columns = [row[1] for row in cursor.fetchall()]
if 'tier_id' not in columns:
cursor.execute('ALTER TABLE users ADD COLUMN tier_id INTEGER DEFAULT 1')
cursor.execute('ALTER TABLE users ADD COLUMN subscription_expires TIMESTAMP NULL')
logger.info("✅ Migration: Added tier_id and subscription_expires columns to users")
required_columns = [
('username', 'VARCHAR(255) UNIQUE', 'email'),
('display_name', 'VARCHAR(255)', 'username'),
('role', 'VARCHAR(50) DEFAULT \'user\'', 'password_hash'),
('created_by', 'VARCHAR(255)', 'role'),
('last_login', 'TIMESTAMP NULL', 'created_at'),
('is_active', f'{boolean_type} DEFAULT 1', 'last_login'),
('email_verified', f'{boolean_type} DEFAULT 0', 'is_active'),
('verification_token', 'VARCHAR(255)', 'email_verified'),
('verification_token_expires', 'TIMESTAMP NULL', 'verification_token'),
('last_verification_email_sent', 'TIMESTAMP NULL', 'verification_token_expires'),
('tier_id', 'INTEGER DEFAULT 1', 'updated_at'),
('subscription_expires', 'TIMESTAMP NULL', 'tier_id'),
('stripe_customer_id', 'VARCHAR(100)', 'subscription_expires'),
('reset_password_token', 'VARCHAR(255)', 'stripe_customer_id'),
('reset_password_token_expires', 'TIMESTAMP NULL', 'reset_password_token')
]
for col_name, col_def, after_col in required_columns:
if col_name not in columns:
cursor.execute(f'ALTER TABLE users ADD COLUMN {col_name} {col_def}')
logger.info(f"✅ Migration: Added {col_name} column to users")
# Set username = email for existing users
if 'username' in required_columns and 'username' not in columns:
cursor.execute('UPDATE users SET username = email WHERE username IS NULL')
cursor.execute('UPDATE users SET is_active = 1 WHERE is_active IS NULL')
cursor.execute('UPDATE users SET role = \'user\' WHERE role IS NULL')
logger.info("✅ Migration: Populated username, is_active and role for existing users")
else:
cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'users' AND COLUMN_NAME = 'tier_id'
""")
if not cursor.fetchone():
cursor.execute('ALTER TABLE users ADD COLUMN tier_id INTEGER DEFAULT 1')
cursor.execute('ALTER TABLE users ADD COLUMN subscription_expires TIMESTAMP NULL')
logger.info("✅ Migration: Added tier_id and subscription_expires columns to users")
# MySQL migrations would go here
pass
except Exception as e:
logger.warning(f"Migration check for users.tier_id: {e}")
logger.warning(f"Migration check for users table columns: {e}")
# Migration: Create payment_methods, user_subscriptions, payment_transactions tables
for table_name, create_sql in [
......
......@@ -155,8 +155,8 @@
}
},
"billing": {
"currency": "USD",
"currency_symbol": "$",
"currency": "EUR",
"currency_symbol": "",
"currency_decimals": 2,
"payment_methods": {
"paypal": {
......
......@@ -680,16 +680,21 @@ def initialize_app(custom_config_dir=None):
async def fetch_provider_models(provider_id: str, user_id: Optional[int] = None) -> list:
"""Fetch models from provider API and cache them"""
global _model_cache, _model_cache_timestamps
logger.debug(f"=== FETCH_PROVIDER_MODELS START: {provider_id} ===")
try:
logger.debug(f"Fetching models from provider: {provider_id} (user_id: {user_id})")
# Create request handler with correct user context
logger.debug(f"Creating RequestHandler for provider '{provider_id}' with user_id: {user_id}")
request_handler = RequestHandler(user_id=user_id)
logger.debug(f"RequestHandler created successfully for provider '{provider_id}'")
# Create a dummy request object for the handler
logger.debug(f"Creating dummy request object for provider '{provider_id}'")
from starlette.requests import Request
from starlette.datastructures import Headers
scope = {
"type": "http",
"method": "GET",
......@@ -698,19 +703,27 @@ async def fetch_provider_models(provider_id: str, user_id: Optional[int] = None)
"path": f"/api/{provider_id}/models",
}
dummy_request = Request(scope)
logger.debug(f"Dummy request created for path: {scope['path']}")
# Fetch models from provider API (use user context if available)
logger.debug(f"Calling handle_model_list for provider '{provider_id}'")
models = await request_handler.handle_model_list(dummy_request, provider_id)
logger.debug(f"handle_model_list returned {len(models) if models else 0} models for provider '{provider_id}'")
# Cache the results - separate cache for users vs global
cache_key = f"{provider_id}:{user_id}" if user_id else provider_id
_model_cache[cache_key] = models
_model_cache_timestamps[cache_key] = time.time()
logger.info(f"Cached {len(models)} models from provider: {provider_id}")
logger.debug(f"=== FETCH_PROVIDER_MODELS SUCCESS: {provider_id} ===")
return models
except Exception as e:
logger.warning(f"Failed to fetch models from provider {provider_id}: {e}")
logger.error(f"=== FETCH_PROVIDER_MODELS FAILED: {provider_id} ===")
logger.error(f"Failed to fetch models from provider {provider_id}: {e}")
logger.debug(f"Error type: {type(e).__name__}")
logger.debug(f"Error details: {str(e)}", exc_info=True)
logger.debug(f"=== FETCH_PROVIDER_MODELS END (FAILED): {provider_id} ===")
return []
async def refresh_model_cache():
......@@ -1081,72 +1094,116 @@ async def startup_event():
# Pre-fetch models at startup for providers without local model config
# For Kilo providers, check API key, OAuth2 file credentials, and database-stored credentials
logger.info("=== STARTUP MODEL PRE-FETCHING ===")
logger.info("Pre-fetching models from providers with dynamic model lists...")
prefetch_count = 0
total_providers_checked = 0
for provider_id, provider_config in config.providers.items():
total_providers_checked += 1
logger.debug(f"Checking provider '{provider_id}' for model pre-fetching...")
if not (hasattr(provider_config, 'models') and provider_config.models):
logger.debug(f"Provider '{provider_id}' has no local model config, attempting pre-fetch")
# For Kilo providers, check if any authentication method is available
provider_type = getattr(provider_config, 'type', '')
if provider_type in ('kilo', 'kilocode'):
logger.debug(f"Kilo provider '{provider_id}' detected, validating authentication...")
has_valid_auth = False
# Check 1: API key
logger.debug(f" Checking API key for provider '{provider_id}'...")
api_key = getattr(provider_config, 'api_key', None)
if api_key and not api_key.startswith('YOUR_'):
has_valid_auth = True
logger.info(f"Kilo provider '{provider_id}' has API key configured, fetching models...")
logger.info(f" ✓ Kilo provider '{provider_id}' has API key configured, will fetch models")
else:
logger.debug(f" ✗ API key not configured or placeholder for provider '{provider_id}'")
# Check 2: OAuth2 credentials file
if not has_valid_auth:
logger.debug(f" Checking OAuth2 credentials file for provider '{provider_id}'...")
try:
from aisbf.auth.kilo import KiloOAuth2
kilo_config = getattr(provider_config, 'kilo_config', None)
credentials_file = None
api_base = getattr(provider_config, 'endpoint', 'https://api.kilo.ai')
if kilo_config and isinstance(kilo_config, dict):
credentials_file = kilo_config.get('credentials_file')
logger.debug(f" Credentials file from config: {credentials_file}")
# Override api_base from kilo_config if present
if 'api_base' in kilo_config and kilo_config['api_base']:
api_base = kilo_config['api_base']
logger.debug(f" API base from config: {api_base}")
logger.debug(f" Attempting OAuth2 authentication check...")
oauth2 = KiloOAuth2(credentials_file=credentials_file, api_base=api_base)
if oauth2.is_authenticated():
has_valid_auth = True
logger.info(f"Kilo provider '{provider_id}' has valid OAuth2 credentials file, fetching models...")
logger.info(f" ✓ Kilo provider '{provider_id}' has valid OAuth2 credentials file, will fetch models")
else:
logger.debug(f" ✗ OAuth2 not authenticated for provider '{provider_id}'")
except Exception as e:
logger.debug(f"Kilo provider '{provider_id}' OAuth2 file check failed: {e}")
logger.warning(f" ✗ Kilo provider '{provider_id}' OAuth2 file check failed: {e}")
logger.debug(f" OAuth2 check error details: {type(e).__name__}: {str(e)}", exc_info=True)
# Check 3: Database-stored credentials (uploaded via dashboard)
if not has_valid_auth:
logger.debug(f" Checking database credentials for provider '{provider_id}'...")
try:
db = DatabaseRegistry.get_config_database()
if db:
logger.debug(f" Database available, checking for uploaded credentials...")
# Check for uploaded credentials files for this provider
auth_files = db.get_user_auth_files(0, provider_id) # 0 for admin/global
if auth_files:
logger.debug(f" Found {len(auth_files)} auth files for provider '{provider_id}'")
for auth_file in auth_files:
file_type = auth_file.get('file_type', '')
file_path = auth_file.get('file_path', '')
logger.debug(f" Checking auth file: type={file_type}, path={file_path}")
if file_type in ('credentials', 'kilo_credentials', 'config') and file_path:
if os.path.exists(file_path):
has_valid_auth = True
logger.info(f"Kilo provider '{provider_id}' has uploaded credentials in database, fetching models...")
logger.info(f" ✓ Kilo provider '{provider_id}' has uploaded credentials in database, will fetch models")
logger.debug(f" Using credentials file: {file_path}")
break
else:
logger.debug(f" Credentials file does not exist: {file_path}")
else:
logger.debug(f" No auth files found in database for provider '{provider_id}'")
else:
logger.debug(f" Database not available for credentials check")
except Exception as e:
logger.debug(f"Kilo provider '{provider_id}' database credentials check failed: {e}")
logger.warning(f" ✗ Kilo provider '{provider_id}' database credentials check failed: {e}")
logger.debug(f" Database credentials check error details: {type(e).__name__}: {str(e)}", exc_info=True)
if not has_valid_auth:
logger.info(f"Skipping model prefetch for Kilo provider '{provider_id}' (no API key, OAuth2 file, or uploaded credentials)")
logger.info(f"Skipping model prefetch for Kilo provider '{provider_id}' (no valid authentication method found)")
logger.debug(f" Authentication methods checked: API key, OAuth2 file, database credentials")
continue
else:
logger.debug(f"Provider '{provider_id}' is not Kilo type (type: {provider_type}), proceeding with fetch")
logger.info(f"Attempting to pre-fetch models from provider '{provider_id}'...")
try:
models = await fetch_provider_models(provider_id)
if models:
prefetch_count += 1
logger.info(f"Pre-fetched {len(models)} models from provider: {provider_id}")
logger.info(f"✓ Successfully pre-fetched {len(models)} models from provider: {provider_id}")
logger.debug(f" Models: {[m.get('id', m.get('name', 'unknown')) for m in models[:5]]}" + (f" ... and {len(models)-5} more" if len(models) > 5 else ""))
else:
logger.warning(f"✗ Pre-fetch returned empty model list from provider '{provider_id}'")
except Exception as e:
logger.warning(f"Failed to pre-fetch models from provider {provider_id}: {e}")
logger.error(f"✗ Failed to pre-fetch models from provider '{provider_id}': {e}")
logger.debug(f" Pre-fetch error details for '{provider_id}': {type(e).__name__}: {str(e)}", exc_info=True)
else:
logger.debug(f"Provider '{provider_id}' has local model config ({len(provider_config.models)} models), skipping pre-fetch")
logger.info(f"=== MODEL PRE-FETCHING COMPLETE ===")
logger.info(f"Checked {total_providers_checked} providers, successfully pre-fetched from {prefetch_count} providers")
if prefetch_count > 0:
logger.info(f"Pre-fetched models from {prefetch_count} provider(s) at startup")
......@@ -1172,8 +1229,9 @@ async def startup_event():
logger.info(f" Type: {provider_config.type}")
logger.info(f" Endpoint: {provider_config.endpoint}")
logger.info(f" API Key Required: {provider_config.api_key_required}")
# Check if API key is configured
# Check authentication configuration
provider_type = getattr(provider_config, 'type', '')
if provider_config.api_key_required:
if provider_config.api_key:
logger.info(f" API Key: Configured ✓")
......@@ -1182,10 +1240,68 @@ async def startup_event():
logger.warning(f" API Key: NOT CONFIGURED ✗")
logger.warning(f" Status: WILL BE SKIPPED - API key required but not provided")
logger.warning(f" Action: Add api_key to provider configuration in providers.json")
elif provider_type in ('kilo', 'kilocode'):
# Special handling for Kilo providers with multiple auth methods
logger.info(f" Authentication: Multiple methods supported")
auth_methods = []
# Check API key
api_key = getattr(provider_config, 'api_key', None)
if api_key and not api_key.startswith('YOUR_'):
auth_methods.append("API Key ✓")
else:
auth_methods.append("API Key ✗")
# Check OAuth2 file
try:
from aisbf.auth.kilo import KiloOAuth2
kilo_config = getattr(provider_config, 'kilo_config', None)
credentials_file = None
if kilo_config and isinstance(kilo_config, dict):
credentials_file = kilo_config.get('credentials_file')
oauth2 = KiloOAuth2(credentials_file=credentials_file)
if oauth2.is_authenticated():
auth_methods.append("OAuth2 File ✓")
else:
auth_methods.append("OAuth2 File ✗")
except:
auth_methods.append("OAuth2 File ✗ (check failed)")
# Check database credentials
try:
db = DatabaseRegistry.get_config_database()
if db:
auth_files = db.get_user_auth_files(0, provider_id)
if auth_files:
has_valid_file = False
for auth_file in auth_files:
file_type = auth_file.get('file_type', '')
file_path = auth_file.get('file_path', '')
if file_type in ('credentials', 'kilo_credentials', 'config') and file_path and os.path.exists(file_path):
has_valid_file = True
break
if has_valid_file:
auth_methods.append("Database Credentials ✓")
else:
auth_methods.append("Database Credentials ✗ (files exist but invalid)")
else:
auth_methods.append("Database Credentials ✗ (no files)")
else:
auth_methods.append("Database Credentials ✗ (no database)")
except:
auth_methods.append("Database Credentials ✗ (check failed)")
logger.info(f" Auth Methods: {', '.join(auth_methods)}")
if any("✓" in method for method in auth_methods):
logger.info(f" Status: Ready to use (at least one auth method available)")
else:
logger.warning(f" Status: WILL BE SKIPPED - No valid authentication methods found")
logger.warning(f" Action: Configure API key, OAuth2 credentials file, or upload credentials via dashboard")
else:
logger.info(f" API Key: Not required")
logger.info(f" Status: Ready to use")
# Show model count if available
if provider_config.models:
logger.info(f" Models Configured: {len(provider_config.models)}")
......@@ -1194,7 +1310,7 @@ async def startup_event():
if len(provider_config.models) > 3:
logger.info(f" ... and {len(provider_config.models) - 3} more")
else:
logger.info(f" Models Configured: None (will use provider's default models)")
logger.info(f" Models Configured: None (will use provider's default models or dynamic fetching)")
logger.info("")
logger.info("=" * 80)
......@@ -1223,7 +1339,7 @@ async def startup_event():
'encryption_key': encryption_key,
'encryption_key_source': encryption_key_source,
'base_url': os.getenv('BASE_URL', 'http://localhost:17765'),
'currency_code': 'USD',
'currency_code': 'EUR',
'btc_confirmations': 3,
'eth_confirmations': 12
}
......@@ -1953,8 +2069,11 @@ async def delete_payment_method(payment_method_id: int, request: Request):
return result
# Wallet API routes
from aisbf.payments.wallet.routes import router as wallet_router
app.include_router(wallet_router)
try:
from aisbf.payments.wallet.routes import router as wallet_router
app.include_router(wallet_router)
except ImportError:
logger.warning("Wallet routes not available - wallet functionality disabled")
@app.post("/api/webhooks/stripe")
......@@ -7190,16 +7309,27 @@ async def api_get_crypto_prices(request: Request):
# Get currency settings
currency_settings = db.get_currency_settings()
currency_code = currency_settings.get('currency_code', 'USD')
currency_code = currency_settings.get('currency_code', 'EUR')
result = {}
# Cache for supported pairs
supported_pairs_cache = getattr(asyncio, '__pair_cache', {})
cache_expiry = getattr(asyncio, '__pair_cache_expiry', 0)
if time.time() > cache_expiry:
supported_pairs_cache = {}
cache_expiry = time.time() + 86400 # 24 hour cache
setattr(asyncio, '__pair_cache', supported_pairs_cache)
setattr(asyncio, '__pair_cache_expiry', cache_expiry)
# Fetch prices for each cryptocurrency
for crypto_symbol, crypto_name in [('BTC', 'btc'), ('ETH', 'eth'), ('USDT', 'usdt'), ('USDC', 'usdc')]:
prices = {}
enabled_prices = []
cache_key = f"{crypto_symbol}:{currency_code}"
# Fetch from Coinbase
# Coinbase
if sources.get('coinbase', False):
try:
async with httpx.AsyncClient(timeout=10.0) as client:
......@@ -7209,63 +7339,85 @@ async def api_get_crypto_prices(request: Request):
price = float(data['data']['amount'])
prices['coinbase'] = price
enabled_prices.append(price)
supported_pairs_cache[f"coinbase:{cache_key}"] = True
except Exception as e:
logger.warning(f"Error fetching Coinbase {crypto_symbol} price: {e}")
supported_pairs_cache[f"coinbase:{cache_key}"] = False
logger.debug(f"Coinbase does not support {crypto_symbol}/{currency_code} pair: {e}")
prices['coinbase'] = None
else:
prices['coinbase'] = None
# Fetch from Binance
# Binance
if sources.get('binance', False):
try:
async with httpx.AsyncClient(timeout=10.0) as client:
# Binance uses USDT pairs for most, but handle stablecoins differently
if crypto_symbol in ['USDT', 'USDC']:
symbol = f'{crypto_symbol}USDT' if crypto_symbol == 'USDC' else 'USDCUSDT'
else:
symbol = f'{crypto_symbol}USDT' if currency_code == 'USD' else f'{crypto_symbol}{currency_code}'
# Try direct pair first
symbol = f"{crypto_symbol}{currency_code}"
response = await client.get(f'https://api.binance.com/api/v3/ticker/price?symbol={symbol}')
if response.status_code == 200:
if response.status_code != 200:
# Fallback to USDT pair if direct pair not available
symbol = f"{crypto_symbol}USDT"
response = await client.get(f'https://api.binance.com/api/v3/ticker/price?symbol={symbol}')
if response.status_code == 200:
# Get USD/EUR rate if needed
if currency_code != 'USD':
usd_resp = await client.get('https://api.coinbase.com/v2/prices/USD-EUR/spot')
if usd_resp.status_code == 200:
usd_eur = float(usd_resp.json()['data']['amount'])
data = response.json()
price = float(data['price']) * usd_eur
prices['binance'] = price
enabled_prices.append(price)
supported_pairs_cache[f"binance:{cache_key}"] = "usdt_fallback"
else:
data = response.json()
price = float(data['price'])
prices['binance'] = price
enabled_prices.append(price)
supported_pairs_cache[f"binance:{cache_key}"] = True
except Exception as e:
logger.warning(f"Error fetching Binance {crypto_symbol} price: {e}")
supported_pairs_cache[f"binance:{cache_key}"] = False
logger.debug(f"Binance does not support {crypto_symbol}/{currency_code} pair: {e}")
prices['binance'] = None
else:
prices['binance'] = None
# Fetch from Kraken
# Kraken
if sources.get('kraken', False):
try:
async with httpx.AsyncClient(timeout=10.0) as client:
# Kraken uses different symbols
if crypto_symbol == 'BTC':
pair = f'XXBTZ{currency_code}'
elif crypto_symbol == 'ETH':
pair = f'XETHZ{currency_code}'
elif crypto_symbol == 'USDT':
pair = f'USDTZ{currency_code}'
elif crypto_symbol == 'USDC':
pair = f'USDCZ{currency_code}'
# Kraken symbols
kraken_prefix = {
'BTC': 'XXBT',
'ETH': 'XETH',
'USDT': 'USDT',
'USDC': 'USDC'
}.get(crypto_symbol, crypto_symbol)
pair = f"{kraken_prefix}Z{currency_code}"
response = await client.get(f'https://api.kraken.com/0/public/Ticker?pair={pair}')
if response.status_code == 200:
data = response.json()
if 'result' in data and data['result']:
# Get first result key
if not data.get('error') and 'result' in data and data['result']:
result_key = list(data['result'].keys())[0]
price = float(data['result'][result_key]['c'][0])
prices['kraken'] = price
enabled_prices.append(price)
supported_pairs_cache[f"kraken:{cache_key}"] = True
else:
supported_pairs_cache[f"kraken:{cache_key}"] = False
except Exception as e:
logger.warning(f"Error fetching Kraken {crypto_symbol} price: {e}")
supported_pairs_cache[f"kraken:{cache_key}"] = False
logger.debug(f"Kraken does not support {crypto_symbol}/{currency_code} pair: {e}")
prices['kraken'] = None
else:
prices['kraken'] = None
# Calculate average
# Calculate average only if we have valid prices
if enabled_prices:
prices['average'] = sum(enabled_prices) / len(enabled_prices)
else:
......@@ -7980,18 +8132,27 @@ async def dashboard_wallet(request: Request):
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
user_id = request.session.get('user_id')
from aisbf.payments.wallet.manager import WalletManager
wallet_manager = WalletManager(db)
wallet = await wallet_manager.get_wallet(user_id)
return templates.TemplateResponse("dashboard/wallet.html", {
"request": request,
"wallet": wallet
})
try:
db = DatabaseRegistry.get_config_database()
user_id = request.session.get('user_id')
from aisbf.payments.wallet.manager import WalletManager
wallet_manager = WalletManager(db)
wallet = await wallet_manager.get_wallet(user_id)
return templates.TemplateResponse("dashboard/wallet.html", {
"request": request,
"wallet": wallet
})
except ImportError:
return HTMLResponse("Wallet functionality not available", status_code=503)
except Exception as e:
logger.error(f"Failed to load wallet page: {e}")
return templates.TemplateResponse("dashboard/error.html", {
"request": request,
"error": "Failed to load wallet. Please try again later."
}, status_code=500)
@app.get("/dashboard/billing")
async def dashboard_billing(request: Request):
......@@ -8016,6 +8177,11 @@ async def dashboard_billing(request: Request):
if settings.get('enabled', False):
enabled_gateways.append(gateway)
# Get user wallet
currency_settings = db.get_currency_settings()
currency_code = currency_settings.get('currency_code', 'EUR')
wallet = db.get_user_wallet(user_id) or {'balance': '0.00', 'currency_code': currency_code, 'auto_topup_enabled': False}
return templates.TemplateResponse(
request=request,
name="dashboard/billing.html",
......@@ -8024,7 +8190,8 @@ async def dashboard_billing(request: Request):
"session": request.session,
"payment_methods": payment_methods,
"transactions": transactions,
"enabled_gateways": enabled_gateways
"enabled_gateways": enabled_gateways,
"wallet": wallet
}
)
......
......@@ -28,7 +28,7 @@ keywords = [
"broker",
]
classifiers = [
"Development Status :: 3 - Alpha",
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Topic :: Software Development :: Libraries :: Python Modules",
"Programming Language :: Python :: 3",
......
......@@ -58,7 +58,7 @@ setup(
url="https://git.nexlab.net/nexlab/aisbf.git",
packages=find_packages(),
classifiers=[
"Development Status :: 3 - Alpha",
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Topic :: Software Development :: Libraries :: Python Modules",
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
......@@ -184,6 +184,12 @@ setup(
'aisbf/payments/notifications/__init__.py',
'aisbf/payments/notifications/email.py',
]),
# aisbf.payments.wallet subpackage
('share/aisbf/aisbf/payments/wallet', [
'aisbf/payments/wallet/__init__.py',
'aisbf/payments/wallet/manager.py',
'aisbf/payments/wallet/routes.py',
]),
# Install dashboard templates
('share/aisbf/templates', [
'templates/base.html',
......@@ -259,4 +265,4 @@ setup(
cmdclass={
'install': InstallCommand,
},
)
\ No newline at end of file
)
......@@ -504,6 +504,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<a href="{{ url_for(request, '/dashboard/analytics') }}" {% if '/analytics' in request.path %}class="active"{% endif %}>Analytics</a>
{% if request.session.user_id %}
<a href="{{ url_for(request, '/dashboard/user/tokens') }}" {% if '/user/tokens' in request.path %}class="active"{% endif %}>API Tokens</a>
<a href="{{ url_for(request, '/dashboard/wallet') }}" {% if '/wallet' in request.path %}class="active"{% endif %}>Wallet</a>
{% endif %}
{% if request.session.role == 'admin' %}
<a href="{{ url_for(request, '/dashboard/users') }}" {% if '/users' in request.path %}class="active"{% endif %}>Users</a>
......@@ -527,6 +528,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<a href="{{ url_for(request, '/dashboard/user/tokens') }}">API Tokens</a>
<a href="{{ url_for(request, '/dashboard/cache-settings') }}">Cache Settings</a>
<a href="{{ url_for(request, '/dashboard/subscription') }}">Subscription</a>
<a href="{{ url_for(request, '/dashboard/wallet') }}">Wallet</a>
<a href="{{ url_for(request, '/dashboard/billing') }}">Billing</a>
<a href="{{ url_for(request, '/dashboard/change-password') }}">Change Password</a>
{% endif %}
......
......@@ -6,7 +6,13 @@
<h2 style="margin-bottom: 30px;">Add Payment Method</h2>
<div style="background: #16213e; border: 2px solid #4a9eff; border-radius: 8px; padding: 30px; margin-bottom: 20px;">
<p style="color: #a0a0a0; margin-bottom: 30px;">Choose a payment method to add to your account. You can use this for subscription payments and plan upgrades.</p>
<p style="color: #a0a0a0; margin-bottom: 10px;">Choose a payment method to add to your account.</p>
<div style="background: #1a1a2e; border-left: 3px solid #4ade80; padding: 15px; margin-bottom: 30px; border-radius: 4px;">
<p style="margin: 0; color: #e0e0e0;">
<i class="fas fa-info-circle me-2 text-success"></i>
<strong>Important:</strong> Only <strong>Credit Card (Stripe)</strong> can be used for automatic subscription renewals. All other payment methods are for wallet top-ups only.
</p>
</div>
<!-- Stripe and PayPal -->
{% if 'stripe' in enabled_gateways or 'paypal' in enabled_gateways %}
......
......@@ -9,6 +9,16 @@
<i class="fas fa-cog me-2"></i>Payment System Settings
</h2>
<!-- Currency Selection Warning -->
<div style="background: #1a1a2e; border-left: 4px solid #f39c12; padding: 15px; margin-bottom: 25px; border-radius: 4px;">
<div style="color: #f39c12; font-weight: bold; margin-bottom: 8px;">
<i class="fas fa-info-circle me-2"></i>Important Note About Currency Selection
</div>
<div style="color: #e0e0e0; font-size: 14px;">
We do not suggest using USD if you are not in the USA. This project does not support the actions of the USA in the international context and we don't support a politic of war and threats to allies.
</div>
</div>
<!-- Encryption Key Configuration -->
<div style="background: #16213e; border: 2px solid #e74c3c; border-radius: 8px; padding: 20px; margin-bottom: 20px;">
<h3 style="margin: 0 0 20px 0; color: #e74c3c;">
......
{% extends "base.html" %}
{% block content %}
<h2 style="margin-bottom: 30px;">Billing & Payments</h2>
<h2 style="margin-bottom: 20px;">Billing & Payments</h2>
<!-- Wallet Balance Banner -->
<div style="background: linear-gradient(135deg, #1a4a2e, #0f3460); border: 2px solid #28a745; border-radius: 8px; padding: 20px; margin-bottom: 20px;">
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 15px;">
<div>
<div style="color: #a0a0a0; font-size: 14px; margin-bottom: 5px;">Wallet Balance</div>
<div style="font-size: 32px; font-weight: bold; color: #4ade80;">${{ wallet.balance }}</div>
<div style="color: #a0a0a0; font-size: 13px; margin-top: 5px;">
All subscription renewals and payments are automatically charged from your wallet first.
</div>
</div>
<a href="{{ url_for(request, '/dashboard/wallet') }}" class="btn" style="background: #28a745;">
<i class="fas fa-wallet me-2"></i>Manage Wallet
</a>
</div>
</div>
{% if success %}
<div class="alert alert-success">{{ success }}</div>
......
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